25张图,一万字,拆解Linux网络包发送过程(超级详细~)【上文】
半年前我以源码的方式描述了网络包的接收过程。之后不断有粉丝提醒我还没聊发送过程呢。好,安排!
在开始今天的文章之前,我先来请大家思考几个小问题。
问1:我们在查看内核发送数据消耗的 CPU 时,是应该看 sy 还是 si ?
问2:为什么你服务器上的 /proc/softirqs 里 NET_RX 要比 NET_TX 大的多的多?
问3:发送网络数据的时候都涉及到哪些内存拷贝操作?
这些问题虽然在线上经常看到,但我们似乎很少去深究。如果真的能透彻地把这些问题理解到位,我们对性能的掌控能力将会变得更强。
带着这三个问题,我们开始今天对 Linux 内核网络发送过程的深度剖析。还是按照我们之前的传统,先从一段简单的代码作为切入。如下代码是一个典型服务器程序的典型的缩微代码:
今天我们来讨论上述代码中,调用 send 之后内核是怎么样把数据包发送出去的。本文基于Linux 3.10,网卡驱动采用Intel的igb网卡举例。
【文章福利】小编推荐自己的Linux内核技术交流群:【891587639】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!(含视频教程、电子书、实战项目及代码)


我觉得看 Linux 源码最重要的是得有整体上的把握,而不是一开始就陷入各种细节。
我这里先给大家准备了一个总的流程图,简单阐述下 send 发送了的数据是如何一步一步被发送到网卡的。

在这幅图中,我们看到用户数据被拷贝到内核态,然后经过协议栈处理后进入到了 RingBuffer 中。随后网卡驱动真正将数据发送了出去。当发送完成的时候,是通过硬中断来通知 CPU,然后清理 RingBuffer。
因为文章后面要进入源码,所以我们再从源码的角度给出一个流程图。

虽然数据这时已经发送完毕,但是其实还有一件重要的事情没有做,那就是释放缓存队列等内存。
那内核是如何知道什么时候才能释放内存的呢,当然是等网络发送完毕之后。网卡在发送完毕的时候,会给 CPU 发送一个硬中断来通知 CPU。更完整的流程看图:

注意,我们今天的主题虽然是发送数据,但是硬中断最终触发的软中断却是 NET_RX_SOFTIRQ,而并不是 NET_TX_SOFTIRQ !!!(T 是 transmit 的缩写,R 表示 receive)
意不意外,惊不惊喜???
所以这就是开篇问题 1 的一部分的原因(注意,这只是一部分原因)。
问1:在服务器上查看 /proc/softirqs,为什么 NET_RX 要比 NET_TX 大的多的多?
传输完成最终会触发 NET_RX,而不是 NET_TX。所以自然你观测 /proc/softirqs 也就能看到 NET_RX 更多了。
好,现在你已经对内核是怎么发送网络包的有一个全局上的把握了。不要得意,我们需要了解的细节才是更有价值的地方,让我们继续!!
二、网卡启动准备
现在的服务器上的网卡一般都是支持多队列的。每一个队列上都是由一个 RingBuffer 表示的,开启了多队列以后的的网卡就会对应有多个 RingBuffer。

网卡在启动时最重要的任务之一就是分配和初始化 RingBuffer,理解了 RingBuffer 将会非常有助于后面我们掌握发送。因为今天的主题是发送,所以就以传输队列为例,我们来看下网卡启动时分配 RingBuffer 的实际过程。
在网卡启动的时候,会调用到 __igb_open 函数,RingBuffer 就是在这里分配的。
在上面 __igb_open 函数调用 igb_setup_all_tx_resources 分配所有的传输 RingBuffer, 调用 igb_setup_all_rx_resources 创建所有的接收 RingBuffer。
真正的 RingBuffer 构造过程是在 igb_setup_tx_resources 中完成的。
从上述源码可以看到,实际上一个 RingBuffer 的内部不仅仅是一个环形队列数组,而是有两个。
1)igb_tx_buffer 数组:这个数组是内核使用的,通过 vzalloc 申请的。 2)e1000_adv_tx_desc 数组:这个数组是网卡硬件使用的,硬件是可以通过 DMA 直接访问这块内存,通过 dma_alloc_coherent 分配。
这个时候它们之间还没有啥联系。将来在发送的时候,这两个环形数组中相同位置的指针将都将指向同一个 skb。这样,内核和硬件就能共同访问同样的数据了,内核往 skb 里写数据,网卡硬件负责发送。

最后调用 netif_tx_start_all_queues 开启队列。另外,对于硬中断的处理函数 igb_msix_ring 其实也是在 __igb_open 中注册的。
三、accept 创建新 socket
在发送数据之前,我们往往还需要一个已经建立好连接的 socket。
我们就以开篇服务器缩微源代码中提到的 accept 为例,当 accept 之后,进程会创建一个新的 socket 出来,然后把它放到当前进程的打开文件列表中,专门用于和对应的客户端通信。
假设服务器进程通过 accept 和客户端建立了两条连接,我们来简单看一下这两条连接和进程的关联关系。

其中代表一条连接的 socket 内核对象更为具体一点的结构图如下。

今天我们还是把重点放到数据发送过程上。
四、发送数据真正开始
4.1 send 系统调用实现
send 系统调用的源码位于文件 net/socket.c 中。在这个系统调用里,内部其实真正使用的是 sendto 系统调用。整个调用链条虽然不短,但其实主要只干了两件简单的事情,
第一是在内核中把真正的 socket 找出来,在这个对象里记录着各种协议栈的函数地址。
第二是构造一个 struct msghdr 对象,把用户传入的数据,比如 buffer地址、数据长度啥的,统统都装进去.
剩下的事情就交给下一层,协议栈里的函数 inet_sendmsg 了,其中 inet_sendmsg 函数的地址是通过 socket 内核对象里的 ops 成员找到的。大致流程如图。

有了上面的了解,我们再看起源码就要容易许多了。源码如下:
从源码可以看到,我们在用户态使用的 send 函数和 sendto 函数其实都是 sendto 系统调用实现的。send 只是为了方便,封装出来的一个更易于调用的方式而已。
在 sendto 系统调用里,首先根据用户传进来的 socket 句柄号来查找真正的 socket 内核对象。接着把用户请求的 buff、len、flag 等参数都统统打包到一个 struct msghdr 对象中。
接着调用了 sock_sendmsg => __sock_sendmsg ==> __sock_sendmsg_nosec。在__sock_sendmsg_nosec 中,调用将会由系统调用进入到协议栈,我们来看它的源码。
通过第三节里的 socket 内核对象结构图,我们可以看到,这里调用的是 sock->ops->sendmsg 实际执行的是 inet_sendmsg。这个函数是 AF_INET 协议族提供的通用发送函数。
4.2 传输层处理
1)传输层拷贝
在进入到协议栈 inet_sendmsg 以后,内核接着会找到 socket 上的具体协议发送函数。对于 TCP 协议来说,那就是 tcp_sendmsg(同样也是通过 socket 内核对象找到的)。
在这个函数中,内核会申请一个内核态的 skb 内存,将用户待发送的数据拷贝进去。注意这个时候不一定会真正开始发送,如果没有达到发送条件的话很可能这次调用直接就返回了。大概过程如图:

我们来看 inet_sendmsg 函数的源码。
在这个函数中会调用到具体协议的发送函数。同样参考第三节里的 socket 内核对象结构图,我们看到对于 TCP 协议下的 socket 来说,来说 sk->sk_prot->sendmsg 指向的是 tcp_sendmsg(对于 UPD 来说是 udp_sendmsg)。
tcp_sendmsg 这个函数比较长,我们分多次来看它。先看这一段
理解对 socket 调用 tcp_write_queue_tail 是理解发送的前提。如上所示,这个函数是在获取 socket 发送队列中的最后一个 skb。skb 是 struct sk_buff 对象的简称,用户的发送队列就是该对象组成的一个链表。

我们再接着看 tcp_sendmsg 的其它部分。
这个函数比较长,不过其实逻辑并不复杂。其中 msg->msg_iov 存储的是用户态内存的要发送的数据的 buffer。接下来在内核态申请内核内存,比如 skb,并把用户内存里的数据拷贝到内核态内存中。这就会涉及到一次或者几次内存拷贝的开销。

至于内核什么时候真正把 skb 发送出去。在 tcp_sendmsg 中会进行一些判断。
只有满足 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 的源码。
可以看到我们之前在网络协议里学的滑动窗口、拥塞控制就是在这个函数中完成的,这部分就不过多展开了,感兴趣同学自己找这段源码来读。我们今天只看发送主过程,那就走到了 tcp_transmit_skb。
第一件事是先克隆一个新的 skb,这里重点说下为什么要复制一个 skb 出来呢?
是因为 skb 后续在调用网络层,最后到达网卡发送完成的时候,这个 skb 会被释放掉。而我们知道 TCP 协议是支持丢失重传的,在收到对方的 ACK 之前,这个 skb 不能被删除。所以内核的做法就是每次调用网卡发送的时候,实际上传递出去的是 skb 的一个拷贝。等收到 ACK 再真正删除。
第二件事是修改 skb 中的 TCP header,根据实际情况把 TCP 头设置好。这里要介绍一个小技巧,skb 内部其实包含了网络协议中所有的 header。在设置 TCP 头的时候,只是把指针指向 skb 的合适位置。后面再设置 IP 头的时候,在把指针挪一挪就行,避免频繁的内存申请和拷贝,效率很高。

tcp_transmit_skb 是发送数据位于传输层的最后一步,接下来就可以进入到网络层进行下一层的操作了。调用了网络层提供的发送接口icsk->icsk_af_ops->queue_xmit()。
在下面的这个源码中,我们的知道了 queue_xmit 其实指向的是 ip_queue_xmit 函数。
自此,传输层的工作也就都完成了。数据离开了传输层,接下来将会进入到内核在网络层的实现里。
文章篇幅过长,下文继续讲解
