TCP/IP的底层队列是如何实现的?

自从上次学习了TCP/IP的拥塞控制算法后,我愈加想要更加深刻的了解TCP/IP的一些底层原理,搜索了不少网络上的资料,收益颇多。今天就总结一下。算法

我本身比较了解Java语言,对Java网络编程的理解就止于Netty框架的使用。 Netty的源码贡献者Norman Maurer对于Netty网络开发有过一句建议,"Never block the event loop, reduce context-swtiching"。也就是尽可能不要阻塞IO线程,也尽可能减小线程切换。咱们今天只关注前半句。编程

为何不能阻塞读取网络信息的IO线程呢?这里就要从经典的网络C10K开始理解,服务器如何支持并发1万请求。C10K的根源在于网络的IO模型。Linux 中网络处理都用同步阻塞的方式,也就是每一个请求都分配一个进程或者线程,那么要支持1万并发,难道就要使用1万个线程处理请求嘛?这1万个线程的调度、上下文切换乃至它们占用的内存,都会成为瓶颈。解决C10K的通用办法就是使用I/O 多路复用,Netty就是这样。服务器

TCP/IP的底层队列是如何实现的?

Netty有负责服务端监听创建链接的线程组(mainReactor)和负责链接读写操做的IO线程组(subReactor),还能够有专门处理业务逻辑的Worker线程组(ThreadPool)。网络

三者相互独立,这样有不少好处。一是有专门的线程组负责监听和处理网络链接的创建,能够防止TCP/IP的半链接队列(sync)和全链接队列(acceptable)被占满。二是IO线程组和Worker线程分开,双方并行处理网络I/O和业务逻辑,能够避免IO线程被阻塞,防止TCP/IP的接收报文的队列被占满。固然,若是业务逻辑较少,也就是IO 密集型的轻计算业务,能够将业务逻辑放在IO线程中处理,避免线程切换,这也就是Norman Maurer话的后半部分。多线程

TCP/IP怎么就这么多队列啊?今天咱们就来细看一下TCP/IP的几个队列,包括创建链接时的半链接队列(sync),全链接队列(accept)和接收报文时的receive、outoforder、prequeue以及backlog队列。并发

创建链接时的队列

TCP/IP的底层队列是如何实现的?

如上图所示,这里有两个队列:syns queue(半链接队列)和accept queue(全链接队列)。三次握手中,服务端接收到客户端的SYN报文后,把相关信息放到半链接队列中,同时回复SYN+ACK给客户端。 第三步的时候服务端收到客户端的ACK,若是这时全链接队列没满,那么从半链接队列拿出相关信息放入到全链接队列中,不然按 tcp_abort_on_overflow的值来执行相关操做,直接抛弃或者过一段时间在重试。框架

接收报文时的队列

相比于创建链接,TCP在接收报文时的处理逻辑更为复杂,相关的队列和涉及的配置参数更多。socket

应用程序接收TCP报文和程序所在服务器系统接收网络里发来的TCP报文是两个独立流程。两者都会操控socket实例,可是会经过锁竞争来决定某一时刻由谁来操控,由此产生不少不一样的场景。例如,应用程序正在接收报文时,操做系统经过网卡又接收到报文,这时该如何处理?若应用程序没有调用read或者recv读取报文时,操做系统收到报文又会如何处理?tcp

咱们接下来就以三张图为主,介绍TCP接收报文时的三种场景,并在其中介绍四个接收相关的队列。ide

接收报文场景一

TCP/IP的底层队列是如何实现的?

上图是TCP接收报文场景一的示意图。操做系统首先接收报文,存储到socket的receive队列,而后用户进程再调用recv进行读取。

1) 当网卡接收报文而且判断为TCP协议时,通过层层调用,最终会调用到内核的 tcp_v4_rcv方法。因为当前TCP要接收的下一个报文正是S1,因此 tcp_v4_rcv函数将其直接加入到 receive队列中。 receive队列是将已经接收到的TCP报文,去除了TCP头部、排好序放入的、用户进程能够直接按序读取的队列。因为socket不在用户进程上下文中(也就是没有用户进程在读socket),而且咱们须要S1序号的报文,而刚好收到了S1报文,所以,它进入了 receive队列。

2) 接收到S3报文,因为TCP要接收的下一个报文序号是S2,因此加入到 out_of_order队列,全部乱序的报文会放在这里。

3) 接着,收到了TCP指望的S2报文,直接进入 recevie队列。因为此时 out_of_order队列不为空,须要检查一下。

4) 每次向 receive队列插入报文时都会检查 out_of_order队列,因为接收到S2报文后,指望的的序号为S3,因此 out_of_order队列中的S3报文会被移到 receive队列。

5) 用户进程开始读取socket,先在进程中分配一块内存,而后调用 read或者 recv方法。socket有一系列的具备默认值的配置属性,好比socket默认是阻塞式的,它的 SO_RCVLOWAT属性值默认为1。固然,recv这样的方法还会接收一个flag参数,它能够设置为 MSG_WAITALLMSG_PEEKMSG_TRUNK等等,这里咱们假定为最经常使用的0。进程调用了 recv方法。

6) 调用 tcp_recvmsg方法

7) tcp_recvmsg方法会首先锁住socket。socket是能够被多线程使用的,并且操做系统也会使用,因此必须处理并发问题。要操控socket,就先获取锁。

8) 此时, receive队列已经有3个报文了,将第一个报文拷贝到用户态内存中,因为第五步中socket的参数并无带 MSG_PEEK,因此将第一个报文从队列中移除,从内核态释放掉。反之, MSG_PEEK标志位会致使 receive队列不会删除报文。因此, MSG_PEEK主要用于多进程读取同一套接字的情形。

9) 拷贝第二个报文,固然,执行拷贝前都会检查用户态内存的剩余空间是否足以放下当前这个报文,不够时会直接返回已经拷贝的字节数。

10) 拷贝第三个报文。

11) receive队列已经为空,此时会检查 SO_RCVLOWAT这个最小阈值。若是已经拷贝字节数小于它,进程会休眠,等待更多报文。默认的 SO_RCVLOWAT值为1,也就是读取到报文就能够返回。

12) 检查 backlog队列, backlog队列是用户进程正在拷贝数据时,网卡收到的报文会进这个队列。若是此时 backlog队列有数据,就顺带处理下。 backlog队列是没有数据的,所以释放锁,准备返回用户态。

13) 用户进程代码开始执行,此时recv等方法返回的就是从内核拷贝的字节数。

接收报文场景二

第二张图给出了第二个场景,这里涉及了 prequeue队列。用户进程调用recv方法时,socket队列中没有任何报文,而socket是阻塞的,因此进程睡眠了。而后操做系统收到了报文,此时 prequeue队列开始产生做用。该场景中, tcp_low_latency为默认的0,套接字socket的 SO_RCVLOWAT是默认的1,仍然是阻塞socket,以下图。

TCP/IP的底层队列是如何实现的?

其中1,2,3步骤的处理和以前同样。咱们直接从第四步开始。

4) 因为此时 receive, prequeuebacklog队列都为空,因此没有拷贝一个字节到用户内存中。而socket的配置要求至少拷贝 SO_RCVLOWAT也就是1字节的报文,所以进入阻塞式套接字的等待流程。最长等待时间为 SO_RCVTIMEO指定的时间。socket在进入等待前会释放socket锁,会使第五步中,新来的报文再也不只能进入 backlog队列。

5) 接到S1报文,将其加入 prequeue队列中。

6) 插入到 prequeue队列后,会唤醒在socket上休眠的进程。

7) 用户进程被唤醒后,从新获取socket锁,此后再接收到的报文只能进入 backlog队列。

8) 进程先检查 receive队列,固然仍然是空的;再去检查 prequeue队列,发现有报文S1,正好是正在等待序号的报文,因而直接从 prequeue队列中拷贝到用户内存,再释放内核中的这个报文。

9) 目前已经拷贝了一个字节的报文到用户内存,检查这个长度是否超过了最低阈值,也就是len和 SO_RCVLOWAT的最小值。

10) 因为 SO_RCVLOWAT使用了默认值1,拷贝字节数大于最低阈值,准备返回用户态,顺便会查看一下backlog队列中是否有数据,此时没有,因此准备放回,释放socket锁。

11) 返回用户已经拷贝的字节数。

接收报文场景三

在第三个场景中,系统参数 tcp_low_latency为1,socket上设置了 SO_RCVLOWAT属性值。服务器先收到报文S1,可是其长度小于 SO_RCVLOWAT。用户进程调用 recv方法读取,虽然读取到了一部分,可是没有到达最小阈值,因此进程睡眠了。与此同时,在睡眠前接收的乱序的报文S3直接进入 backlog队列。而后,报文S2到达,因为没有使用 prequeue队列(由于设置了tcplowlatency),而它起始序号正是下一个待拷贝的值,因此直接拷贝到用户内存中,总共拷贝字节数已知足 SO_RCVLOWAT的要求!最后在返回用户前把 backlog队列中S3报文也拷贝给用户。

TCP/IP的底层队列是如何实现的?

1) 接收到报文S1,正是准备接收的报文序号,所以,将它直接加入到有序的 receive队列中。

2) 将系统属性 tcp_low_latency设置为1,代表服务器但愿程序可以及时的接收到TCP报文。用户调用的 recv接收阻塞socket上的报文,该socket的 SO_RCVLOWAT值大于第一个报文的大小,而且用户分配了足够大的长度为len的内存。

3) 调用 tcp_recvmsg方法来完成接收工做,先锁住socket。

4) 准备处理内核各个接收队列中的报文。

5) receive队列中有报文能够直接拷贝,其大小小于len,直接拷贝到用户内存。

6) 在进行第五步的同时,内核又接收到S3报文,此时socket被锁,报文直接进入backlog队列。这个报文并非有序的。

7) 在第五步时,拷贝报文S1到用户内存,它的大小小于 SO_RCVLOWAT的值。因为socket是阻塞型,因此用户进程进入睡眠状态。进入睡眠前,会先处理 backlog队列的报文。由于S3报文是失序的,因此进入 out_of_order队列。用户进程进入休眠状态前都会先处理一下 backlog队列。

8) 进程休眠,直到超时或者 receive队列不为空。

9) 内核接收到报文S2。注意,此时因为打开了 tcp_low_latency标志位,因此报文是不会进入 prequeue队列等待进程处理。

10) 因为报文S2正是要接收的报文,同时,一个用户进程在休眠等待该报文,因此直接将报文S2拷贝到用户内存。

11) 每处理完一个有序报文后,不管是拷贝到 receive队列仍是直接复制到用户内存,都会检查 out_of_order队列,看看是否有报文能够处理。报文S3拷贝到用户内存,而后唤醒用户进程。

12) 唤醒用户进程。

13) 此时会检查已拷贝的字节数是否大于 SO_RCVLOWAT,以及 backlog队列是否为空。二者皆知足,准备返回。

总结一下四个队列的做用。

  • receive队列是真正的接收队列,操做系统收到的TCP数据包通过检查和处理后,就会保存到这个队列中。

  • backlog是“备用队列”。当socket处于用户进程的上下文时(即用户正在对socket进行系统调用,如recv),操做系统收到数据包时会将数据包保存到 backlog队列中,而后直接返回。

  • prequeue是“预存队列”。当socket没有正在被用户进程使用时,也就是用户进程调用了read或者recv系统调用,可是进入了睡眠状态时,操做系统直接将收到的报文保存在 prequeue中,而后返回。

  • out_of_order是“乱序队列”。队列存储的是乱序的报文,操做系统收到的报文并非TCP准备接收的下一个序号的报文,则放入 out_of_order队列,等待后续处理。
相关文章
相关标签/搜索