关于TCP的CLOSING状态和CLOSE_WAIT状态浅析(附带一个2.6.32内核tun驱动BUG定位)

很多资料讲了关于TCP的CLOSING和CLOSE_WAIT状态以及所谓的优雅关闭的细节,多数侧重与Linux的内核实现(除了《UNIX网络编程》)。本文不注重代码细节,只关注逻辑。所使用的工具,tcpdump,packetdrill以及ss。
        关于ss可以先多说几句,它展示的信息跟netstat差不多,只不过更加详细。netstat的信息是通过procfs获取的,本质上来讲就是遍历/proc/net/netstat文件的内容,然后将其组织成可读的形式展示出来,然而ss则可以针对特定的五元组信息提供更加详细的内容,它不再通过procfs,而是用过Netlink来提取特定socket的信息,对于TCP而言,它可以提取到甚至tcp_info这种详细的信息,它包括cwnd,ssthresh,rtt,rto等。
        本文展示的逻辑使用了以下三样工具:

1).packetdrill

使用packetdrill构造出一系列的包序列,使得TCP进入CLOSING状态或者CLOSE_WAIT状态。

2).tcpdump/tshark

抓取packetdrill注入的数据包以及协议栈反馈的包,以确认数据包序列确实如TCP标准所述的那样。

3).ss/netstat

通过ss抓取packetdrill相关套接字的tcp_info,再次确认细节。
        我想,我使用上述的三件套解析了CLOSING状态之后,接下来的CLOSE_WAIT状态就可以当作练习了。
        我来一个一个说。

1.关于CLOSING状态

首先我来描述一下而不是细说概念。

        什么是CLOSING状态呢?我们来看一下下面的局部状态图:




也就是说,当两端都主动发送FIN的时候,并且在收到对方对自己发送的FIN之前收到了对方发送的FIN的时候,两边就都进入了CLOSING状态,这个在状态图上显示的很清楚。这个用俗话说就是”同时关闭“。时序图我就不给出了,请自行搜索或者自己画。

        有很多人都说,这种状态的TCP连接在系统中存在了好长时间并百思不得其解。这到底是为什么呢?通过状态图和时序图,我们知道,在进入CLOSING状态后,只要收到了对方对自己的FIN的ACK,就可以双双进入TIME_WAIT状态,因此,如果RTT处在一个可接受的范围内,发出的FIN会很快被ACK从而进入到TIME_WAIT状态,CLOSING状态应该持续的时间特别短。
        以下是packetdrill脚本,很简单的一个脚本:
0.000 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
0.000 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
0.000 bind(3, ..., ...) = 0
0.000 listen(3, 1) = 0

0.100 < S 0:0(0) win 32792 <mss 1460,sackOK,nop,nop,nop,wscale 7>
0.100 > S. 0:0(0) ack 1 win 5840 <mss 1460,nop,nop,sackOK,nop,wscale 7>
0.200 < . 1:1(0) ack 1 win 257
0.200 accept(3, ..., ...) = 4

// 象征性写入一些数据,装的像一点一个正常的TCP连接:握手-传输-挥手
0.250 write(4, ..., 1000) = 1000

0.300 < . 1:1(0) ack 1001 win 257

// 主动断开,发送FIN
0.400 close(4) = 0
// 在未对上述close的FIN进行ACK前,先FIN
0.500 < F. 1:1(0) ack 1001 win 260
// 至此,成功进入同时关闭的CLOSING状态。

// 由于packetdrill不能用dup调用,也好用多线程,为了维持进程不退出,只能等待
10000.000 close(4) = 0

同时,我启用tcpdump抓包,确认了TCP状态图的细节,即,还没有收到对方对FIN的ACK时,收到了对方的FIN:




有个异常,没有收到FIN的ACK(packetdrill没有回复,这正常,因为脚本里本来就没有这个语句),然而也没有看到重传,此时该连接应该是处于CLOSING状态了,用ss来确认:
CLOSING    1      1   192.168.0.1:webcache 192.0.2.1:54442   
          cubic wscale:7,7 rto:2000 rtt:50/25 ssthresh:2 send 467.2Kbps rcv_space:5840

果然,进入了CLOSING状态且没有消失,时不我待,当过了2秒以后,ss的结果变成了:
CLOSING    1      1   192.168.0.1:webcache 192.0.2.1:54442   
          cubic wscale:7,7 rto:4000 rtt:50/25 ssthresh:2 send 467.2Kbps rcv_space:5840

明显在退避!如果继续观察,你会发现rto退避到了64秒之多。在我的场景中,CLOSING状态的套接字维持了两分钟之久。
        然而,为什么呢?为什么CLOSING状态会维持这么久?为什么它没有继续维持下去直到永久呢?
        很明显,一端的FIN发出去后,没有收到ACK,因此会退避重发,知道4次退避,即2*2*2*2*2*2秒之久。现在的问题是,为什么重发FIN始终不成功呢?要是成功了的话,估计ACK瞬间也就回来了,那么CLOSING状态也就可以进入TIME_WAIT了,但是没有成功重传FIN!
        到此为止,我们知道,进入CLOSING状态之后,两边都会等待接收自己FIN的ACK,一旦收到ACK,就会进入TIME_WAIT,如此反复,如果收不到ACK,则会不断重传FIN,直到忍无可忍,将socket销毁。现在,我们集中于解释为什么重传没有成功,但是请记住,并不是每次都这样,只是在我这个packetdrill构造的场景中会有重传不成功,不然如果大概率不成功的话。岂不是每个CLOSING状态都要维持很长时间??!!
        在我的场景下,通过hook重传函数以及抓包确认,发现所有的重传虽然退避了,但是都没有真正将数据包发送出去,究其原因,最终确认问题出在以下代码上:
if (atomic_read(&sk->sk_wmem_alloc) >
    min(sk->sk_wmem_queued + (sk->sk_wmem_queued >> 2), sk->sk_sndbuf))
    return -EAGAIN;
在Linux协议栈的实现中,tcp_retransmit_skb由tcp_retransmit_timer调用,即便是这里出了些问题没有重传成功,也还是会退避的,退避超时到期后,继续在这里出错,直到”不可容忍“销毁socket。
        我们可以得知,不管如何CLOSING状态的TCP连接即便没有收到对自己FIN的ACK,也不会永久保持下去,保持多久取决于自己发送FIN时刻的RTT,然后RTT计算出的RTO按照最大的退避次数来退避,直到最终执行了固定次数的退避后,算出来的那个比较大的超时时间到期,然后TCP socket就销毁了。
        因此,CLOSING状态并不可怕,起码,不管怎样,它有一个可控的销毁时限。
...
现在我来解释重传不成功的细节。
        我们知道,根据上述的代码段,sk_wmem_alloc要足够大,大到它比sk_wmem_queued+sk_wmem_queued/4更大的时候,才会返回错误造成重传不成功,然而我们的packetdrill脚本中构造的TCP连接的生命周期中仅仅传输了1000个字节的数据,并且这1000个字节的数据得到了ACK,然后就结束了连接。一个socket保有一个sk_wmem_alloc字段,在skb交给这个socket的时候,该字段会增加skb长度的大小(skb本身大小包括skb数据大小),然而当skb不再由该socket持有的时候,也就是其被更底层的逻辑接管之后,socket的sk_wmem_alloc字段自然会减去skb长度的大小,这一切的过程由以下的函数决定,即skb_set_owner_w和skb_orphan。我们来看一下这两个函数:
static inline void skb_set_owner_w(struct sk_buff *skb, struct sock *sk)
{
    skb_orphan(skb);
    skb->sk = sk;
    // sock_wfree回调中会递减sk_wmem_alloc相应的大小,其大小就是skb->truesize
    skb->destructor = sock_wfree;
    /*
     * We used to take a refcount on sk, but following operation
     * is enough to guarantee sk_free() wont free this sock until
     * all in-flight packets are completed
     */
    atomic_add(skb->truesize, &sk->sk_wmem_alloc);
}
static inline void skb_orphan(struct sk_buff *skb)
{
    // 调用回调函数,递减sk_wmem_alloc
    if (skb->destructor)
        skb->destructor(skb);
    skb->destructor = NULL;
    skb->sk        = NULL;
}
也就是说,只要skb_orphan在skb通向网卡的路径上被正确调用,就会保证sk_wmem_alloc的值随着skb进入socket的管辖时而增加,而被实际发出后而减少。但是根据我的场景,事实好像不是这样,sk_wmem_alloc的值只要发送一个skb就会增加,丝毫没有减少的迹象...这是为什么呢?
        有的时候,当你对某个逻辑理解足够深入后,一定要相信自己的判断,内核存在BUG!内核并不完美。我使用的是2.6.32老内核,这个内核我已经使用了6年多,这是我在这个内核上发现的第4个BUG了。
        请注意,我的这个场景中,我使用了packetdrill来构造数据包,而packetdrill使用了tun网卡。为什么使用真实网卡甚至使用loopback网卡就不会有问题呢?这进一步引导我去调查tun的代码,果不其然,在其hard_xmit回调中没有调用skb_orphan!也就说说,但凡使用2.6.32内核版本tun驱动的,都会遇到这个问题呢。在tun的xmit中加入skb_orphan之后,问题消失,抓包你会发现大量的FIN重传包,这些重传随着退避而间隔加大(注意,用ss命令比对一下rto字段的值和tcpdump抓取的实际值):




(为了验证这个,我修改了packetdrill脚本,中间增加了很多的数据传输,以便尽快重现sk_wmem_alloc在使用tun时不递减的问题)于是,我联系了前公司的同事,让他们修改OpenVPN使用的tun驱动代码,因为当时确实出现过关于TCP使用OpenVPN隧道的重传问题,然而,得到的答复却是,xmit函数中已经有skb_orphan了...然后我看了下代码,发现,公司的代码已经不存在问题了,因为我在前年搞tun多队列的时候,已经移植了3.9.6的tun驱动,这个问题已经被修复。
        自己曾经做的事情,已然不再忆起...

2.关于CLOSE_WAIT状态

和CLOSING状态不同,CLOSE_WAIT状态可能会持续更久更久的时间,导致无用的socket无法释放,这个时间可能与应用进程的生命周期一样久!
        我们先看一下CLOSE_WAIT的局部状态图。




然后我来构造一个packetdrill脚本:
0.000 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
0.000 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
0.000 bind(3, ..., ...) = 0
0.000 listen(3, 1) = 0

0.100 < S 0:0(0) win 32792 <mss 1460,sackOK,nop,nop,nop,wscale 7>
0.100 > S. 0:0(0) ack 1 win 14600 <mss 1460,nop,nop,sackOK,nop,wscale 7>
0.200 < . 1:1(0) ack 1 win 257
0.200 accept(3, ..., ...) = 4
// 什么也不发了,直接断开
0.350 < F. 1:1(0) ack 1 win 260
// 协议栈会对这个FIN进行ACK,然则应用程序不关闭连接的话...
//0.450 close(4) = 0
// 该连接就会变成CLOSE_WAIT,并且只要其socket引用计数不为0,就一直僵死在那里
2000.000 close(4) = 0
同样的,我来展示抓包结果:




最后,和描述CLOSING状态不同的是,隔了N个小时之后,我来看ss -ip的结果:
CLOSE-WAIT 1      0     192.168.0.1:webcache    192.0.2.1:53753    users:(("ppp",2399,8))
         cubic wscale:7,7 rto:300 rtt:100/50 ato:40 cwnd:10 send 1.2Mbps rcv_space:14600

这个CLOSE_WAIT还在!这是为什么呢?
        很遗憾,上述的packetdrill脚本并不能直观地展示这个现象,还得靠我说一说。
        CLOSE_WAIT是一端在收到FIN之后,发送自己的FIN之前所处的状态,那么很显然,如果一个进程/线程始终不发送FIN,那么在该连接所隶属的socket的生命周期内,这个socket就会一直存在,我们知道,在UNIX/Linux/WinSock中,socket作为一个描述符出现,只要进程/线程继续持有它,它就会一直存在,因此大多数情况下进程/线程的生命周期内,此TCP套接字就会始终处在CLOSE_WAIT状态。进程/线程长时间持有不需要的socket描述符,更多的并不是有意的,而是在进行诸如fork/clone之类的系统调用后,dup了父亲的文件描述符,然后在孩子那里又没有及时关闭,另外的原因就是编程者对socket描述符的close接口以及shutdown接口不是很理解了。
        现在,我们用一个问题来继续我们的讨论。
        什么时候进程在超长的生命周期内不会如愿关闭TCP从而发送FIN呢?
        我的答案比较直接: 不能指望close会发送FIN!
相信很多人在想断开一个TCP连接的时候,都会调用close吧。并且这种做法几乎都是正确的,以至于很多人都把这作为一种标准的做法。但是这是不对的!Why?!在《UNIX网络编程》中,曾经提到了所谓的”优雅关闭TCP连接“,何谓优雅??!如果你充分理解close,shutdown,应该就会知道,CLOSE_WAIT出现,你应该可以给出一些解释。

close调用

close的参数只是一个文件描述符号,它不理解这个文件真正的细节,它只是一个文件系统内范畴的一个调用,它只是关闭文件描述符,保证此进程不会在读取它而已。如果你关闭了文件描述符4,即close(4),你知道4代表的文件会作何反应吗??文件系统并不知道4号描述符代表的文件到底是什么,更不知道有多少进程共享这个底层的”实体“,所以一个进程层面上逻辑根本没有权力去彻底关闭一个socket。如果你想了解close的细节,更应该去看看UNIX文件抽象或者文件系统的细节,而不是socket。请参见位于fs/open.c中的:
SYSCALL_DEFINE1(close, unsigned int, fd)
{
    ...
    fdt = files_fdtable(files);
    ...
    filp = fdt->fd[fd];
    ...
    retval = filp_close(filp, files);
    ...
    return retval;
    ...
}
EXPORT_SYMBOL(sys_close);
在filp_close中会有fput调用:
void fput(struct file *file)
{
    if (atomic_long_dec_and_test(&file->f_count))
        __fput(file);
}
看到那个引用计数了吗?只有当这个文件的引用计数变成0的时候,才会调用底层的关闭逻辑,对于socket而言,如果仍然还有一个进程或者线程持有这个socket对应的文件系统的描述符,那么即便你调用了close,也不会进入了socket的close逻辑,它在文件系统层面就返回了!

shutdown调用

这个才是真正关闭一个TCP连接的调用!shutdown并没有文件系统的语义,它专门针对内核层的TCP socket。因此,调用shutdown的逻辑,才是真正关闭了与之共享信道的tcp socket。
        所谓的优雅关闭,就是在调用close之前, 首先自己调用shutdown(RD or WD)。这样的时序才是关闭TCP的必由之路!
        如果你想优雅关闭一个TCP连接,请先用shutdown,然后后面跟一个close。不过有点诡异的是,Linux的shutdown(SHUT_RD)貌似没有任何效果,不过这无所谓了,本来对于读不读的,就不属于TCP的范畴,只有SHUT_WR才会实际发送一个FIN给对方。

        好了,本文写完了,昨天爬山肚皮疼,小憩一会儿,中午跟家人一起去吃海记牛肉去!

  • 7
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值