Linux · ネットワーク パケットの送信プロセスの図解

いくつかの小さな質問について考えてください。

  • 質問 1: データを送信するカーネルによって消費される CPU を見るとき、sy と si のどちらを見るべきですか?
  • 質問 2:サーバー上/proc/softirqs の NET_RX が NET_TX よりもはるかに大きいのはなぜですか?
  • Q3: ネットワーク データの送信にはどのようなメモリ コピー操作が必要ですか?

こうした問題はネット上でよく見られますが、私たちがそれを掘り下げることはほとんどないようです。これらをしっかりと理解することができれば、パフォーマンスをコントロールする能力はさらに強くなります。

これら 3 つの質問から、今日の Linux カーネル ネットワーク送信プロセスの詳細な分析を始めます。以前の伝統に従い、単純なコードから始めます。次のコードは、一般的なサーバー プログラムの一般的なマイクロコードです。

int main(){
 fd = socket(AF_INET, SOCK_STREAM, 0);
 bind(fd, ...);
 listen(fd, ...);

 cfd = accept(fd, ...);

 // 接收用户请求
 read(cfd, ...);

 // 用户请求处理
 dosometing(); 

 // 给用户返回结果
 send(cfd, buf, sizeof(buf), 0);
}

今日は、上記のコードで send を呼び出した後、カーネルがデータ パケットを送信する方法について説明します。この記事は Linux 3.10 に基づいており、ネットワーク カード ドライバーは例として Intel の igb ネットワーク カードを使用します。

警告: この記事には 10,000 文字以上、25 枚の写真が含まれています。長い記事には注意してください。

1. Linuxネットワーク送信処理の概要

Linux のソースコードを見るときに最も重要なことは、最初からさまざまな詳細に囚われるのではなく、全体を把握することだと思います。

ここでは一般的なフローチャートを用意し、send で送信されたデータがどのようにネットワーク カードに送信されるかを段階的に簡単に説明しました。

この図では、ユーザー データがカーネル状態にコピーされ、プロトコル スタックによって処理された後、RingBuffer に入力されることがわかります。次に、ネットワーク カード ドライバーが実際にデータを送信します。送信が完了すると、ハード割り込みを通じて CPU に通知され、RingBuffer がクリーンアップされます。

ソースコードは記事の後半で入力するため、ソースコードの観点からフローチャートを示します。

この時点でデータは送信されていますが、実はまだ行われていない重要なことが一つあり、それはキャッシュキューなどのメモリの解放です。

カーネルは、メモリを解放するタイミングをどのようにして知るのでしょうか (もちろんネットワークが送信された後です)。ネットワーク カードは送信を完了すると、CPU にハード割り込みを送信して CPU に通知します。より完全なプロセスについては、図を参照してください。

今日のトピックはデータの送信ですが、ハード割り込みによってトリガーされるソフト割り込みは NET_TX_SOFTIRQ ではなく NET_RX_SOFTIRQ であることに注意してください。(Tは送信の略、Rは受信を意味します)

驚きですか、驚きですか?? ?

それが、質問 1 を開いた理由の一部です (注、これは理由の一部にすぎません)。

質問 1: サーバー上の /proc/softirqs を確認してください。NET_RX が NET_TX よりはるかに大きいのはなぜですか?

転送が完了すると、最終的に NET_TX ではなく NET_RX がトリガーされます。したがって、当然のことながら、/proc/softirqs を観察すると、より多くの NET_RX を確認できます。

これで、カーネルがネットワーク パケットを送信する方法の全体像がわかりました。満足しないでください。私たちが知る必要がある詳細の方が価値があります。続けましょう。

2. ネットワークカードの起動準備

現在のサーバー上のネットワーク カードは通常、複数のキューをサポートしています。各キューは RingBuffer によって表され、複数のキューが有効になっているネットワーク カードには複数の RingBuffer があります。

ネットワーク カードの起動時に最も重要なタスクの 1 つは、RingBuffer の割り当てと初期化です。RingBuffer を理解することは、後で送信をマスターするのに非常に役立ちます。今日のテーマは送信ですので、送信キューを例にして、ネットワークカードの起動時に実際に RingBuffer を割り当てるプロセスを見てみましょう。

ネットワークカードの起動時に__igb_open関数が呼び出され、ここにRingBufferが割り当てられます。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static int __igb_open(struct net_device *netdev, bool resuming)
{
 struct igb_adapter *adapter = netdev_priv(netdev);

 //分配传输描述符数组
 err = igb_setup_all_tx_resources(adapter);

 //分配接收描述符数组
 err = igb_setup_all_rx_resources(adapter);

 //开启全部队列
 netif_tx_start_all_queues(netdev);
}

上記の __igb_open 関数では、igb_setup_all_tx_resources を呼び出してすべての送信リング バッファを割り当て、igb_setup_all_rx_resources を呼び出してすべての受信リング バッファを作成します。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static int igb_setup_all_tx_resources(struct igb_adapter *adapter)
{
 //有几个队列就构造几个 RingBuffer
 for (i = 0; i < adapter->num_tx_queues; i++) {
  igb_setup_tx_resources(adapter->tx_ring[i]);
 }
}

RingBuffer の実際の構築プロセスは igb_setup_tx_resources で完了します。

//file: drivers/net/ethernet/intel/igb/igb_main.c
int igb_setup_tx_resources(struct igb_ring *tx_ring)
{
 //1.申请 igb_tx_buffer 数组内存
 size = sizeof(struct igb_tx_buffer) * tx_ring->count;
 tx_ring->tx_buffer_info = vzalloc(size);

 //2.申请 e1000_adv_tx_desc DMA 数组内存
 tx_ring->size = tx_ring->count * sizeof(union e1000_adv_tx_desc);
 tx_ring->size = ALIGN(tx_ring->size, 4096);
 tx_ring->desc = dma_alloc_coherent(dev, tx_ring->size,
        &tx_ring->dma, GFP_KERNEL);

 //3.初始化队列成员
 tx_ring->next_to_use = 0;
 tx_ring->next_to_clean = 0;
}

上記のソース コードからわかるように、実際には、RingBuffer にはリング キュー配列が 1 つだけではなく 2 つあります。

1) igb_tx_buffer 配列: この配列はカーネルによって使用され、vzalloc を通じて適用されます。2) e1000_adv_tx_desc アレイ: このアレイはネットワーク カード ハードウェアによって使用され、ハードウェアは DMA を通じてこのメモリに直接アクセスし、dma_alloc_coherent を通じてメモリを割り当てることができます。

現時点では、それらの間に接続はありません。将来送信する場合、2 つのリング配列の同じ位置にあるポインターはすべて同じ skb を指すことになります。このようにして、カーネルとハードウェアは同じデータに共同でアクセスでき、カーネルはデータを skb に書き込み、ネットワーク カード ハードウェアはデータの送信を担当します。

最後に、netif_tx_start_all_queues を呼び出してキューを開始します。また、実際にはハード割り込み用の処理関数 igb_msix_ring が __igb_open に登録されます。

3、新しいソケットの作成を承認します

データを送信する前に、接続がすでに確立されているソケットが必要になることがよくあります。

サーバーのマイクロフォーム ソース コードで言及されている accept を例として取り上げます。受け入れた後、プロセスは新しいソケットを作成し、それを現在のプロセスのオープン ファイル リストに追加します。このソケットは、対応するクライアントとの通信に特に使用されます。コミュニケーション。

サーバー プロセスが accept を通じてクライアントとの 2 つの接続を確立したと仮定して、これら 2 つの接続とプロセスの関係を簡単に見てみましょう。

接続を表すソケットカーネルオブジェクトのより具体的な構造図は以下のとおりです。

煩雑になることを避けるため、ここではacceptのソースコード処理の詳細は紹介しませんが、興味のある方は「図解|epollでIO多重化を実現する仕組みを徹底解明!」を参照してください。。 」記事の最初の部分。

現在もデータ送信プロセスに重点を置いています。

4.実際にデータ送信が開始されます

4.1 sendシステムコールの実装

send システム コールのソース コードは、ファイル net/socket.c にあります。このシステムコールでは、実際に内部でsendtoシステムコールが使用されています。呼び出しチェーン全体は短くありませんが、実際に実行されるのは 2 つの単純なことだけです。

  • 1 つ目は、カーネル内の実際のソケットを見つけることであり、さまざまなプロトコル スタックの関数アドレスがこのオブジェクトに記録されます。
  • 2 つ目は、struct msghdr オブジェクトを構築し、バッファ アドレスやデータ長など、ユーザーによって渡されたすべてのデータをそのオブジェクトに入れることです。

残りは次の層、プロトコル スタック内の関数 inet_sendmsg に渡されます。ここで、inet_sendmsg 関数のアドレスは、ソケット カーネル オブジェクト内の ops メンバーを通じて見つかります。一般的なプロセスを図に示します。

上記を理解すると、ソース コードを確認するのがはるかに簡単になります。ソースコードは次のとおりです。

//file: net/socket.c
SYSCALL_DEFINE4(send, int, fd, void __user *, buff, size_t, len,
  unsigned int, flags)
{
 return sys_sendto(fd, buff, len, flags, NULL, 0);
}

SYSCALL_DEFINE6(......)
{
 //1.根据 fd 查找到 socket
 sock = sockfd_lookup_light(fd, &err, &fput_needed);

 //2.构造 msghdr
 struct msghdr msg;
 struct iovec iov;

 iov.iov_base = buff;
 iov.iov_len = len;
 msg.msg_iovlen = 1;

 msg.msg_iov = &iov;
 msg.msg_flags = flags;
 ......

 //3.发送数据
 sock_sendmsg(sock, &msg, len);
}

ソースコードからわかるように、ユーザーモードで使用するsend関数とsendto関数は、実際にはsendtoシステムコールによって実装されています。send は、便宜上カプセル化された呼び出しやすい方法にすぎません。

sendto システムコールでは、ユーザーが渡したソケットハンドル番号に従って、実際のソケットカーネルオブジェクトが最初に検索されます。次に、buff、len、flag、およびユーザーが要求したその他のパラメーターを struct msghdr オブジェクトにパックします。

次に、sock_sendmsg => __sock_sendmsg ==> __sock_sendmsg_nosec を呼び出します。__sock_sendmsg_nosec では、呼び出しはシステム コールからプロトコル スタックに入ります。そのソース コードを見てみましょう。

//file: net/socket.c
static inline int __sock_sendmsg_nosec(...)
{
 ......
 return sock->ops->sendmsg(iocb, sock, msg, size);
}

3 番目のセクションのソケット カーネル オブジェクト構造図を見ると、ここで呼び出されているのは sock->ops->sendmsg であり、実際には inet_sendmsg が実行されていることがわかります。この関数は、AF_INET プロトコル ファミリによって提供される一般的な送信関数です。

4.2 トランスポート層の処理

1) トランスポート層のコピー

プロトコル スタック inet_sendmsg に入ると、カーネルはソケット上の特定のプロトコル送信関数を見つけます。TCP プロトコルの場合、それは tcp_sendmsg です (これもソケット カーネル オブジェクトを通じて見つかります)。

この関数では、カーネルはカーネル モードの skb メモリを申請し、ユーザーが送信するデータをそこにコピーします。なお、この時点では実際に送信が開始されない場合があり、送信条件が満たされない場合は直接折り返し電話となる可能性がございます。おおよそのプロセスは次の図に示すとおりです。

inet_sendmsg 関数のソース コードを見てみましょう。

//file: net/ipv4/af_inet.c
int inet_sendmsg(......)
{
 ......
 return sk->sk_prot->sendmsg(iocb, sk, msg, size);
}

この関数では、特定のプロトコルの送信関数が呼び出されます。3 番目のセクションのソケット カーネル オブジェクト構造図も参照してください。TCP プロトコルに基づくソケットの場合、sk->sk_prot->sendmsg が tcp_sendmsg (UPD の場合は udp_sendmsg) を指していることがわかります。

関数 tcp_sendmsg は比較的長いので、何回か見てみましょう。まずこれを見てください

//file: net/ipv4/tcp.c
int tcp_sendmsg(...)
{
 while(...){
  while(...){
   //获取发送队列
   skb = tcp_write_queue_tail(sk);

   //申请skb 并拷贝
   ......
  }
 }
}

//file: include/net/tcp.h
static inline struct sk_buff *tcp_write_queue_tail(const struct sock *sk)
{
 return skb_peek_tail(&sk->sk_write_queue);
}

送信を理解するには、ソケットでの tcp_write_queue_tail の呼び出しを理解することが前提条件です。上に示したように、この関数はソケット送信キュー内の最後の skb を取得することです。skb は struct sk_buff オブジェクトの略で、ユーザの送信キューはこのオブジェクトから構成されるリンクリストです。

tcp_sendmsg の他の部分を見てみましょう。

//file: net/ipv4/tcp.c
int tcp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
  size_t size)
{
 //获取用户传递过来的数据和标志
 iov = msg->msg_iov; //用户数据地址
 iovlen = msg->msg_iovlen; //数据块数为1
 flags = msg->msg_flags; //各种标志

 //遍历用户层的数据块
 while (--iovlen >= 0) {

  //待发送数据块的地址
  unsigned char __user *from = iov->iov_base;

  while (seglen > 0) {

   //需要申请新的 skb
   if (copy <= 0) {

    //申请 skb,并添加到发送队列的尾部
    skb = sk_stream_alloc_skb(sk,
         select_size(sk, sg),
         sk->sk_allocation);

    //把 skb 挂到socket的发送队列上
    skb_entail(sk, skb);
   }

   // skb 中有足够的空间
   if (skb_availroom(skb) > 0) {
    //拷贝用户空间的数据到内核空间,同时计算校验和
    //from是用户空间的数据地址 
    skb_add_data_nocache(sk, skb, from, copy);
   } 
   ......

この関数は比較的長いですが、ロジックは複雑ではありません。このうちmsg->msg_iovは送信するデータのバッファをユーザーモードメモリに格納します。次に、skbなどのカーネルステートでカーネルメモリを申請し、ユーザーメモリのデータをカーネルステートメモリにコピーします。これには、1 つまたは複数のメモリ コピーのオーバーヘッドが伴います

カーネルが実際に skb を送信するタイミングについては。一部の判断は tcp_sendmsg で行われます。

//file: net/ipv4/tcp.c
int tcp_sendmsg(...)
{
 while(...){
  while(...){
   //申请内核内存并进行拷贝

   //发送判断
   if (forced_push(tp)) {
    tcp_mark_push(tp, skb);
    __tcp_push_pending_frames(sk, mss_now, TCP_NAGLE_PUSH);
   } else if (skb == tcp_send_head(sk))
    tcp_push_one(sk, mss_now);  
   }
   continue;
  }
 }
}

Forced_push(tp) または skb == tcp_send_head(sk) が満たされる場合にのみ、カーネルは実際にデータ パケットの送信を開始します。このうち、forced_push(tp)は、未送信データが最大ウィンドウの半分を超えているかどうかを判定します。

条件が満たされない場合は、ユーザーが今回送信したいデータがカーネルにコピーされるだけでジョブは終了します。

2) トランスポート層送信

カーネルの送信条件が満たされたと仮定して、実際の送信プロセスを追跡してみましょう。前節の関数では、実際の送信条件が満たされると、__tcp_push_pending_frames を呼び出しても tcp_push_one を呼び出しても、最後に実際には tcp_write_xmit が実行されます。

したがって、tcp_write_xmit から直接見てみると、この関数はトランスポート層の輻輳制御とスライディング ウィンドウに関連する作業を処理します。ウィンドウ要件が満たされている場合、TCP ヘッダーを設定し、処理のために skb を下位ネットワーク層に渡します。

tcp_write_xmit のソースコードを見てみましょう。

//file: net/ipv4/tcp_output.c
static bool tcp_write_xmit(struct sock *sk, unsigned int mss_now, int nonagle,
      int push_one, gfp_t gfp)
{
 //循环获取待发送 skb
 while ((skb = tcp_send_head(sk))) 
 {
  //滑动窗口相关
  cwnd_quota = tcp_cwnd_test(tp, skb);
  tcp_snd_wnd_test(tp, skb, mss_now);
  tcp_mss_split_point(...);
  tso_fragment(sk, skb, ...);
  ......

  //真正开启发送
  tcp_transmit_skb(sk, skb, 1, gfp);
 }
}

ネットワーク プロトコルで学習したスライディング ウィンドウと輻輳制御がこの関数で完了していることがわかりますが、この部分はあまり拡張されませんので、興味のある学生はこのソース コードを見つけて自分で読むことができます。今日は送信のメインプロセスのみを見て、その後 tcp_transmit_skb に進みます。

//file: net/ipv4/tcp_output.c
static int tcp_transmit_skb(struct sock *sk, struct sk_buff *skb, int clone_it,
    gfp_t gfp_mask)
{
 //1.克隆新 skb 出来
 if (likely(clone_it)) {
  skb = skb_clone(skb, gfp_mask);
  ......
 }

 //2.封装 TCP 头
 th = tcp_hdr(skb);
 th->source  = inet->inet_sport;
 th->dest  = inet->inet_dport;
 th->window  = ...;
 th->urg   = ...;
 ......

 //3.调用网络层发送接口
 err = icsk->icsk_af_ops->queue_xmit(skb, &inet->cork.fl);
}

まず、新しい skb を複製する必要があります。ここでは、なぜ skb をコピーする必要があるのか​​に焦点を当てます。

これは、skb が後でネットワーク層を呼び出しており、最終的にネットワーク カードが送信されるときに skb が解放されるためです。また、TCP プロトコルは消失再送をサポートしており、相手から ACK を受信するまではこの skb を削除できないこともわかっています。したがって、カーネルの方法では、送信のためにネットワーク カードが呼び出されるたびに、実際に渡されるのは skb のコピーになります。実際に削除する前に、ACK が受信されるまで待ってください。

2 番目のことは、skb の TCP ヘッダーを変更し、実際の状況に応じて TCP ヘッダーを設定することです。ここでちょっとしたトリックを紹介します。skb には実際にはネットワーク プロトコルのすべてのヘッダーが含まれています。TCP ヘッダーを設定するときは、ポインタを skb の適切な位置にポイントするだけです。後で IP ヘッダーを設定する場合は、ポインタを移動するだけでメモリの頻繁な適用とコピーを回避できるため、非常に効率的です。

tcp_transmit_skb は、トランスポート層でデータを送信する最後のステップであり、その後、次の操作層のためにネットワーク層に入ることができます。ネットワーク層によって提供される送信インターフェイス icsk->icsk_af_ops->queue_xmit() が呼び出されます。

以下のソース コードでは、queue_xmit が実際には ip_queue_xmit 関数を指していることがわかります。

//file: net/ipv4/tcp_ipv4.c
const struct inet_connection_sock_af_ops ipv4_specific = {
 .queue_xmit    = ip_queue_xmit,
 .send_check    = tcp_v4_send_check,
 ...
}

それ以来、トランスポート層の作業は完了しました。データはトランスポート層を出て、ネットワーク層のカーネルの実装に入ります。

4.3 ネットワーク層送信処理

Linux カーネルのネットワーク層での送信の実装は、ファイル net/ipv4/ip_output.c にあります。トランスポート層によって呼び出される ip_queue_xmit もここにあります。(ファイル名からもIP層に入り、ソースファイル名がtcp_xxxからip_xxxに変わっていることがわかります。)

ネットワーク層では、主にルーティング アイテムの検索、IP ヘッダーの設定、ネットフィルター フィルタリング、skb セグメンテーション (MTU より大きい場合) などのいくつかのタスクを処理します。これらのタスクの処理後、下位のネイバーに引き渡されます。処理用のサブシステム。

ネットワーク層エントリ関数 ip_queue_xmit のソース コードを見てみましょう。

//file: net/ipv4/ip_output.c
int ip_queue_xmit(struct sk_buff *skb, struct flowi *fl)
{
 //检查 socket 中是否有缓存的路由表
 rt = (struct rtable *)__sk_dst_check(sk, 0);
 if (rt == NULL) {
  //没有缓存则展开查找
  //则查找路由项, 并缓存到 socket 中
  rt = ip_route_output_ports(...);
  sk_setup_caps(sk, &rt->dst);
 }

 //为 skb 设置路由表
 skb_dst_set_noref(skb, &rt->dst);

 //设置 IP header
 iph = ip_hdr(skb);
 iph->protocol = sk->sk_protocol;
 iph->ttl      = ip_select_ttl(inet, &rt->dst);
 iph->frag_off = ...;

 //发送
 ip_local_out(skb);
}

ip_queue_xmit がネットワーク層に到達しました。この関数では、ネットワーク層に関連する関数ルーティング項目検索が表示されます。見つかった場合は skb に設定されます (ルートがない場合は、直接エラーを報告して戻ります) )。

Linux では、route コマンドを使用してローカル マシンのルーティング構成を確認できます。

ルーティング テーブルでは、宛先ネットワークがどの Iface (ネットワーク カード) とどのゲートウェイ (ネットワーク カード) を経由して送信するかを確認できます。検索が見つかるとソケットにキャッシュされ、次回データが送信されるときに確認する必要はありません。

次に、ルーティングテーブルのアドレスもskbに入れます。

//file: include/linux/skbuff.h
struct sk_buff {
 //保存了一些路由相关信息
 unsigned long  _skb_refdst;
}

次のステップでは、skb 内の IP ヘッダーの位置を特定し、プロトコル仕様に従って IP ヘッダーの設定を開始します。

次に、ip_local_out を通じて次のステップに進みます。

//file: net/ipv4/ip_output.c  
int ip_local_out(struct sk_buff *skb)
{
 //执行 netfilter 过滤
 err = __ip_local_out(skb);

 //开始发送数据
 if (likely(err == 1))
  err = dst_output(skb);
 ......

ip_local_out => __ip_local_out => nf_hook では、netfilter フィルタリングが実行されます。iptables を使用していくつかのルールを設定する場合、ここでルールがヒットするかどうかがチェックされます。非常に複雑な netfilter ルールを設定した場合、この機能によりプロセスの CPU オーバーヘッドが大幅に増加します

まだそれについてはあまり話さず、dst_output の送信に関連するプロセスについてだけ話続けます。

//file: include/net/dst.h
static inline int dst_output(struct sk_buff *skb)
{
 return skb_dst(skb)->output(skb);
}

この関数は、この skb へのルーティング テーブル (dst エントリ) を検索し、ルーティング テーブルの出力メソッドを呼び出します。これも関数ポインタであり、ip_output メソッドを指します。

//file: net/ipv4/ip_output.c
int ip_output(struct sk_buff *skb)
{
 //统计
 .....

 //再次交给 netfilter,完毕后回调 ip_finish_output
 return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING, skb, NULL, dev,
    ip_finish_output,
    !(IPCB(skb)->flags & IPSKB_REROUTED));
}

ip_output で簡単な統計処理を実行し、netfilter フィルタリングを再度実行します。フィルタリング後に ip_finish_output をコールバックします。

//file: net/ipv4/ip_output.c
static int ip_finish_output(struct sk_buff *skb)
{
 //大于 mtu 的话就要进行分片了
 if (skb->len > ip_skb_dst_mtu(skb) && !skb_is_gso(skb))
  return ip_fragment(skb, ip_finish_output2);
 else
  return ip_finish_output2(skb);
}

ip_finish_output では、データが MTU より大きい場合、断片化が実行されることがわかります。

実際の MTU サイズは MTU ディスカバリによって決定され、イーサネット フレームは 1500 バイトです。初期の頃、QQ チームはデータ パケットのサイズを MTU よりも小さくなるように制御し、この方法でネットワーク パフォーマンスを最適化しようとしました。フラグメンテーションは次の 2 つの問題を引き起こすためです。 1. 追加のセグメント化処理が必要となり、パフォーマンスのオーバーヘッドが追加されます。2. フラグメントが失われる限り、パケット全体を再送信する必要があります。したがって、フラグメンテーションを回避すると、フラグメンテーションのオーバーヘッドが排除されるだけでなく、再送信率も大幅に低下します。

ip_finish_output2 では、最後の送信プロセスは次の層である隣接サブシステムに入ります。

//file: net/ipv4/ip_output.c
static inline int ip_finish_output2(struct sk_buff *skb)
{
 //根据下一跳 IP 地址查找邻居项,找不到就创建一个
 nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr);  
 neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
 if (unlikely(!neigh))
  neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);

 //继续向下层传递
 int res = dst_neigh_output(dst, neigh, skb);
}

4.4 近隣サブシステム

隣接サブシステムは、ネットワーク層とデータリンク層の間に位置するシステムであり、その機能は、ネットワーク層にカプセル化を提供することで、ネットワーク層が下位層のアドレス情報を気にする必要がなく、下位層はどの MAC アドレスに送信するかを決定します。

また、この近隣サブシステムは、プロトコル スタック net/ipv4/ ディレクトリではなく、net/core/neighbor.c にあります。このモジュールは IPv4 と IPv6 の両方に必要であるためです。

隣接サブシステムでは、主に隣接エントリを検索または作成することが目的であり、隣接エントリの作成時に実際の ARP 要求が送信される場合があります。次に、MAC ヘッダーをカプセル化し、送信プロセスを下位レベルのネットワーク デバイス サブシステムに渡します。一般的なプロセスを図に示します。

大まかな処理を理解したところで、ソースコードを振り返ってみましょう。__ipv4_neigh_lookup_noref は、上記のセクションの ip_finish_output2 のソース コードで呼び出されます。これは arp キャッシュ内を検索し、その 2 番目のパラメータはルートのネクストホップ IP 情報です。

//file: include/net/arp.h
extern struct neigh_table arp_tbl;
static inline struct neighbour *__ipv4_neigh_lookup_noref(
 struct net_device *dev, u32 key)
{
 struct neigh_hash_table *nht = rcu_dereference_bh(arp_tbl.nht);

 //计算 hash 值,加速查找
 hash_val = arp_hashfn(......);
 for (n = rcu_dereference_bh(nht->hash_buckets[hash_val]);
   n != NULL;
   n = rcu_dereference_bh(n->next)) {
  if (n->dev == dev && *(u32 *)n->primary_key == key)
   return n;
 }
}

見つからない場合は、__neigh_create を呼び出してネイバーを作成します。

//file: net/core/neighbour.c
struct neighbour *__neigh_create(......)
{
 //申请邻居表项
 struct neighbour *n1, *rc, *n = neigh_alloc(tbl, dev);

 //构造赋值
 memcpy(n->primary_key, pkey, key_len);
 n->dev = dev;
 n->parms->neigh_setup(n);

 //最后添加到邻居 hashtable 中
 rcu_assign_pointer(nht->hash_buckets[hash_val], n);
 ......

隣接エントリを取得した後、宛先 MAC アドレスがまだ取得されていないため、現時点では IP パケットを送信する機能はまだありません。dst_neigh_output を呼び出して、skb を渡し続けます。

//file: include/net/dst.h
static inline int dst_neigh_output(struct dst_entry *dst, 
     struct neighbour *n, struct sk_buff *skb)
{
 ......
 return n->output(n, skb);
}

Output の呼び出しは、実際には neigh_resolve_output を指します。この関数内で arp ネットワーク リクエストを発行することができます。

//file: net/core/neighbour.c
int neigh_resolve_output(){

 //注意:这里可能会触发 arp 请求
 if (!neigh_event_send(neigh, skb)) {

  //neigh->ha 是 MAC 地址
  dev_hard_header(skb, dev, ntohs(skb->protocol),
           neigh->ha, NULL, skb->len);
  //发送
  dev_queue_xmit(skb);
 }
}

ハードウェア MAC アドレスを取得したら、skb の MAC ヘッダーをカプセル化できます。最後に、dev_queue_xmit を呼び出して、skb を Linux ネットワーク デバイス サブシステムに渡します。

4.5 ネットワーク機器サブシステム

近隣サブシステムは、dev_queue_xmit を通じてネットワーク デバイス サブシステムに入ります。

//file: net/core/dev.c 
int dev_queue_xmit(struct sk_buff *skb)
{
 //选择发送队列
 txq = netdev_pick_tx(dev, skb);

 //获取与此队列关联的排队规则
 q = rcu_dereference_bh(txq->qdisc);

 //如果有队列,则调用__dev_xmit_skb 继续处理数据
 if (q->enqueue) {
  rc = __dev_xmit_skb(skb, q, dev, txq);
  goto out;
 }

 //没有队列的是回环设备和隧道设备
 ......
}

冒頭の章の 2 番目のセクションでは、ネットワーク カードの開始の準備で、ネットワーク カード (特に現在のネットワーク カード) には複数の送信キューがあると述べました。上記の netdev_pick_tx 関数の呼び出しは、送信するキューを選択することです。

netdev_pick_tx 送信キューの選択は XPS などの構成に影響され、キャッシュもあり、これも小規模で複雑なロジックの集合です。ここでは 2 つのロジックにのみ焦点を当てます。まず、ユーザーの XPS 構成が取得され、それ以外の場合は自動的に計算されます。コードについては、netdev_pick_tx => __netdev_pick_tx を参照してください。

//file: net/core/flow_dissector.c
u16 __netdev_pick_tx(struct net_device *dev, struct sk_buff *skb)
{
 //获取 XPS 配置
 int new_index = get_xps_queue(dev, skb);

 //自动计算队列
 if (new_index < 0)
  new_index = skb_tx_hash(dev, skb);}

次に、このキューに関連付けられた qdisc を取得します。qdisc タイプは、Linux の tc コマンドで確認できます。たとえば、私のマルチキュー ネットワーク カード マシンの 1 つでは mq disc です。

#tc qdisc
qdisc mq 0: dev eth0 root

ほとんどのデバイスにはキューがあるため (ループバック デバイスとトンネル デバイスを除く)、__dev_xmit_skb に進みます。

//file: net/core/dev.c
static inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q,
     struct net_device *dev,
     struct netdev_queue *txq)
{
 //1.如果可以绕开排队系统
 if ((q->flags & TCQ_F_CAN_BYPASS) && !qdisc_qlen(q) &&
     qdisc_run_begin(q)) {
  ......
 }

 //2.正常排队
 else {

  //入队
  q->enqueue(skb, q)

  //开始发送
  __qdisc_run(q);
 }
}

上記のコードには 2 つの状況があり、1 つはキュー システムをバイパスできる状況、もう 1 つは通常のキューイングです。2 番目のケースのみを見ていきます。

まず q->enqueue を呼び出して、skb をキューに追加します。次に、__qdisc_run を呼び出して送信を開始します。

//file: net/sched/sch_generic.c
void __qdisc_run(struct Qdisc *q)
{
 int quota = weight_p;

 //循环从队列取出一个 skb 并发送
 while (qdisc_restart(q)) {
  
  // 如果发生下面情况之一,则延后处理:
  // 1. quota 用尽
  // 2. 其他进程需要 CPU
  if (--quota <= 0 || need_resched()) {
   //将触发一次 NET_TX_SOFTIRQ 类型 softirq
   __netif_schedule(q);
   break;
  }
 }
}

上記のコードでは、while ループが継続的にキューから skb をフェッチし、送信していることがわかります。この時間は実際にはユーザー プロセスのシステム状態時間 (sy) を占めることに注意してください。クォータが使い果たされた場合、または他のプロセスが CPU を必要とした場合にのみ、ソフト割り込みがトリガーされて送信されます。

これが、一般的なサーバーで /proc/softirqs を表示するときに、NET_RX が一般的に NET_TX よりもはるかに大きい 2 番目の理由です読み取りの場合は、NET_RX ソフト割り込みを通過する必要があります。送信の場合、ソフト割り込みはシステム状態のクォータが使い果たされた場合にのみ許可されます。

qdisc_restart に注目して、送信プロセスを続けて見てみましょう。

static inline int qdisc_restart(struct Qdisc *q)
{
 //从 qdisc 中取出要发送的 skb
 skb = dequeue_skb(q);
 ...

 return sch_direct_xmit(skb, q, dev, txq, root_lock);
}

qdisc_restart はキューから skb を取得し、sch_direct_xmit を呼び出して送信を続けます。

//file: net/sched/sch_generic.c
int sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q,
   struct net_device *dev, struct netdev_queue *txq,
   spinlock_t *root_lock)
{
 //调用驱动程序来发送数据
 ret = dev_hard_start_xmit(skb, dev, txq);
}

4.6 ソフト割り込みのスケジューリング

4.5 では、システム状態の CPU がネットワーク パケットを送信するのに十分でない場合、__netif_schedule を呼び出してソフト割り込みをトリガーすることがわかりました。この関数は __netif_reschedule に入り、実際に NET_TX_SOFTIRQ タイプのソフト割り込みを発行します。

ソフト割り込みはカーネル スレッドによって実行され、送信キューを取得できる net_tx_action 関数に入り、最後にドライバーのエントリ関数 dev_hard_start_xmit が呼び出されます。

//file: net/core/dev.c
static inline void __netif_reschedule(struct Qdisc *q)
{
 sd = &__get_cpu_var(softnet_data);
 q->next_sched = NULL;
 *sd->output_queue_tailp = q;
 sd->output_queue_tailp = &q->next_sched;

 ......
 raise_softirq_irqoff(NET_TX_SOFTIRQ);
}

この関数では、送信するデータキューをsoftirqからアクセスできるsoftnet_dataに設定し、output_queueに追加します。次に、NET_TX_SOFTIRQ タイプのソフト割り込みがトリガーされます。(Tは送信送信の略)

ここでは、softirq のエントリ コードについては詳しく説明しません。興味のある学生は、記事「図解された Linux ネットワーク パケット受信プロセス」のセクション 3.2 (ksoftirqd カーネル スレッド プロセス Softirq) を参照してください。

NET_TX_SOFTIRQ Softirq によって登録されたコールバック関数 net_tx_action から直接開始します。ユーザー モード プロセスがソフト割り込みをトリガーした後、ソフト割り込みカーネル スレッドが net_tx_action を実行します。

今後、データ送信によって消費される CPU は si に表示され、ユーザープロセスのシステム時間は消費されないことに注意してください

//file: net/core/dev.c
static void net_tx_action(struct softirq_action *h)
{
 //通过 softnet_data 获取发送队列
 struct softnet_data *sd = &__get_cpu_var(softnet_data);

 // 如果 output queue 上有 qdisc
 if (sd->output_queue) {

  // 将 head 指向第一个 qdisc
  head = sd->output_queue;

  //遍历 qdsics 列表
  while (head) {
   struct Qdisc *q = head;
   head = head->next_sched;

   //发送数据
   qdisc_run(q);
  }
 }
}

ソフト割り込みはここでsoftnet_dataを取得します。前に、__netif_reschedule を呼び出すときに、プロセス カーネル モードが送信キューを Softnet_data の Output_queue に書き込むことを見てきました。ソフト割り込みは sd->output_queue をループしてデータ フレームを送信します。

qdisc_run を見てみましょう。これも、プロセス ユーザー モードと同様に、__qdisc_run を呼び出します。

//file: include/net/pkt_sched.h
static inline void qdisc_run(struct Qdisc *q)
{
 if (qdisc_run_begin(q))
  __qdisc_run(q);
}

次に、ドライバー関数 dev_hard_start_xmit が実行されるまで、同じように qdisc_restart => sch_direct_xmit を入力します。

4.7 igb ネットワークカードドライバーの送信

前に見たように、ユーザー プロセスのカーネル状態のためであっても、ソフト割り込みコンテキストのためであっても、ネットワーク デバイス サブシステムの dev_hard_start_xmit 関数が呼び出されます。この関数では、ドライバー内の送信関数 igb_xmit_frame が呼び出されます。

ドライバー関数では、skb が RingBuffer 上にハングされ、ドライバーが呼び出された後、実際にデータ パケットがネットワーク カードから送信されます。

実際のソースコードを見てみましょう。

//file: net/core/dev.c
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev,
   struct netdev_queue *txq)
{
 //获取设备的回调函数集合 ops
 const struct net_device_ops *ops = dev->netdev_ops;

 //获取设备支持的功能列表
 features = netif_skb_features(skb);

 //调用驱动的 ops 里面的发送回调函数 ndo_start_xmit 将数据包传给网卡设备
 skb_len = skb->len;
 rc = ops->ndo_start_xmit(skb, dev);
}

このうち、ndo_start_xmit はネットワークカードドライバによって実装される関数であり、net_device_ops で定義されています。

//file: include/linux/netdevice.h
struct net_device_ops {
 netdev_tx_t  (*ndo_start_xmit) (struct sk_buff *skb,
         struct net_device *dev);

}

igb ネットワーク カード ドライバーのソース コードでそれが見つかりました。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static const struct net_device_ops igb_netdev_ops = {
 .ndo_open  = igb_open,
 .ndo_stop  = igb_close,
 .ndo_start_xmit  = igb_xmit_frame, 
 ...
};

つまり、ネットワークデバイス層で定義されるndo_start_xmitの場合、igbの実装関数はigb_xmit_frameとなる。この機能は、ネットワークカードドライバーの初期化時に割り当てられます。特定の初期化プロセスについては、記事「図解された Linux ネットワーク パケット受信プロセス」のセクション 2.4、ネットワーク カード ドライバーの初期化を参照してください。

したがって、上のネットワークデバイス層で ops->ndo_start_xmit を呼び出すと、実際には関数 igb_xmit_frame に入ります。この関数にステップインして、ドライバーがどのように動作するかを見てみましょう。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static netdev_tx_t igb_xmit_frame(struct sk_buff *skb,
      struct net_device *netdev)
{
 ......
 return igb_xmit_frame_ring(skb, igb_tx_queue_mapping(adapter, skb));
}

netdev_tx_t igb_xmit_frame_ring(struct sk_buff *skb,
    struct igb_ring *tx_ring)
{
 //获取TX Queue 中下一个可用缓冲区信息
 first = &tx_ring->tx_buffer_info[tx_ring->next_to_use];
 first->skb = skb;
 first->bytecount = skb->len;
 first->gso_segs = 1;

 //igb_tx_map 函数准备给设备发送的数据。
 igb_tx_map(tx_ring, first, hdr_len);
}

ここでは、ネットワーク カードの送信キューの RingBuffer から要素が取得され、その要素に skb が付加されます。

igb_tx_map 関数は、ネットワーク カードがアクセスできるメモリ DMA 領域への skb データのマッピングを処理します。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static void igb_tx_map(struct igb_ring *tx_ring,
      struct igb_tx_buffer *first,
      const u8 hdr_len)
{
 //获取下一个可用描述符指针
 tx_desc = IGB_TX_DESC(tx_ring, i);

 //为 skb->data 构造内存映射,以允许设备通过 DMA 从 RAM 中读取数据
 dma = dma_map_single(tx_ring->dev, skb->data, size, DMA_TO_DEVICE);

 //遍历该数据包的所有分片,为 skb 的每个分片生成有效映射
 for (frag = &skb_shinfo(skb)->frags[0];; frag++) {

  tx_desc->read.buffer_addr = cpu_to_le64(dma);
  tx_desc->read.cmd_type_len = ...;
  tx_desc->read.olinfo_status = 0;
 }

 //设置最后一个descriptor
 cmd_type |= size | IGB_TXD_DCMD;
 tx_desc->read.cmd_type_len = cpu_to_le32(cmd_type);

 /* Force memory writes to complete before letting h/w know there
  * are new descriptors to fetch
  */
 wmb();
}

必要な記述子がすべて構築され、skb 内のすべてのデータが DMA アドレスにマップされると、ドライバーは最終ステップに進み、実際の送信をトリガーします。

4.8 完了ハード割り込みの送信

データを送信したら、作業は終わりではありません。メモリがクリーンアップされていないためです。送信が完了すると、ネットワーク カード デバイスはハード割り込みをトリガーしてメモリを解放します。

記事「図解 Linux ネットワーク パケット受信プロセス」のセクション 3.1 と 3.2では、ハード割り込みとソフト割り込みの処理プロセスについて詳しく説明します。

送信完了ハード割り込みでは、図のようにRingBufferメモリのクリーニングが行われます。

ハード割り込みによってトリガーされるソフト割り込みのソース コードを振り返ってください。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static inline void ____napi_schedule(...){
 list_add_tail(&napi->poll_list, &sd->poll_list);
 __raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

ここで非常に興味深い詳細があり、ハード割り込みが受信データがあるためなのか、送信完了通知によるものなのか、ハード割り込みからトリガーされるソフト割り込みは NET_RX_SOFTIRQ です最初のセクションで述べましたが、これがソフト割り込み統計で RX が TX よりも高い理由の 1 つです。

さて、ソフト割り込みのコールバック関数 igb_poll に行きましょう。この関数には、igb_clean_tx_irq という行があることに気付きました。ソース コードを参照してください。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static int igb_poll(struct napi_struct *napi, int budget)
{
 //performs the transmit completion operations
 if (q_vector->tx.ring)
  clean_complete = igb_clean_tx_irq(q_vector);
 ...
}

送信が完了したときに igb_clean_tx_irq が何を行うかを見てみましょう。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static bool igb_clean_tx_irq(struct igb_q_vector *q_vector)
{
 //free the skb
 dev_kfree_skb_any(tx_buffer->skb);

 //clear tx_buffer data
 tx_buffer->skb = NULL;
 dma_unmap_len_set(tx_buffer, len, 0);

 // clear last DMA location and unmap remaining buffers */
 while (tx_desc != eop_desc) {
 }
}

これは、skb をクリーンアップし、DMA マッピングを解放するなどの作業にすぎません。この時点で、基本的に転送は完了です。

なぜ完全に完成ではなく、基本的に完成していると言えるのでしょうか?トランスポート層は信頼性を確保する必要があるため、実際にはskbは削除されていません。相手からACKを受信するまで削除されず、その時点で送信完了となります。

やっと

画像を使用して送信プロセス全体を要約します。

送信プロセス全体を理解したら、最初に述べた質問に戻って確認してみましょう。

1. データを送信するカーネルによって消費される CPU を監視するとき、sy と si のどちらを見るべきですか?

ネットワーク パケットの送信プロセスでは、ユーザー プロセス (カーネル状態) が、ドライバーの呼び出しも含め、ほとんどの作業を完了します。ソフト割り込みは、カーネル モード プロセスが中断される前にのみ開始されます。送信処理中、オーバーヘッドのほとんど (90%) がユーザー プロセスのカーネル モードで消費されます。

ソフト割り込み (NET_TX タイプ) は少数の場合にのみトリガーされ、ソフト割り込み ksoftirqd カーネル プロセスによって送信されます。

したがって、サーバーへのネットワーク IO によって引き起こされる CPU オーバーヘッドを監視するときは、si だけを見るのではなく、si と sy の両方を考慮する必要があります。

2. サーバー上の /proc/softirqs を確認します。NET_RX が NET_TX よりはるかに大きいのはなぜですか?

以前は、NET_RX が読み取り、NET_TX が送信であると考えていました。ユーザーのリクエストを受信するだけでなく、ユーザーにリクエストを返すサーバーの場合。これら 2 つの部分の数はほぼ同じである必要があります。少なくとも桁違いの違いはありません。しかし実際には、Fei Ge のサーバーの 1 つは次のようになります。

本日のソースコード分析の結果、この問題には 2 つの理由があることが判明しました。

1 つ目の理由は、データ送信が完了すると、ハード割り込みによってドライバに送信完了が通知されるためです。ただし、ハード割り込みがデータ受信または送信完了のどちらであっても、トリガーされるソフト割り込みは NET_TX_SOFTIRQ ではなく NET_RX_SOFTIRQ です。

2 番目の理由は、読み取りの場合、すべて NET_RX ソフト割り込みを通過する必要があり、すべて ksoftirqd カーネル プロセスを通過する必要があるためです。送信の場合、ほとんどの作業はユーザー プロセス カーネル モードで処理され、システム モード クォータが使い果たされた場合にのみ、ソフト割り込みを許可するために NET_TX が送信されます。

上記の 2 つの理由に基づいて、マシン上の NET_RX が NET_TX よりもはるかに大きいことを理解するのは難しくありません。

3. ネットワーク データの送信にはどのようなメモリ コピー操作が必要ですか?

ここでのメモリコピーとは、送信するデータのメモリコピーのみを指します。

最初のコピー操作は、カーネルが skb を適用した後、ユーザーによって渡されたバッファー内のデータ コンテンツが skb にコピーされます。送信するデータの量が比較的大きい場合、このコピー操作のオーバーヘッドは小さくありません。

2 番目のコピー操作は、トランスポート層からネットワーク層に入るときであり、各 skb が新しいコピーに複製されます。ネットワーク層とその下にあるドライバー、ソフト割り込み、その他のコンポーネントは、送信が完了するとこのコピーを削除します。トランスポート層は元の skb を保存し、ネットワークの相手側に ack がない場合にそれを再送信することで、TCP で要求される信頼性の高い送信を実現します。

3 番目のコピーは必要ありませんが、skb が MTU より大きいことが IP 層で検出された場合にのみ必要です。追加の skb を適用し、元の skb を複数の小さな skb にコピーします。

ここで余談を入れます。ネットワーク パフォーマンスの最適化で誰もがよく聞くゼロ コピーですが、これは少し誇張されていると思います。TCP の信頼性を確保するため、2 番目のコピーは保存できません。パケットが MTU より大きい場合、フラグメンテーション中のコピーも避けられません。

これを見ると、データパケットを送信するカーネルはもはやまったく理解できないブラックボックスではないことがわかります。

おすすめ

転載: blog.csdn.net/m0_64560763/article/details/131570295