高性能网络编程3----TCP消息的接收
这篇文章将试图说明应用程序如何接收网络上发送过来的TCP消息流,由于篇幅所限,暂时忽略ACK报文的回复和接收窗口的滑动。为了快速掌握本文所要表达的思想,我们可以带着以下问题阅读:1、应用程序调用read、recv等方法时,socket套接字可以设置为阻塞或者非阻塞,这两种方式是如何工作的?2、若socket为默认的阻塞套接字,此时recv方法传入的len参数,是表示必须超时(SO_RCVTIMEO)或者接收到len长度的消息,recv方法才会返回吗?而且,socket上可以设置一个属性叫做SO_RCVLOWAT,它会与len产生什么样的交集,又是决定recv等接收方法什么时候返回?3、应用程序开始收取TCP消息,与程序所在的机器网卡上接收到网络里发来的TCP消息,这是两个独立的流程。它们之间是如何互相影响的?例如,应用程序正在收取消息时,内核通过网卡又在这条TCP连接上收到消息时,究竟是如何处理的?若应用程序没有调用read或者recv时,内核收到TCP连接上的消息后又是怎样处理的?4、recv这样的接收方法还可以传入各种flags,例如MSG_WAITALL、MSG_PEEK、MSG_TRUNK等等。它们是如何工作的?5、1个socket套接字可能被多个进程在使用,出现并发访问时,内核是怎么处理这种状况的?6、linux的sysctl系统参数中,有类似tcp_low_latency这样的开关,默认为0或者配置为1时是如何影响TCP消息处理流程的?
书接上文。本文将通过三幅图讲述三种典型的接收TCP消息场景,理清内核为实现TCP消息的接收所实现的4个队列容器。当然,了解内核的实现并不是目的,而是如何使用socket接口、如何配置操作系统内核参数,才能使TCP传输消息更高效,这才是最终目的。
很多同学不希望被内核代码扰乱了思维,如何阅读本文呢?我会在图1的步骤都介绍完了才来从代码上说明tcp_v4_rcv等主要方法。像flags参数、非阻塞套接字会产生怎样的效果我是在代码介绍中说的。然后我会介绍图2、图3,介绍它们的步骤时我会穿插一些上文没有涉及的少量代码。不喜欢了解内核代码的同学请直接看完图1的步骤后,请跳到图2、图3中,我认为这3幅图覆盖了主要的TCP接收场景,能够帮助你理清其流程。
接收消息时调用的系统方法要比上一篇发送TCP消息复杂许多。接收TCP消息的过程可以一分为二:首先是PC上的网卡接收到网线传来的报文,通过软中断内核拿到并且解析其为TCP报文,然后TCP模块决定如何处理这个TCP报文。其次,用户进程调用read、recv等方法获取TCP消息,则是将内核已经从网卡上收到的消息流拷贝到用户进程里的内存中。
第一幅图描述的场景是,TCP连接上将要收到的消息序号是S1(TCP上的每个报文都有序号,详见《TCP/IP协议详解》),此时操作系统内核依次收到了序号S1-S2的报文、S3-S4、S2-S3的报文,注意后两个包乱序了。之后,用户进程分配了一段len大小的内存用于接收TCP消息,此时,len是大于S4-S1的。另外,用户进程始终没有对这个socket设置过SO_RCVLOWAT参数,因此,接收阀值SO_RCVLOWAT使用默认值1。另外,系统参数tcp_low_latency设置为0,即从操作系统的总体效率出发,使用prequeue队列提升吞吐量。当然,由于用户进程收消息时,并没有新包来临,所以此图中prequeue队列始终为空。先不细表。图1如下:
上图中有13个步骤,应用进程使用了阻塞套接字,调用recv等方法时flag标志位为0,用户进程读取套接字时没有发生进程睡眠。内核在处理接收到的TCP报文时使用了4个队列容器(当链表理解也可),分别为receive、out_of_order、prequeue、backlog队列,本文会说明它们存在的意义。下面详细说明这13个步骤。1、当网卡接收到报文并判断为TCP协议后,将会调用到内核的tcp_v4_rcv方法。此时,这个TCP连接上需要接收的下一个报文序号恰好就是S1,而这一步里,网卡上收到了S1-S2的报文,所以,tcp_v4_rcv方法会把这个报文直接插入到receive队列中。注意:receive队列是允许用户进程直接读取的,它是将已经接收到的TCP报文,去除了TCP头部、排好序放入的、用户进程可以直接按序读取的队列。由于socket不在进程上下文中(也就是没有进程在读socket),由于我们需要S1序号的报文,而恰好收到了S1-S2报文,因此,它进入了receive队列。
2、接着,我们收到了S3-S4报文。在第1步结束后,这时我们需要收到的是S2序号,但到来的报文却是S3打头的,怎么办呢?进入out_of_order队列!从这个队列名称就可以看出来,所有乱序的报文都会暂时放在这。
3、仍然没有进入来读取socket,但又过来了我们期望的S2-S3报文,它会像第1步一样,直接进入receive队列。不同的时,由于此时out_of_order队列不像第1步是空的,所以,引发了接来的第4步。
4、每次向receive队列插入报文时都会检查out_of_order队列。由于收到S2-S3报文后,期待的序号成为了S3,这样,out_of_order队列里的唯一报文S3-S4报文将会移出本队列而插入到receive队列中(这件事由tcp_ofo_queue方法完成)。
5、终于有用户进程开始读取socket了。做过应用端编程的同学都知道,先要在进程里分配一块内存,接着调用read或者recv等方法,把内存的首地址和内存长度传入,再把建立好连接的socket也传入。当然,对这个socket还可以配置其属性。这里,假定没有设置任何属性,都使用默认值,因此,此时socket是阻塞式,它的SO_RCVLOWAT是默认的1。当然,recv这样的方法还会接收一个flag参数,它可以设置为MSG_WAITALL、MSG_PEEK、MSG_TRUNK等等,这里我们假定为最常用的0。进程调用了recv方法。
6、无论是何种接口,C库和内核经过层层封装,接收TCP消息最终一定会走到tcp_recvmsg方法。下面介绍代码细节时,它会是重点。
7、在tcp_recvmsg方法里,会首先锁住socket。为什么呢?因此socket是可以被多进程同时使用的,同时,内核中断也会操作它,而下面的代码都是核心的、操作数据的、有状态的代码,不可以被重入的,锁住后,再有用户进程进来时拿不到锁就要休眠在这了。内核中断看到被锁住后也会做不同的处理,参见图2、图3。
8、此时,第1-4步已经为receive队列里准备好了3个报文。最上面的报文是S1-S2,将它拷贝到用户态内存中。由于第5步flag参数并没有携带MSG_PEEK这样的标志位,因此,再将S1-S2报文从receive队列的头部移除,从内核态释放掉。反之,MSG_PEEK标志位会导致receive队列不会删除报文。所以,MSG_PEEK主要用于多进程读取同一套接字的情形。
9、如第8步,拷贝S2-S3报文到用户态内存中。当然,执行拷贝前都会检查用户态内存的剩余空间是否足以放下当前这个报文,不足以时会直接返回已经拷贝的字节数。10、同上。
11、receive队列为空了,此时会先来检查SO_RCVLOWAT这个阀值。如果已经拷贝的字节数到现在还小于它,那么可能导致进程会休眠,等待拷贝更多的数据。第5步已经说明过了,socket套接字使用的默认的SO_RCVLOWAT,也就是1,这表明,只要读取到报文了,就认为可以返回了。做完这个检查了,再检查backlog队列。backlog队列是进程正在拷贝数据时,网卡收到的报文会进这个队列。此时若backlog队列有数据,就顺带处理下。图3会覆盖这种场景。
12、在本图对应的场景中,backlog队列是没有数据的,已经拷贝的字节数为S4-S1,它是大于1的,因此,释放第7步里加的锁,准备返回用户态了。
13、用户进程代码开始执行,此时recv等方法返回的就是S4-S1,即从内核拷贝的字节数。
图1描述的场景是最简单的1种场景,下面我们来看看上述步骤是怎样通过内核代码实现的(以下代码为2.6.18内核代码)。
我们知道,linux对中断的处理是分为上半部和下半部的,这是处于系统整体效率的考虑。我们将要介绍的都是在网络软中断的下半部里,例如这个tcp_v4_rcv方法。图1中的第1-4步都是在这个方法里完成的。
static void __release_sock(struct sock *sk){struct sk_buff *skb = sk->sk_backlog.head; //遍历backlog队列do {sk->sk_backlog.head = sk->sk_backlog.tail = NULL;bh_unlock_sock(sk);do {struct sk_buff *next = skb->next;skb->next = NULL; //处理报文,其实就是tcp_v4_do_rcv方法,上文介绍过,不再赘述sk->sk_backlog_rcv(sk, skb);cond_resched_softirq();skb = next;} while (skb != NULL);bh_lock_sock(sk);} while((skb = sk->sk_backlog.head) != NULL);}