这篇文章将试图说明应用程序如何接收网络上发送过来的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步都是在这个方法里完成的。
- int tcp_v4_rcv(struct sk_buff *skb)
- {
- ... ...
-
-
- if (!sock_owned_by_user(sk)) {
- {
-
- if (!tcp_prequeue(sk, skb))
- ret = tcp_v4_do_rcv(sk, skb);
- }
- } else
- sk_add_backlog(sk, skb);
- ... ...
- }
图1第1步里,我们从网络上收到了序号为S1-S2的包。此时,没有用户进程在读取套接字,因此,sock_owned_by_user(sk)会返回0。所以,tcp_prequeue方法将得到执行。简单看看它:
- static inline int tcp_prequeue(struct sock *sk, struct sk_buff *skb)
- {
- struct tcp_sock *tp = tcp_sk(sk);
-
-
- if (!sysctl_tcp_low_latency && tp->ucopy.task) {
-
- __skb_queue_tail(&tp->ucopy.prequeue, skb);
- tp->ucopy.memory += skb->truesize;
-
- if (tp->ucopy.memory > sk->sk_rcvbuf) {
- while ((skb1 = __skb_dequeue(&tp->ucopy.prequeue)) != NULL) {
-
- sk->sk_backlog_rcv(sk, skb1);
- }
- } else if (skb_queue_len(&tp->ucopy.prequeue) == 1) {
-
- wake_up_interruptible(sk->sk_sleep);
- }
-
- return 1;
- }
-
- return 0;
- }
由于tp->ucopy.task此时是NULL,所以我们收到的第1个报文在tcp_prequeue函数里直接返回了0,因此,将由 tcp_v4_do_rcv方法处理。
- int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
- {
- if (sk->sk_state == TCP_ESTABLISHED) {
-
- if (tcp_rcv_established(sk, skb, skb->h.th, skb->len))
- goto reset;
-
- return 0;
- }
- ... ...
- }
tcp_rcv_established方法在图1里,主要调用tcp_data_queue方法将报文放入队列中,继续看看它又干了些什么事:
- static void tcp_data_queue(struct sock *sk, struct sk_buff *skb)
- {
- struct tcp_sock *tp = tcp_sk(sk);
-
-
- if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {
-
- if (tcp_receive_window(tp) == 0)
- goto out_of_window;
-
-
- if (tp->ucopy.task == current &&
- tp->copied_seq == tp->rcv_nxt && tp->ucopy.len &&
- sock_owned_by_user(sk) && !tp->urg_data) {
-
- if (!skb_copy_datagram_iovec(skb, 0, tp->ucopy.iov, chunk)) {
- tp->ucopy.len -= chunk;
- tp->copied_seq += chunk;
- }
- }
-
- if (eaten <= 0) {
- queue_and_out:
-
- __skb_queue_tail(&sk->sk_receive_queue, skb);
- }
-
- tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;
-
-
- if (!skb_queue_empty(&tp->out_of_order_queue)) {
-
- tcp_ofo_queue(sk);
- }
- }
- ... ...
-
-
- if (!skb_peek(&tp->out_of_order_queue)) {
- __skb_queue_head(&tp->out_of_order_queue,skb);
- } else {
- ... ...
- __skb_append(skb1, skb, &tp->out_of_order_queue);
- }
- }
图1第4步时,正是通过tcp_ofo_queue方法把之前乱序的S3-S4报文插入receive队列的。
- static void tcp_ofo_queue(struct sock *sk)
- {
- struct tcp_sock *tp = tcp_sk(sk);
- __u32 dsack_high = tp->rcv_nxt;
- struct sk_buff *skb;
-
- while ((skb = skb_peek(&tp->out_of_order_queue)) != NULL) {
- ... ...
-
- __skb_unlink(skb, &tp->out_of_order_queue);
-
- __skb_queue_tail(&sk->sk_receive_queue, skb);
-
- tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;
- }
- }
下面再介绍图1第6步提到的tcp_recvmsg方法。
-
- int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
- size_t len, int nonblock, int flags, int *addr_len)
- {
-
- lock_sock(sk);
-
-
- err = -ENOTCONN;
-
-
- timeo = sock_rcvtimeo(sk, nonblock);
-
-
-
- seq = &tp->copied_seq;
-
- if (flags & MSG_PEEK) {
-
- peek_seq = tp->copied_seq;
- seq = &peek_seq;
- }
-
-
-
- target = sock_rcvlowat(sk, flags & MSG_WAITALL, len);
-
-
- do {
-
- skb = skb_peek(&sk->sk_receive_queue);
- do {
-
- if (!skb)
- break;
-
-
-
- offset = *seq - TCP_SKB_CB(skb)->seq;
-
- if (skb->h.th->syn)
- offset--;
-
- if (offset < skb->len)
- goto found_ok_skb;
-
- skb = skb->next;
- } while (skb != (struct sk_buff *)&sk->sk_receive_queue);
-
-
- if (copied >= target && !sk->sk_backlog.tail)
- break;
-
-
- if (copied) {
- ... ...
- } else {
-
- if (sk->sk_shutdown & RCV_SHUTDOWN)
- break;
-
-
- if (!timeo) {
-
- copied = -EAGAIN;
- break;
- }
- ... ...
- }
-
-
- if (!sysctl_tcp_low_latency && tp->ucopy.task == user_recv) {
-
- if (!skb_queue_empty(&tp->ucopy.prequeue))
- goto do_prequeue;
- }
-
-
- if (copied >= target) {
-
- release_sock(sk);
- lock_sock(sk);
- } else
- sk_wait_data(sk, &timeo);
-
- if (user_recv) {
- if (tp->rcv_nxt == tp->copied_seq &&
- !skb_queue_empty(&tp->ucopy.prequeue)) {
- do_prequeue:
-
- tcp_prequeue_process(sk);
- }
- }
-
-
- continue;
-
- found_ok_skb:
-
-
- used = skb->len - offset;
-
- if (len < used)
- used = len;
-
-
- if (!(flags & MSG_TRUNC)) {
- {
-
- err = skb_copy_datagram_iovec(skb, offset,
- msg->msg_iov, used);
- }
- }
-
-
- *seq += used;
-
- copied += used;
-
- len -= used;
-
- ... ...
- } while (len > 0);
-
-
- if (user_recv) {
-
- if (!skb_queue_empty(&tp->ucopy.prequeue)) {
- tcp_prequeue_process(sk);
- }
-
-
- tp->ucopy.task = NULL;
- tp->ucopy.len = 0;
- }
-
-
- release_sock(sk);
-
- return copied;
- }
图2给出了第2种场景,这里涉及到prequeue队列。用户进程调用recv方法时,连接上没有任何接收并缓存到内核的报文,而socket是阻塞的,所以进程睡眠了。然后网卡中收到了TCP连接上的报文,此时prequeue队列开始产生作用。图2中tcp_low_latency为默认的0,套接字socket的SO_RCVLOWAT是默认的1,仍然是阻塞socket,如下图:
简单描述上述11个步骤:
1、用户进程分配了一块len大小的内存,将其传入recv这样的函数,同时socket参数皆为默认,即阻塞的、SO_RCVLOWAT为1。调用接收方法,其中flags参数为0。
2、C库和内核最终调用到tcp_recvmsg方法来处理。
3、锁住socket。
4、由于此时receive、prequeue、backlog队列都是空的,即没有拷贝1个字节的消息到用户内存中,而我们的最低要求是拷贝至少SO_RCVLOWAT为1长度的消息。此时,开始进入阻塞式套接字的等待流程。最长等待时间为SO_RCVTIMEO指定的时间。
这个等待函数叫做sk_wait_data,有必要看下其实现:
- int sk_wait_data(struct sock *sk, long *timeo)
- {
-
- rc = sk_wait_event(sk, timeo, !skb_queue_empty(&sk->sk_receive_queue));
- }
sk_wait_event也值得我们简单看下:
- #define sk_wait_event(__sk, __timeo, __condition) \
- ({ int rc; \
- release_sock(__sk); \
- rc = __condition; \
- if (!rc) { \
- *(__timeo) = schedule_timeout(*(__timeo)); \
- } \
- lock_sock(__sk); \
- rc = __condition; \
- rc; \
- })
注意,它在睡眠前会调用release_sock,这个方法会释放socket锁,使得下面的第5步中,新到的报文不再只能进入backlog队列。
5、这个套接字上期望接收的序号也是S1,此时网卡恰好收到了S1-S2的报文,在tcp_v4_rcv方法中,通过调用tcp_prequeue方法把报文插入到prequeue队列中。
6、插入prequeue队列后,此时会接着调用wake_up_interruptible方法,唤醒在socket上睡眠的进程。参见tcp_prequque方法。
7、用户进程被唤醒后,重新调用lock_sock接管了这个socket,此后再进来的报文都只能进入backlog队列了。
8、进程醒来后,先去检查receive队列,当然仍然是空的;再去检查prequeue队列,发现有一个报文S1-S2,正好是socket连接待拷贝的起始序号S1,于是,从prequeue队列中取出这个报文并把内容复制到用户内存中,再释放内核中的这个报文。
9、目前已经拷贝了S2-S1个字节到用户态,检查这个长度是否超过了最低阀值(即len和SO_RCVLOWAT的最小值)。
10、由于SO_RCVLOWAT使用了默认的1,所以准备返回用户。此时会顺带再看看backlog队列中有没有数据,若有,则检查这个无序的队列中是否有可以直接拷贝给用户的报文。当然,此时是没有的。所以准备返回,释放socket锁。
11、返回用户已经拷贝的字节数。
图3给出了第3种场景。这个场景中,我们把系统参数tcp_low_latency设为1,socket上设置了SO_RCVLOWAT属性的值。服务器先是收到了S1-S2这个报文,但S2-S1的长度是小于SO_RCVLOWAT的,用户进程调用recv方法读套接字时,虽然读到了一些,但没有达到最小阀值,所以进程睡眠了,与此同时,在睡眠前收到的乱序的S3-S4包直接进入backlog队列。此时先到达了S2-S3包,由于没有使用prequeue队列,而它起始序号正是下一个待拷贝的值,所以直接拷贝到用户内存中,总共拷贝字节数已满足SO_RCVLOWAT的要求!最后在返回用户前把backlog队列中S3-S4报文也拷贝给用户了。如下图:
简明描述上述15个步骤:
1、内核收到报文S1-S2,S1正是这个socket连接上待接收的序号,因此,直接将它插入有序的receive队列中。
2、用户进程所处的linux操作系统上,将sysctl中的tcp_low_latency设置为1。这意味着,这台服务器希望TCP进程能够更及时的接收到TCP消息。用户调用了recv方法接收socket上的消息,这个socket上设置了SO_RCVLOWAT属性为某个值n,这个n是大于S2-S1,也就是第1步收到的报文大小。这里,仍然是阻塞socket,用户依然是分配了足够大的len长度内存以接收TCP消息。
3、通过tcp_recvmsg方法来完成接收工作。先锁住socket,避免并发进程读取同一socket的同时,也在告诉内核网络软中断处理到这一socket时要有不同行为,如第6步。
4、准备处理内核各个接收队列中的报文。
5、receive队列中的有序报文可直接拷贝,在检查到S2-S1是小于len之后,将报文内容拷贝到用户态内存中。
6、在第5步进行的同时,socket是被锁住的,这时内核又收到了一个S3-S4报文,因此报文直接进入backlog队列。注意,这个报文不是有序的,因为此时连接上期待接收序号为S2。
7、在第5步,拷贝了S2-S1个字节到用户内存,它是小于SO_RCVLOWAT的,因此,由于socket是阻塞型套接字(超时时间在本文中忽略),进程将不得不转入睡眠。转入睡眠之前,还会干一件事,就是处理backlog队列里的报文,图2的第4步介绍过休眠方法sk_wait_data,它在睡眠前会执行release_sock方法,看看是如何实现的:
- void fastcall release_sock(struct sock *sk)
- {
- mutex_release(&sk->sk_lock.dep_map, 1, _RET_IP_);
-
- spin_lock_bh(&sk->sk_lock.slock);
-
- if (sk->sk_backlog.tail)
- __release_sock(sk);
-
- sk->sk_lock.owner = NULL;
- if (waitqueue_active(&sk->sk_lock.wq))
- wake_up(&sk->sk_lock.wq);
- spin_unlock_bh(&sk->sk_lock.slock);
- }
再看看__release_sock方法是如何遍历backlog队列的:
- static void __release_sock(struct sock *sk)
- {
- struct sk_buff *skb = sk->sk_backlog.head;
-
-
- do {
- sk->sk_backlog.head = sk->sk_backlog.tail = NULL;
- bh_unlock_sock(sk);
-
- do {
- struct sk_buff *next = skb->next;
-
- skb->next = NULL;
-
- 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);
- }
此时遍历到S3-S4报文,但因为它是失序的,所以从backlog队列中移入out_of_order队列中(参见上文说过的tcp_ofo_queue方法)。
8、进程休眠,直到超时或者receive队列不为空。
9、内核接收到了S2-S3报文。注意,这里由于打开了tcp_low_latency标志位,这个报文是不会进入prequeue队列以待进程上下文处理的。
10、此时,由于S2是连接上正要接收的序号,同时,有一个用户进程正在休眠等待接收数据中,且它要等待的数据起始序号正是S2,于是,这种种条件下,使得这一步同时也是网络软中断执行上下文中,把S2-S3报文直接拷贝进用户内存。
11、上文介绍tcp_data_queue方法时大家可以看到,每处理完1个有序报文(无论是拷贝到receive队列还是直接复制到用户内存)后都会检查out_of_order队列,看看是否有报文可以处理。那么,S3-S4报文恰好是待处理的,于是拷贝进用户内存。然后唤醒用户进程。
12、用户进程被唤醒了,当然唤醒后会先来拿到socket锁。以下执行又在进程上下文中了。
13、此时会检查已拷贝的字节数是否大于SO_RCVLOWAT,以及backlog队列是否为空。两者皆满足,准备返回。
14、释放socket锁,退出tcp_recvmsg方法。
15、返回用户已经复制的字节数S4-S1。
好了,这3个场景读完,想必大家对于TCP的接收流程是怎样的已经非常清楚了,本文起始的6个问题也在这一大篇中都涉及到了。下一篇我们来讨论TCP连接的关闭。
TCP连接的关闭有两个方法close和shutdown,这篇文章将尽量精简的说明它们分别做了些什么。
为方便阅读,我们可以带着以下5个问题来阅读本文:
1、当socket被多进程或者多线程共享时,关闭连接时有何区别?
2、关连接时,若连接上有来自对端的还未处理的消息,会怎么处理?
3、关连接时,若连接上有本进程待发送却未来得及发送出的消息,又会怎么处理?
4、so_linger这个功能的用处在哪?
5、对于监听socket执行关闭,和对处于ESTABLISH这种通讯的socket执行关闭,有何区别?
下面分三部分进行:首先说说多线程多进程关闭连接的区别;再用一幅流程图谈谈close;最后用一幅流程图说说shutdown。
先不提其原理和实现,从多进程、多线程下 close和shutdown方法调用时的区别说起。
看看close与shutdown这两个系统调用对应的内核函数:(参见unistd.h文件)
- #define __NR_close 3
- __SYSCALL(__NR_close, sys_close)
- #define __NR_shutdown 48
- __SYSCALL(__NR_shutdown, sys_shutdown)
但sys_close和sys_shutdown这两个系统调用最终是由tcp_close和tcp_shutdown方法来实现的,调用过程如下图所示:
sys_shutdown与多线程和多进程都没有任何关系,而sys_close则不然,上图中可以看到,层层封装调用中有一个方法叫fput,它有一个引用计数,记录这个socket被引用了多少次。在说明多线程或者多进程调用close的区别前,先在代码上简单看下close是怎么调用的,对内核代码没兴趣的同学可以仅看fput方法:
- void fastcall fput(struct file *file)
- {
- if (atomic_dec_and_test(&file->f_count))
- __fput(file);
- }
当这个socket的引用计数f_count不为0时,是不会触发到真正关闭TCP连接的tcp_close方法的。
那么,这个引用计数的意义何在呢?为了说明它,先要说道下进程与线程的区别。
大家知道,所谓线程其实就是“轻量级”的进程。创建进程只能是一个进程(父进程)创建另一个进程(子进程),子进程会复制父进程的资源,这里的”复制“针对不同的资源其意义是不同的,例如对内存、文件、TCP连接等。创建进程是由clone系统调用实现的,而创建线程时同样也是clone实现的,只不过clone的参数不同,其行为也很不同。这个话题是很大的,这里我们仅讨论下TCP连接。
在clone系统调用中,会调用方法copy_files来拷贝文件描述符(包括socket)。创建线程时,传入的flag参数中包含标志位CLONE_FILES,此时,线程将会共享父进程中的文件描述符。而创建进程时没有这个标志位,这时,会把进程打开的所有文件描述符的引用计数加1,即把file数据结构的f_count成员加1,如下:
- static int copy_files(unsigned long clone_flags, struct task_struct * tsk)
- {
- if (clone_flags & CLONE_FILES) {
- goto out;
- }
- newf = dup_fd(oldf, &error);
- out:
- return error;
- }
再看看dup_fd方法:
- static struct files_struct *dup_fd(struct files_struct *oldf, int *errorp)
- {
- for (i = open_files; i != 0; i--) {
- struct file *f = *old_fds++;
- if (f) {
- get_file(f);
- }
- }
- }
get_file宏就会加引用计数。
- #define get_file(x) atomic_inc(&(x)->f_count)
所以,子进程会将父进程中已经建立的socket加上引用计数。当进程中close一个socket时,只会减少引用计数,仅当引用计数为0时才会触发tcp_close。
到这里,对于第一个问题的close调用自然有了结论:单线程(进程)中使用close与多线程中是一致的,但这两者与多进程的行为并不一致,多进程中共享的同一个socket必须都调用了close才会真正的关闭连接。
而shutdown则不然,这里是没有引用计数什么事的,只要调用了就会去试图按需关闭连接。所以,调用shutdown与多线程、多进程无关。
下面我们首先深入探讨下close的行为,因为close比较shutdown来说要复杂许多。顺便回答其余四个问题。
TCP连接是一种双工的连接,何谓双工?即连接双方可以并行的发送或者接收消息,而无须顾及对方此时到底在发还是收消息。这样,关闭连接时,就存在3种情形:完全关闭连接;关闭发送消息的功能;关闭接收消息的功能。其中,后两者就叫做半关闭,由shutdown实现(所以 shutdown多出一个参数正是控制关闭发送或者关闭接收),前者由close实现。
TCP连接是一种可靠的连接,在这里可以这么理解:既要确认本机发出的包得到确认,又要确认收到的任何消息都已告知连接的对端。
以下主要从双工、可靠性这两点上理解连接的关闭。
TCP双工的这个特性使得连接的正常关闭需要四次握手,其含义为:主动端关闭了发送的功能;被动端认可;被动端也关闭了发送的功能;主动端认可。
但还存在程序异常的情形,此时,则通过异常的那端发送RST复位报文通知另一端关闭连接。
下图是close的主要流程:
这个图稍复杂,这是因为它覆盖了关闭监听句柄、关闭普通连接、关闭设置了SO_LINGER的连接这三种主要场景。
1)关闭监听句柄
先从最右边的分支说说关闭监听socket的那些事。用于listen的监听句柄也是使用close关闭,关闭这样的句柄含义当然很不同,它本身并不对应着某个TCP连接,但是,附着在它之上的却可能有半成品连接。什么意思呢?之前说过TCP是双工的,它的打开需要三次握手,三次握手也就是3个步骤,其含义为:客户端打开接收、发送的功能;服务器端认可并也打开接收、发送的功能;客户端认可。当第1、2步骤完成、第3步步骤未完成时,就会在服务器上有许多半连接,close这个操作主要是清理这些连接。
参照上图,close首先会移除keepalive定时器。keepalive功能常用于服务器上,防止僵死、异常退出的客户端占用服务器连接资源。移除此定时器后,若ESTABLISH状态的TCP连接在tcp_keepalive_time时间(如服务器上常配置为2小时)内没有通讯,服务器就会主动关闭连接。
接下来,关闭每一个半连接。如何关闭半连接?这时当然不能发FIN包,即正常的四次握手关闭连接,而是会发送RST复位标志去关闭请求。处理完所有半打开的连接close的任务就基本完成了。
2)关闭普通ESTABLISH状态的连接(未设置so_linger)
首先检查是否有接收到却未处理的消息。
如果close调用时存在收到远端的、没有处理的消息,这时根据close这一行为的意义,是要丢弃这些消息的。但丢弃消息后,意味着连接远端误以为发出的消息已经被本机收到处理了(因为ACK包确认过了),但实际上确是收到未处理,此时也不能使用正常的四次握手关闭,而是会向远端发送一个RST非正常复位关闭连接。这个做法的依据请参考draft-ietf-tcpimpl-prob-03.txt文档3.10节,
Failure to RST on close with data pending。所以,这也要求我们程序员在关闭连接时,要确保已经接收、处理了连接上的消息。
如果此时没有未处理的消息,那么进入发送FIN来关闭连接的阶段。
这时,先看看是否有待发送的消息。前一篇已经说过,发消息时要计算滑动窗口、拥塞窗口、angle算法等,这些因素可能导致消息会延迟发送的。如果有待发送的消息,那么要尽力保证这些消息都发出去的。所以,会在最后一个报文中加入FIN标志,同时,关闭用于减少网络中小报文的angle算法,向连接对端发送消息。如果没有待发送的消息,则构造一个报文,仅含有FIN标志位,发送出去关闭连接。
3)使用了so_linger的连接
首先要澄清,为何要有so_linger这个功能?因为我们可能有强可靠性的需求,也就是说,必须确保发出的消息、FIN都被对方收到。例如,有些响应发出后调用close关闭连接,接下来就会关闭进程。如果close时发出的消息其实丢失在网络中了,那么,进程突然退出时连接上发出的RST就可能被对方收到,而且,之前丢失的消息不会有重发来保障可靠性了。
so_linger用来保证对方收到了close时发出的消息,即,至少需要对方通过发送ACK且到达本机。
怎么保证呢?等待!close会阻塞住进程,直到确认对方收到了消息再返回。然而,网络环境又得复杂的,如果对方总是不响应怎么办?所以还需要l_linger这个超时时间,控制close阻塞进程的最长时间。注意,务必慎用so_linger,它会在不经意间降低你程序中代码的执行速度(close的阻塞)。
所以,当这个进程设置了so_linger后,前半段依然没变化。检查是否有未读消息,若有则发RST关连接,不会触发等待。接下来检查是否有未发送的消息时与第2种情形一致,设好FIN后关闭angle算法发出。接下来,则会设置最大等待时间l_linger,然后开始将进程睡眠,直到确认对方收到后才会醒来,将控制权交还给用户进程。
这里需要注意,so_linger不是确保连接被四次握手关闭再使close返回,而只是保证我方发出的消息都已被对方收到。例如,若对方程序写的有问题,当它收到FIN进入CLOSE_WAIT状态,却一直不调用close发出FIN,此时,对方仍然会通过ACK确认,我方收到了ACK进入FIN_WAIT2状态,但没收到对方的FIN,我方的close调用却不会再阻塞,close直接返回,控制权交还用户进程。
从上图可知,so_linger还有个偏门的用法,若l_linger超时时间竟被设为0,则不会触发FIN包的发送,而是直接RST复位关闭连接。我个人认为,这种玩法确没多大用处。
最后做个总结。调用close时,可能导致发送RST复位关闭连接,例如有未读消息、打开so_linger但l_linger却为0、关闭监听句柄时半打开的连接。更多时会导致发FIN来四次握手关闭连接,但打开so_linger可能导致close阻塞住等待着对方的ACK表明收到了消息。
最后来看看较为简单的shutdown。
1)shutdown可携带一个参数,取值有3个,分别意味着:只关闭读、只关闭写、同时关闭读写。
对于监听句柄,如果参数为关闭写,显然没有任何意义。但关闭读从某方面来说是有意义的,例如不再接受新的连接。看看最右边蓝色分支,针对监听句柄,若参数为关闭写,则不做任何事;若为关闭读,则把端口上的半打开连接使用RST关闭,与close如出一辙。
2)若shutdown的是半打开的连接,则发出RST来关闭连接。
3)若shutdown的是正常连接,那么关闭读其实与对端是没有关系的。只要本机把接收掉的消息丢掉,其实就等价于关闭读了,并不一定非要对端关闭写的。实际上,shutdown正是这么干的。若参数中的标志位含有关闭读,只是标识下,当我们调用read等方法时这个标识就起作用了,会使进程读不到任何数据。
4)若参数中有标志位为关闭写,那么下面做的事与close是一致的:发出FIN包,告诉对方,本机不会再发消息了。
以上,就是close与shutdown的主要行为,同时也回答了本文最初的5个问题。下一篇,我们开始讨论多路复用中常见的epoll。