大家好,我是二哥。农历虎年第一篇文章来了。先给大家拜年,祝大家虎年大吉大利,虎气冲天。
今天这篇文章,主要尝试回答下面两个问题:
- 内核从网卡那里收到一个网络包,到最终将其所携带的payload完整递交给应用层,中间涉及到多少个队列?
- 为什么需要这么多各种各样的队列呢?
来吧,进入正题。
1. 大图介绍
照例,先来介绍一下为本文所准备的大图。这张图是在之前的文章用图之上修改而来,主要是添加了在TCP层所涉及到的队列。据说这叫重复利用。
这张图用来描绘内核从物理网卡以及虚拟网卡接收到网络包之后的数据流。
估计你注意到了图中的 1(1.a、1.b),2(2.a、2.b、2.c),3 这样的标号。对内核而言,1和2是网络包的接收入口,而3是网络包的处理入口。
具体来说,1和2表示线路1和线路2,它俩分别代表网络包从物理网卡进入内核以及从虚拟网卡进入内核所涉及到的一些关键操作。标号3表示的是内核线程从这个入口位置获取待处理网络设备。
图中最右边是TCP/IP协议栈。对于一个skb而言,协议栈对其的处理是在内核线程这个上下文中进行的。了解到这点很重要,我们总得知道到底是谁在替我们负重前行。
图中的蓝色宽箭头表示网络包流向用户态的数据通道。但箭头在TCP层由实心变成了空心,这是因为对于不同类型的网路包,用户态所拿到的数据是不一样的。在TCP层之下的所有层,大家处理的数据结构都是skb。而到了TCP层则需要关心这个skb到底是与握手相关还是与数据包相关。
如你所料,skb穿过链路层和IP层的时候,会涉及到bridge-netfilter和netfilter(iptables)所设置的基于规则的过滤过程,还有路由过程。
图 1:数据接收流程中的队列鸟瞰图
我们从左到右,从下至上,顺着网络包流过的路径,看看沿途中会碰到哪些队列。
我们说Network namespace用来隔离包括网卡(Network Interface)、回环设备(Loopback Device)、网络栈、IP地址、端口等等在内的网络资源。下文所提的各类队列也是这样一种被隔离了的资源,所以图1中所画的所有这些队列在不同的network ns中都各有一份。
2. RingBuffer
每个网卡在内存里会有若干个队列,每个这样的队列叫做RingBuffer。顾名思义,它是一个环形缓冲区。当物理网卡收到网络包,会通过DMA将其拷贝到RingBuffer。当RingBuffer满的时候,新来的数据包将给丢弃。
这是网络包碰到的第一个队列。那么谁负责将这个队列里面的网络包消费掉呢?答案是内核线程,也即图中的ksoftirqd。详见后文。
3. Per CPU 队列
每个CPU有一个自己专属的数据结构softnet_data。其上附有两个队列poll_list和input_pkt_queue。这两个队列里的内容都由ksoftirqd来消费。图1中所标示的ksoftirqd/4表示这个内核线程与第4个CPU核绑定在一起,也即它只会处理这个核所拥有的softnet_data上的数据。
3.1 input_pkt_queue
物理网卡由RingBuffer来缓存网络包,那虚拟网卡要发送出去的数据暂存在哪里呢?如图1中2.a所示,放在input_pkt_queue这个队列里。这个过程是在函数enqueue_to_backlog()中完成的。
3.2 poll_list
所有的待处理的网卡会挂到每个CPU专属的poll_list上。我们可以将poll_list想象成晾晒香肠的架子,而每个网络设备则如同香肠一样挂到架子上面等待ksoftirqd处理。
待处理的网卡包括物理网卡和虚拟网卡。简单地来说,只要需要图1中的内核线程处理网络包,就需要将这个网卡挂载到poll_list队列上。
那么这个挂载动作是由谁完成的呢?
- 对于物理网卡,由中断服务程序负责将网卡挂到poll_list上。如图1中步骤1.a所示。
- 对于虚拟网卡,如veth或者lo,则在enqueue_to_backlog()函数中将虚拟网卡挂到poll_list上。如图1中步骤2.b所示。
到这里,我们已经碰到了三种不同的队列了,前两者缓存数据,而后者缓存设备列表。其中RingBuffer和input_pkt_queue队列都用于缓存网络包,只是一个服务的对象是物理网卡而另一个则是虚拟网卡。poll_list队列用于缓存需要内核线程处理的设备。
无论网络包是位于哪个队列里,内核线程的启动意味着网络包开始进入TCP/IP协议栈。下面我们来看看在协议栈处理过程中用到的队列有哪几个。
4. listening socket所用队列
对于服务器而言,一个典型的架构是 “监听线程 工作线程池” 组合。下面是伪代码。
代码语言:javascript复制void main(){
int listening_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// 绑定 ip 和端口
bind(listening_socket, ...);
// 监听
listen(listening_socket, 3 /*backlog*/);
while(accept_socket = accept(listening_socket)){
// 将accept_socket交给一个worker thread去读取网络数据
pthread_t worker_thread;
new_sock = malloc(sizeof(int));
*new_sock = client_sock;
// 创建一个工作线程
if( pthread_create( &worker_thread , NULL , connection_handler , (void*) new_sock) < 0)
{
return 1;
}
...
}
}
这段代码的骨架挺简单,主线程为listening thread,用于创建一个listening_socket,并负责基于它来接收客户端的TCP连接。每一次客户端与服务器的成功连接都会使得accept()函数返回一个accept_socket,listening thread还会创建一个work thread并让它基于accept_socket与客户端通信。
这些work thread汇聚成了一个工作线程池。当然,实际工作的代码可不会创建无数个work thread,当已创建的work thread数量到达一个阈值后,创建动作就需要转变成从线程池中提溜一个线程出来这样的操作。
我将这段代码中与本文相关的关键点列在这里:
- 这段代码完成了 “监听线程 工作线程池” 组合这样的骨架。
- 监听线程操作的socket是listening_socket。
- 监听线程针对listening_socket,设置了一个大小为3的backlog。
- 工作线程操作的socket是accept_socket。
在内核中,为每个listening socket 维护了两个队列,它们都与连接管理相关。
- 已经建立了连接的队列,这些连接暂时还没有被work thread领走。队列里面的每个连接已经完成了三次握手,且处于ESTABLISHED状态。这个队列的名字叫
icsk_accept_queue
,如图1中accept_queue所示 。 - 还没有完全建立连接的队列,队列里面的每个连接还没有完成三次握手,处于 SYN_REVD 的状态。这个队列也叫半连接队列,syn queue。
示例代码中,在调用listen()函数的时候,将backlog设置为3。它的作用其实是在控制这个icsk_accept_queue
的大小。而syn queue大小则可以通过 /proc/sys/net/ipv4/tcp_max_syn_backlog
配置。
服务端调用 accept() 函数,其实是从第一个队列icsk_accept_queue
中拿出一个已经完成的连接进行数据处理。如果这个队列里是空的,那表示目前还没有已完成握手的连接,那就把listening thread阻塞等待吧,反正它暂时也没其它事可做。
5. accept socket所用队列
每个accept socket包含有4种不同的队列:backlog队列、prequeue队列、sk_receive_queue队列和out_of_order_queue队列。
其中prequeue队列在17年后的Linux版本中已经取消了,故本文略过这个队列。
5.1 backlog 队列
当网络包到达TCP,但是与之相关的accpet socket没有被用户态进程读取中,那么协议栈会调用tcp_add_backlog()将这个网络包暂存至backlog队列中。这样做的目的是让内核线程可以尽快处理下一个网络包。
什么情况下会出现accpet socket没有被用户态进程读取呢?比如work thread通过read()读取到一段数据后便开始直接处理这段数据而耽搁了下一段数据的读取。
注意这个地方的backlog队列和前文listening socket处所提及的backlog参数是两回事。
5.2 sk_receive_queue和out_of_order_queue队列
当然如果work thread因为调用read()被阻塞了,表示它正在这个accpet socket上急切地等待数据的到来,这个时候协议栈就会把网络包优先通过函数skb_copy_datagram_msg()直接给它了。但work thread处理能力也有限度,一直给它喂数据也会噎死它,那更多的网络包就需要sk_receive_queue队列和out_of_order_queue队列的帮忙了。
sk_receive_queue队列的作用很好理解,它里面存放的是按照seq number排好序的数据。但我们都知道跨internet的传输会使得网络包以乱序方式到达,这个时候就需要把这些乱序的包先放到out_of_order_queue队列排队了。
应用程序可以读取到sk_receive_queue队列和backlog队列中的内容,但无法直接访问out_of_order_queue队列。为了体现这一点,二哥在图1中特意做了处理:out_of_order_queue队列没有出现在通往用户态的数据通道上。当协议栈发现out_of_order_queue队列中的乱续包和新到的包可以拼凑成完整有序的数据流后,就将网络包从out_of_order_queue队列移动到sk_receive_queue队列。
5.3 消费队列
work thread所调用的read()函数在内核态最终通过函数tcp_recvmsg()
来读取暂存在sk_receive_queue中的数据。
每次这个sk_receive_queue队列中的内容处理完毕后,tcp_recvmsg()
还会继续处理backlog队列里面累积的网络包。
6. 为什么需要队列
行文至此,我们来回答文首的第二个问题:为什么需要这么多各种各样的队列呢?
答案是:效率。
把整个面向TCP连接的网络包接收处理流程稍作总结,我们会发现重要的参与者有如下几个:
- 网卡,包括物理的和虚拟的网卡:负责接收网络包。
- 内核线程:消费网络包,负责调用TCP/IP协议栈函数将乱序到达的网络包整理还原成data streaming。
- 应用程序:接收、消费data streaming。
网卡和内核线程操作的对象都是网络包,只是各自关注的焦点不同而已。它们完成自己负责的操作任务后,需要尽快地将网络包交给继任者,以便抽身去处理下一个网络包。递交网络包的时候,继任者可能正在忙,你总不能在那边傻等对吧?这个时候队列的出现就起到了很好的缓冲作用。
比如图1中的内核线程就是这样,它不断地从poll_list里面拿出需要处理的网卡并处理网卡里的网络包。处理好的网络包送进下文所说的几个队列中留待继任者继续处理。
我们还可以将所有这些参与者想象成制造业价值流(源于精益原则)中不同的工作中心。在这个价值流中,不同的工作中心之间通常会转移各自的输出(半)成品,并通过仓库来进行一定程度的缓存,仓库类似本文的队列。
7. 总结
文末二哥做一个总结。
为了可以高效地处理网络包,同时又可以让接收数据的各个重要组成模块以松耦合的方式合作,各式各样的队列参与了网络包的接收过程。
- RingBuffer和input_pkt_queue最先用于缓存网卡所接收到的网络包。
- poll_list用于告诉内核线程,当前有哪些网络设备正在排队等待它的处理。
- Server端一般用到listening socket和accept socket。 listening socket用于监听客户端的连接并负责生成后者,它维护了两个队列,分别用于缓存已经握手成功的但还没有被工作线程领走的连接和还未完成三次握手的连接。 accept socket用于针对具体的连接进行数据通信。它用到了backlog、sk_receive_queue和out_of_order_queue这三个队列。
文中所用高清大图已传至二哥的github:https://github.com/LanceHBZhang/LanceAndCloudnative。自取。
以上就是本文的全部内容。码字不易,画图更难。喜欢本文的话请帮忙转发或点击“在看”。您的举手之劳是对二哥莫大的鼓励。谢谢!