25张图,一万字,拆解Linux网络包发送过程(超级详细~)【下文】
接上文:25张图,一万字,拆解Linux网络包发送过程(超级详细~)【上文】
4.3 网络层发送处理
Linux 内核网络层的发送的实现位于 net/ipv4/ip_output.c 这个文件。传输层调用到的 ip_queue_xmit 也在这里。(从文件名上也能看出来进入到 IP 层了,源文件名已经从 tcp_xxx 变成了 ip_xxx。)
在网络层里主要处理路由项查找、IP 头设置、netfilter 过滤、skb 切分(大于 MTU 的话)等几项工作,处理完这些工作后会交给更下层的邻居子系统来处理。

我们来看网络层入口函数 ip_queue_xmit 的源码:
ip_queue_xmit 已经到了网络层,在这个函数里我们看到了网络层相关的功能路由项查找,如果找到了则设置到 skb 上(没有路由的话就直接报错返回了)。
在 Linux 上通过 route 命令可以看到你本机的路由配置。

在路由表中,可以查到某个目的网络应该通过哪个 Iface(网卡),哪个 Gateway(网卡)发送出去。查找出来以后缓存到 socket 上,下次再发送数据就不用查了。
接着把路由表地址也放到 skb 里去。
接下来就是定位到 skb 里的 IP 头的位置上,然后开始按照协议规范设置 IP header。

再通过 ip_local_out 进入到下一步的处理。
在 ip_local_out => __ip_local_out => nf_hook 会执行 netfilter 过滤。如果你使用 iptables 配置了一些规则,那么这里将检测是否命中规则。如果你设置了非常复杂的 netfilter 规则,在这个函数这里将会导致你的进程 CPU 开销会极大增加。
还是不多展开说,继续只聊和发送有关的过程 dst_output。
此函数找到到这个 skb 的路由表(dst 条目) ,然后调用路由表的 output 方法。这又是一个函数指针,指向的是 ip_output 方法。
在 ip_output 中进行一些简单的,统计工作,再次执行 netfilter 过滤。过滤通过之后回调 ip_finish_output。
在 ip_finish_output 中我们看到,如果数据大于 MTU 的话,是会执行分片的。
实际 MTU 大小确定依赖 MTU 发现,以太网帧为 1500 字节。之前 QQ 团队在早期的时候,会尽量控制自己数据包尺寸小于 MTU,通过这种方式来优化网络性能。因为分片会带来两个问题:1、需要进行额外的切分处理,有额外性能开销。2、只要一个分片丢失,整个包都得重传。所以避免分片既杜绝了分片开销,也大大降低了重传率。
在 ip_finish_output2 中,终于发送过程会进入到下一层,邻居子系统中。
4.4 邻居子系统
邻居子系统是位于网络层和数据链路层中间的一个系统,其作用是对网络层提供一个封装,让网络层不必关心下层的地址信息,让下层来决定发送到哪个 MAC 地址。
而且这个邻居子系统并不位于协议栈 net/ipv4/ 目录内,而是位于 net/core/neighbour.c。因为无论是对于 IPv4 还是 IPv6 ,都需要使用该模块。

在邻居子系统里主要是查找或者创建邻居项,在创造邻居项的时候,有可能会发出实际的 arp 请求。然后封装一下 MAC 头,将发送过程再传递到更下层的网络设备子系统。大致流程如图。

理解了大致流程,我们再回头看源码。在上面小节 ip_finish_output2 源码中调用了 __ipv4_neigh_lookup_noref。它是在 arp 缓存中进行查找,其第二个参数传入的是路由下一跳 IP 信息。
如果查找不到,则调用 __neigh_create 创建一个邻居。
有了邻居项以后,此时仍然还不具备发送 IP 报文的能力,因为目的 MAC 地址还未获取。调用 dst_neigh_output 继续传递 skb。
调用 output,实际指向的是 neigh_resolve_output。在这个函数内部有可能会发出 arp 网络请求。
当获取到硬件 MAC 地址以后,就可以封装 skb 的 MAC 头了。最后调用 dev_queue_xmit 将 skb 传递给 Linux 网络设备子系统。
【文章福利】小编推荐自己的Linux内核技术交流群:【891587639】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!(含视频教程、电子书、实战项目及代码)


4.5 网络设备子系统

邻居子系统通过 dev_queue_xmit 进入到网络设备子系统中来。
开篇第二节网卡启动准备里我们说过,网卡是有多个发送队列的(尤其是现在的网卡)。上面对 netdev_pick_tx 函数的调用就是选择一个队列进行发送。
netdev_pick_tx 发送队列的选择受 XPS 等配置的影响,而且还有缓存,也是一套小复杂的逻辑。这里我们只关注两个逻辑,首先会获取用户的 XPS 配置,否则就自动计算了。代码见 netdev_pick_tx => __netdev_pick_tx。
然后获取与此队列关联的 qdisc。在 linux 上通过 tc 命令可以看到 qdisc 类型,例如对于我的某台多队列网卡机器上是 mq disc。
大部分的设备都有队列(回环设备和隧道设备除外),所以现在我们进入到 __dev_xmit_skb。
上述代码中分两种情况,1 是可以 bypass(绕过)排队系统的,另外一种是正常排队。我们只看第二种情况。
先调用 q->enqueue 把 skb 添加到队列里。然后调用 __qdisc_run 开始发送。
在上述代码中,我们看到 while 循环不断地从队列中取出 skb 并进行发送。注意,这个时候其实都占用的是用户进程的系统态时间(sy)。只有当 quota 用尽或者其它进程需要 CPU 的时候才触发软中断进行发送。
所以这就是为什么一般服务器上查看 /proc/softirqs,一般 NET_RX 都要比 NET_TX 大的多的第二个原因。对于读来说,都是要经过 NET_RX 软中断,而对于发送来说,只有系统态配额用尽才让软中断上。
我们来把精力在放到 qdisc_restart 上,继续看发送过程。
qdisc_restart 从队列中取出一个 skb,并调用 sch_direct_xmit 继续发送。
4.6 软中断调度
在 4.5 咱们看到了如果系统态 CPU 发送网络包不够用的时候,会调用 __netif_schedule 触发一个软中断。该函数会进入到 __netif_reschedule,由它来实际发出 NET_TX_SOFTIRQ 类型软中断。
软中断是由内核线程来运行的,该线程会进入到 net_tx_action 函数,在该函数中能获取到发送队列,并也最终调用到驱动程序里的入口函数 dev_hard_start_xmit。

在该函数里在软中断能访问到的 softnet_data 里设置了要发送的数据队列,添加到了 output_queue 里了。紧接着触发了 NET_TX_SOFTIRQ 类型的软中断。(T 代表 transmit 传输)
我们直接从 NET_TX_SOFTIRQ softirq 注册的回调函数 net_tx_action讲起。用户态进程触发完软中断之后,会有一个软中断内核线程会执行到 net_tx_action。
牢记,这以后发送数据消耗的 CPU 就都显示在 si 这里了,不会消耗用户进程的系统时间了。
软中断这里会获取 softnet_data。前面我们看到进程内核态在调用 __netif_reschedule 的时候把发送队列写到 softnet_data 的 output_queue 里了。软中断循环遍历 sd->output_queue 发送数据帧。
来看 qdisc_run,它和进程用户态一样,也会调用到 __qdisc_run。
然后一样就是进入 qdisc_restart => sch_direct_xmit,直到驱动程序函数 dev_hard_start_xmit。
4.7 igb 网卡驱动发送
我们前面看到,无论是对于用户进程的内核态,还是对于软中断上下文,都会调用到网络设备子系统中的 dev_hard_start_xmit 函数。在这个函数中,会调用到驱动里的发送函数 igb_xmit_frame。
在驱动函数里,将 skb 会挂到 RingBuffer上,驱动调用完毕后,数据包将真正从网卡发送出去。

我们来看看实际的源码:
其中 ndo_start_xmit 是网卡驱动要实现的一个函数,是在 net_device_ops 中定义的。
在 igb 网卡驱动源码中,我们找到了。
也就是说,对于网络设备层定义的 ndo_start_xmit, igb 的实现函数是 igb_xmit_frame。这个函数是在网卡驱动初始化的时候被赋值的。具体初始化过程参见《图解Linux网络包接收过程》一文中的 2.4 节,网卡驱动初始化。
所以在上面网络设备层调用 ops->ndo_start_xmit 的时候,会实际上进入 igb_xmit_frame 这个函数中。我们进入这个函数来看看驱动程序是如何工作的。
在这里从网卡的发送队列的 RingBuffer 中取下来一个元素,并将 skb 挂到元素上。

igb_tx_map 函数处理将 skb 数据映射到网卡可访问的内存 DMA 区域。
当所有需要的描述符都已建好,且 skb 的所有数据都映射到 DMA 地址后,驱动就会进入到它的最后一步,触发真实的发送。
4.8 发送完成硬中断
当数据发送完成以后,其实工作并没有结束。因为内存还没有清理。当发送完成的时候,网卡设备会触发一个硬中断来释放内存。
在发送完成硬中断里,会执行 RingBuffer 内存的清理工作,如图。

再回头看一下硬中断触发软中断的源码。
这里有个很有意思的细节,无论硬中断是因为是有数据要接收,还是说发送完成通知,从硬中断触发的软中断都是 NET_RX_SOFTIRQ。这个我们在第一节说过了,这是软中断统计中 RX 要高于 TX 的一个原因。
好我们接着进入软中断的回调函数 igb_poll。在这个函数里,我们注意到有一行 igb_clean_tx_irq,参见源码:
我们来看看当传输完成的时候,igb_clean_tx_irq 都干啥了。
无非就是清理了 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 是传输。对于一个既收取用户请求,又给用户返回的 Server 来说。这两块的数字应该差不多才对,至少不会有数量级的差异。但事实上,飞哥手头的一台服务器是这样的:

经过今天的源码分析,发现这个问题的原因有两个。
第一个原因是当数据发送完成以后,通过硬中断的方式来通知驱动发送完毕。但是硬中断无论是有数据接收,还是对于发送完毕,触发的软中断都是 NET_RX_SOFTIRQ,而并不是 NET_TX_SOFTIRQ。
第二个原因是对于读来说,都是要经过 NET_RX 软中断的,都走 ksoftirqd 内核进程。而对于发送来说,绝大部分工作都是在用户进程内核态处理了,只有系统态配额用尽才会发出 NET_TX,让软中断上。
综上两个原因,那么在机器上查看 NET_RX 比 NET_TX 大的多就不难理解了。
3.发送网络数据的时候都涉及到哪些内存拷贝操作?
这里的内存拷贝,我们只特指待发送数据的内存拷贝。
第一次拷贝操作是内核申请完 skb 之后,这时候会将用户传递进来的 buffer 里的数据内容都拷贝到 skb 中。如果要发送的数据量比较大的话,这个拷贝操作开销还是不小的。
第二次拷贝操作是从传输层进入网络层的时候,每一个 skb 都会被克隆一个新的副本出来。网络层以及下面的驱动、软中断等组件在发送完成的时候会将这个副本删除。传输层保存着原始的 skb,在当网络对方没有 ack 的时候,还可以重新发送,以实现 TCP 中要求的可靠传输。
第三次拷贝不是必须的,只有当 IP 层发现 skb 大于 MTU 时才需要进行。会再申请额外的 skb,并将原来的 skb 拷贝为多个小的 skb。
这里插入个题外话,大家在网络性能优化中经常听到的零拷贝,我觉得这有点点夸张的成分。TCP 为了保证可靠性,第二次的拷贝根本就没法省。如果包再大于 MTU 的话,分片时的拷贝同样也避免不了。
看到这里,相信内核发送数据包对于你来说,已经不再是一个完全不懂的黑盒了。本文哪怕你只看懂十分之一,你也已经掌握了这个黑盒的打开方式。这在你将来优化网络性能时你就会知道从哪儿下手了。
