高性能网络编程

高性能网络编程

 

1、建立连接 accept

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

TCP通过三次握手建立连接,如下图,

当服务器绑定、监听了某个端口后,这个端口的SYN队列和ACCEPT队列就建立好了(在内核中实现)。注意,SYN队列存放的是未建立的连接,数值由内核 /proc/sys/net/ipv4/tcp_max_syn_backlog 参数决定,应用程序无法修改;而ACCEPT队列即listen函数的backlog参数决定,它是ESTABLISHED 状态的连接,这个数值不能超过 /proc/sys/net/core/somaxconn。

客户端使用connect向服务器发起TCP连接,当客户端的SYN包到达了服务器后,内核会把这一信息放到SYN队列(即未完成握手队列)中,同时回一个SYN+ACK包给客户端。一段时间后,客户端再次发来了针对服务器SYN包的ACK网络分组时,内核会把连接从SYN队列中取出,再把这个连接放到ACCEPT队列(即已完成握手队列)中。之后服务器在调用accept()时,其实就是直接从ACCEPT队列中取出已经建立成功的连接套接字而已。

 

如果上图中第1步执行的速度大于第2步执行的速度,SYN队列就会不断增大直到队列满;如果第2步执行的速度远大于第3步执行的速度,ACCEPT队列同样会达到上限。第1、2步不是应用程序可控的,但第3步却是应用程序的行为,假设进程中调用accept获取新连接的代码段长期得不到执行,例如获取不到锁、IO阻塞等。

另外,应用程序可以把listen时设置的套接字设为非阻塞模式(默认为阻塞模式),这两种模式会导致accept方法有不同的行为。

 

对阻塞套接字,accept行为如下图:

 

对非阻塞套接字,accept会有两种返回,如下图:

非阻塞套接字上的accept,不存在等待ACCEPT队列不为空的阶段,它要么返回成功并拿到建立好的连接,要么返回失败。

 

如果 accept 队列满,client 发来 ack,连接从 syn 队列移到 accept 队列的时候会发生什么呢?

1). 如果 /proc/sys/net/ipv4/tcp_abort_on_overflow 为1,会发送 RST;如果为0,则「什么都不做」,也就是「忽略」。

2). 但是,即使被忽略,对于 SYN RECEIVED 状态, 会有重试,重试次数定义在 /proc/sys/net/ipv4/tcp_synack_retries(重试时间有个算法)。

3). client 在收到 server 发来的重试 synack 之后,它认为之前发给 server 的 ack 丢失,会重发,此时如果 server 的 accept 队列有「空位」,会把连接移到 accpet 队列,并把 SYN RECEIVED 改成 ESTABLISHED。

4). 从另一个角度看, 即使 client 发的 ack 被忽略,因为 client 已经收到了 synack,client 认为连接已经建立,它可能会直接发送数据(ack 和 数据一起发送),这部分数据也会被忽略,会重传,幸好有「慢」启动机制保证重传的数据不会太多。

5). 如果 client 先等待 server 发来的数据,在 client 端连接是 ESTABLISHED,server 认为连接是 CLOSED,这会造成「半连接」。

6). 事实上,如果 accept 队列满了,内核会限制 syn 包的进入速度,如果太快,有些包会被丢弃。

 


 

2、消息发送 send

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

在建立的TCP连接上发送消息,可以使用send或者write函数,先看下发送消息时内核在做什么:

 

 

当我们调用发送方法时,会把我们代码中构造好的消息流作为参数传递。这个消息流可大可小,例如几个字节,或者几兆字节。当消息流较大时,将有可能出现分片。

我们先来讨论分片问题,数据链路层有一个MTU(最大传输单元)的限制,例如以太网限制为1500字节,802.3限制为1492字节,如果IP层报文长度大于MTU限制,就会被分成若干个小于MTU的报文,每个报文都会有独立的IP头部,如下图:

 

可以看出,IP头部中指定长度的字段有16位,这意味着一个IP包最大可以是65535字节。

若TCP层在以太网中试图发送一个大于1500字节的消息,调用IP网络层方法发送消息时,IP层会自动的获取所在局域网的MTU值,并按照所在网络的MTU大小来分片。IP层同时希望这个分片对于传输层来说是透明的,接收方的IP层会根据收到的多个IP包头部,将发送方IP层分片出的IP包重组为一个消息。
这种IP层的分片效率是很差的,因为必须所有分片都到达才能重组成一个包,其中任何一个分片丢失了,都必须重发所有分片。所以,TCP层会试图避免IP层执行数据报分片。

为了避免IP层的分片,TCP协议定义了一个新的概念:MSS(最大报文段长度),它定义了一个TCP连接上,一个主机期望对端主机发送单个报文的最大长度。TCP3次握手建立连接时,连接双方都要互相告知自己期望接收到的MSS大小。

如下图,是TCP三次握手的过程,SYN包携带了期望的MSS大小:

 

例子中两台主机都在以太网内,MTU=1500,减去IP和TCP头部,MSS就是1460。

这个MSS就不会改变了吗?
会的。上文说过,MSS就是为了避免IP层分片,在建立握手时告知对方期望接收的MSS值并不一定靠得住。因为这个值是预估的,TCP连接上的两台主机若处于不同的网络中,那么,连接上可能有许多中间网络,这些网络分别具有不同的数据链路层,这样,TCP连接上有许多个MTU。特别是,若中间途径的MTU小于两台主机所在的网络MTU时,选定的MSS仍然太大了,会导致中间路由器出现IP层的分片。
怎样避免中间网络可能出现的分片呢?
通过IP头部的DF标志位,这个标志位是告诉IP报文所途经的所有IP层代码:不要对这个报文分片。如果一个IP报文太大必须要分片,则直接返回一个ICMP错误,说明必须要分片了,且待分片路由器网络接受的MTU值。这样,连接上的发送方主机就可以重新确定MSS。

 

send方法的流程可以分为10步:

1、应用程序调用send方法来发送一段较长的数据;

2、内核通过tcp_sendmsg方法来完成;

3-4、内核真正执行报文的发送,与send方法的调用并不是同步的。即,send方法返回成功了,也不一定把IP报文都发送到网络中了。因此,得把需要发送的用户态内存中的数据,拷贝到内核态内存中,也使得进程可以快速释放发送数据占用的用户态内存。但这个拷贝操作并不是简单的复制,而是把待发送数据划分成多个尽量达到MSS大小的分片报文段,复制到内核中的sk_buff 结构来存放,同时把这些分片组成队列,放到这个TCP连接对应的tcp_write_queue发送队列中。

5、内核中为这个TCP连接分配的内核缓存是有限的(/proc/sys/net/core/wmem_default)。当没有多余的内核态缓存来复制用户态的待发送数据时,需要调用sk_stream_wait_memory方法来等待滑动窗口移动,释放出一些缓存出来(收到ACK后,不需要再缓存原来已经发送出的报文,因为既然已经确认对方收到,就不需要定时重发,自然就释放缓存了)。例如:

static inline long sock_sndtimeo(const struct sock *sk, int noblock)
{
    return noblock ? 0 : sk->sk_sndtimeo;
}

// 等待超时时间,对非阻塞套接字是0,对阻塞套接字由SO_SNDTIMEO选项指定
timeo = sock_sndtimeo(sk, flags & MSG_DONTWAIT); 

wait_for_memory:
    if (copied)
        tcp_push(sk, tp, flags & ~MSG_MORE, mss_now, TCP_NAGLE_PUSH);

    if ((err = sk_stream_wait_memory(sk, &timeo)) != 0)
        goto do_error;     

 

6、假定使用了阻塞套接字,且等待了足够久的时间,收到了对方的ACK,滑动窗口释放出了缓存。
7、将剩下的用户态数据都组成MSS报文拷贝到内核态的sk_buff中。
8、最后,调用tcp_push等方法(细节如下图),它最终会调用IP层的方法来发送tcp_write_queue队列中的报文。注意,IP层返回时,并不一定是把报文发送了出去。
9-10、发送方法返回。无论是使用阻塞还是非阻塞套接字,发送方法成功返回时(无论全部成功或者部分成功),既不代表TCP连接的另一端主机接收到了消息,也不代表本机把消息发送到了网络上,只是说明,内核将会试图保证把消息送达对方。

 

 

tcp_push方法的流程:

这里有几个概念:

滑动窗口:

TCP连接上的双方都会通知对方自己的接收窗口大小。而对方的接收窗口大小就是自己的发送窗口大小。tcp_push在发送数据时当然需要与发送窗口打交道。发送窗口是一个时刻变化的值,随着ACK的到达会变大,随着发出新的数据包会变小。当然,最大也只能到三次握手时对方通告的窗口大小。tcp_push在发送数据时,最终会使用tcp_snd_wnd_test方法来判断当前待发送的数据,其序号是否超出了发送滑动窗口的大小,例如:

//检查这一次要发送的报文最大序号是否超出了发送滑动窗口大小
static inline int tcp_snd_wnd_test(struct tcp_sock *tp, struct sk_buff *skb, unsigned int cur_mss)
{
    //end_seq待发送的最大序号
    u32 end_seq = TCP_SKB_CB(skb)->end_seq;

    if (skb->len > cur_mss)
        end_seq = TCP_SKB_CB(skb)->seq + cur_mss;

    //snd_una是已经发送过的数据中,最小的没被确认的序号;而snd_wnd就是发送窗口的大小
    return !after(end_seq, tp->snd_una + tp->snd_wnd);
}

 

 

慢启动和拥塞窗口:

由于两台主机间的网络可能很复杂,通过广域网时,中间的路由器转发能力可能是瓶颈。也就是说,如果一方简单的按照另一方主机三次握手时通告的滑动窗口大小来发送数据的话,可能会使得网络上的转发路由器性能雪上加霜,最终丢失更多的分组。这时,各个操作系统内核都会对TCP的发送阶段加入慢启动和拥塞避免算法。慢启动算法说白了,就是对方通告的窗口大小只表示对方接收TCP分组的能力,不表示中间网络能够处理分组的能力。所以,发送方请悠着点发,确保网络非常通畅了后,再按照对方通告窗口来敞开了发。
拥塞窗口(cwnd)用来帮助慢启动的实现,连接刚建立时,拥塞窗口的大小远小于发送窗口,它实际上是一个MSS。每收到一个ACK,拥塞窗口扩大一个MSS大小,当然,拥塞窗口最大只能到对方通告的接收窗口大小。当然,为了避免指数式增长,拥塞窗口大小的增长会更慢一些,是线性的平滑的增长过程。
所以,在tcp_push发送消息时,还会检查拥塞窗口,飞行中的报文数要小于拥塞窗口个数,而发送数据的长度也要小于拥塞窗口的长度。

 

 Nagle算法:

Nagle算法的初衷是这样的:应用进程调用发送方法时,可能每次只发送小块数据,造成这台机器发送了许多小的TCP报文。对于整个网络的执行效率来说,小的TCP报文会增加网络拥塞的可能,因此,如果有可能,应该将相临的TCP报文合并成一个较大的TCP报文(当然还是小于MSS的)发送。Nagle算法要求一个TCP连接上最多只能有一个发送出去还没被确认的小分组,在该分组的确认到达之前不能发送其他的小分组。

内核中是通过 tcp_nagle_test方法实现该算法的。我们简单的看下:

static inline int tcp_nagle_test(struct tcp_sock *tp, struct sk_buff *skb,
        unsigned int cur_mss, int nonagle)
{
    // nonagle标志位设置了,返回1表示允许这个分组发送出去
    if (nonagle & TCP_NAGLE_PUSH)
        return 1;

    // 如果这个分组包含了四次握手关闭连接的FIN包,也可以发送出去
    if (tp->urg_mode || (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN))
        return 1;

    // 检查Nagle算法, 返回0表示可以发送,返回非0则不可以
    if (!tcp_nagle_check(tp, skb, cur_mss, nonagle))
        return 1;

    return 0;
}


static inline int tcp_nagle_check(const struct tcp_sock *tp,
        const struct sk_buff *skb, 
        unsigned mss_now, int nonagle)
{
    //先检查是否为小分组,即报文长度是否小于MSS
    return (skb->len < mss_now &&
            ( (nonagle&TCP_NAGLE_CORK) ||
              (!nonagle             // 如果开启了Nagle算法
               && tp->packets_out   // 若已经有小分组发出(packets_out表示“飞行”中的分组)还没有确认
               && tcp_minshall_check(tp)) ) );
}

static inline int tcp_minshall_check(const struct tcp_sock *tp)
{
    //最后一次发送的小分组还没有被确认
    return after(tp->snd_sml,tp->snd_una) &&
        //将要发送的序号是要大于等于上次发送分组对应的序号
        !after(tp->snd_sml, tp->snd_nxt);
}

 

设置TCP_NODELAY选项可以关闭Nagle算法,看看setsockopt是怎么工作的:

static int do_tcp_setsockopt(struct sock *sk, int level,
        int optname, char __user *optval, int optlen)
{
    switch (optname) {
        case TCP_NODELAY:
            if (val) {
                //如果设置了TCP_NODELAY,则更新nonagle标志
                tp->nonagle |= TCP_NAGLE_OFF|TCP_NAGLE_PUSH;
                tcp_push_pending_frames(sk, tp);
            } else {
                tp->nonagle &= ~TCP_NAGLE_OFF;
            }
            break;
    }
}

 

 

 

 


 

3、消息接收 recv

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

接收TCP消息的过程可以一分为二:

  1. 网卡接收到的报文,通过软中断,内核拿到并且解析其为TCP报文,然后TCP模块决定如何处理这个TCP报文;
  2. 用户进程调用read、recv等方法获取TCP消息,则是将内核已经从网卡上收到的消息流拷贝到用户进程里的内存中。

 

内核在处理接收到的TCP报文时使用了4个队列:

  • receive队列,receive队列是允许用户进程直接读取的,它是将已经接收到的TCP报文,去除了TCP头部、排好序放入的、用户进程可以直接按序读取的队列;
  • out_of_order队列,存放乱序的报文;
  • backlog 队列,临时队列,当socker被锁住时(该socket有进程上下文),网卡收到的报文会放入backlog队列;
  • prequeue队列,如果sysctl_tcp_low_latency=1表示系统关闭prequeue队列,这是一个优化队列,可有可无。 并且如果有应用程序正在recvmsg,则才会把数据包放入prequeue中。

 

第一个场景:

如下图,应用进程使用了阻塞套接字,调用recv等方法时flag标志位为0,用户进程读取套接字时没有发生进程睡眠。

TCP连接上将要收到的消息序号是S1(TCP上的每个报文都有序号),此时操作系统内核依次收到了序号S1-S2的报文、S3-S4、S2-S3的报文,注意后两个包乱序了。之后,用户进程分配了一段len大小的内存用于接收TCP消息,此时,len是大于S4-S1的。另外,用户进程始终没有对这个socket设置过SO_RCVLOWAT参数,因此,接收阀值SO_RCVLOWAT使用默认值1。另外,系统参数tcp_low_latency设置为0,即从操作系统的总体效率出发,使用prequeue队列提升吞吐量。当然,由于用户进程收消息时,并没有新包来临,所以此图中prequeue队列始终为空,先不细表。

 

 上图的流程可以分为13个步骤:

  1. 当网卡接收到报文并判断为TCP协议后,将会调用到内核的tcp_v4_rcv方法。此时,这个TCP连接上需要接收的下一个报文序号恰好就是S1,而这一步里,网卡上收到了S1-S2的报文,所以,tcp_v4_rcv方法会把这个报文直接插入到receive队列中;
  2. 接着收到了S3-S4报文。在第1步结束后,这时我们需要收到的是S2序号,但到来的报文却是S3打头的,怎么办呢?进入out_of_order队列!从这个队列名称就可以看出来,所有乱序的报文都会暂时放在这儿;
  3. 用户进程仍然没有调用recv来读取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。
  6. C库和内核经过层层封装,接收TCP消息最终一定会走到tcp_recvmsg方法。下面介绍代码细节时,它会是重点。
  7. 在tcp_recvmsg方法里,会首先锁住socket。为什么呢?因此socket是可以被多进程同时使用的,同时,内核中断也会操作它,而下面的代码都是核心的、操作数据的、有状态的代码,不可以被重入的,锁住后,再有用户进程进来时拿不到锁就要休眠在这了。内核中断看到被锁住后也会做不同的处理。
  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队列有数据,就顺带处理下。
  12. 在本图对应的场景中,backlog队列是没有数据的,已经拷贝的字节数为S4-S1,它是大于1的,因此,释放第7步里加的锁,准备返回用户态了。
  13. 用户进程代码开始执行,此时recv等方法返回的就是S4-S1,即从内核拷贝的字节数。

 

第二个场景:

用户进程调用recv方法时,连接上没有任何接收并缓存到内核的报文,而socket是阻塞的,所以进程睡眠了。然后网卡收到了TCP连接上的报文,此时prequeue队列开始产生作用。

下图中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指定的时间;
  5. 这个套接字上期望接收的序号也是S1,此时网卡恰好收到了S1-S2的报文,在tcp_v4_rcv方法中,通过调用tcp_prequeue方法把报文插入到prequeue队列中。
  6. 插入prequeue队列后,此时会接着调用wake_up_interruptible方法,唤醒在socket上睡眠的进程。
  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. 返回用户已经拷贝的字节数。

 

 

第三个场景: 

这个场景中,我们把系统参数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方法,看看是如何实现的:
  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

 

 

 

MSG_PEEK

MSG_WAITALL

MSG_TRUNK

 

 

 

 SO_RCVLOWAT

 

 

 

 


 

4、关闭连接 close/shutdown

int close(int fd);
int shutdown(int sockfd, int how);

关闭TCP连接时可以考虑下面几个问题:

1、当socket被多进程共享时,关闭连接时有何区别?
2、关连接时,若连接上有来自对端的还未处理的消息,会怎么处理?
3、关连接时,若连接上有本进程待发送却未来得及发送出的消息,又会怎么处理?
4、so_linger这个功能的用处在哪?
5、对于监听socket执行关闭,和对处于ESTABLISH这种通讯的socket执行关闭,有何区别?
 
 
看看close与shutdown这两个系统调用对应的内核函数:(参见unistd.h文件)
#define __NR_close                               3
__SYSCALL(__NR_close, sys_close)
#define __NR_shutdown                           48
__SYSCALL(__NR_shutdown, sys_shutdown)

 

 

sys_shutdown与多进程无关,而sys_close则不然,上图中可以看到,层层封装调用中有一个方法叫fput,它有一个引用计数,记录这个socket被引用了多少次。在说明多进程调用close的区别前,先在代码上简单看下close是怎么调用的,对内核代码没兴趣的同学可以仅看fput方法:

void fastcall fput(struct file *file)
{
    if (atomic_dec_and_test(&file->f_count))//检查引用计数,直到为0才会真正去关闭socket
        __fput(file);
}
当这个socket的引用计数f_count不为0时,是不会触发到真正关闭TCP连接的tcp_close方法的。
如果使用fork创建子进程,子进程会复制父进程打开的文件描述符,会导致该文件描述符的引用计数增加,因此,多进程中共享的同一个socket必须都调用了close才会真正的关闭连接。

TCP连接是一种双工的连接,何谓双工?即连接双方可以并行的发送或者接收消息,而无须顾及对方此时到底在发还是收消息。这样,关闭连接时,就存在3种情形:

  1. 完全关闭连接;
  2. 关闭发送消息的功能;
  3. 关闭接收消息的功能。

其中,后两者就叫做半关闭,由shutdown实现(所以 shutdown多出一个参数正是控制关闭发送或者关闭接收),前者由close实现。

TCP双工的这个特性使得连接的正常关闭需要四次握手,其含义为:主动端关闭了发送的功能;被动端认可;被动端也关闭了发送的功能;主动端认可。
但还存在程序异常的情形,此时,则通过异常的那端发送RST复位报文通知另一端关闭连接。
下图是close的主要流程:

 

 这个图稍复杂,这是因为它覆盖了关闭监听句柄、关闭普通连接、关闭设置了SO_LINGER的连接这三种主要场景。

1)关闭监听句柄

先从最右边的分支说说关闭监听socket的那些事。用于listen的监听句柄也是使用close关闭,关闭这样的句柄含义当然很不同,它本身并不对应着某个TCP连接,但是,附着在它之上的却可能有半连接(half-open)。
参照上图,close首先会移除keepalive定时器。keepalive功能常用于服务器上,防止僵死、异常退出的客户端占用服务器连接资源。移除此定时器后,若ESTABLISH状态的TCP连接在tcp_keepalive_time时间(如服务器上常配置为2小时)内没有通讯,服务器就会主动关闭连接。
接下来,关闭每一个半连接。如何关闭半连接?这时当然不能发FIN包(即正常的四次握手关闭连接),而是会发送RST复位标志去关闭请求。处理完所有半打开的连接close的任务就基本完成了。
 
2)关闭普通ESTABLISH状态的连接(未设置so_linger)
首先检查是否有接收到却未处理的消息。
如果close调用时存在收到远端的、没有处理的消息,这时根据close这一行为的意义,是要丢弃这些消息的。但丢弃消息后,意味着连接远端误以为发出的消息已经被本机收到处理了(因为ACK包确认过了),但实际上确是收到未处理,此时也不能使用正常的四次握手关闭,而是会向远端发送一个RST非正常复位关闭连接。 
如果此时没有未处理的消息,那么进入发送FIN来关闭连接的阶段。
这时,先看看是否有待发送的消息。发消息时要计算滑动窗口、拥塞窗口、Nagle算法等,这些因素可能导致消息会延迟发送的。如果有待发送的消息,那么要尽力保证这些消息都发出去的。所以,会在最后一个报文中加入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包,告诉对方,本机不会再发消息了。

 

 

 

 

 

 

 

 

参考文档:

http://blog.csdn.net/russell_tao/article/details/9111769

http://blog.csdn.net/russell_tao/article/details/9370109

http://blog.csdn.net/russell_tao/article/details/9950615

http://blog.csdn.net/russell_tao/article/details/13092727

 

posted @ 2017-04-25 10:05  如果的事  阅读(1472)  评论(0编辑  收藏  举报