Linux 内核协议栈之TCP连接关闭

Close行为:

当应用程序在调用close()函数关闭TCP连接时,Linux内核的默认行为是将套接口发送队列里的原有数据(比如之前残留的数据)以及新加入 的数据(比如函数close()产生的FIN标记,如果发送队列没有残留之前的数据,那么这个FIN标记将单独产生一个新数据包)发送出去并且销毁套接口 (并非把相关资源全部释放,比如只是把内核对象sock标记为dead状态等,这样当函数close()返回后,TCP发送队列的数据包仍然可以继续由内 核协议栈发送,但是一些相关操作就会受到影响和限制,比如对数据包发送失败后的重传次数)后立即返回。这需要知道两点:

第一,当应用程序获得 close()函数的返回值时,待发送的数据可能还处在Linux内核的TCP发送队列里,因为当我们调用write()函数成功写出数据时,仅表示这些 数据被Linux内核接收放入到发送队列,如果此时立即调用close()函数返回后,那么刚才write()的数据限于TCP本身的拥塞控制机制(比如 发送窗口、接收窗口等等),完全有可能还呆在TCP发送队列里而未被发送出去;当然也有可能发送出去一些,毕竟在调用函数close()时,进入到 Linux内核后有一次数据包的主动发送机会,即:
tcp_close() -> tcp_send_fin() -> __tcp_push_pending_frames() -> tcp_write_xmit()

第二,所有这些数据的发送,最终却并不一定能全部被对端确认(即数据包到了对端TCP协议栈的接收队列),只能做到TCP协议本身提供的一定程度的 保证,比如TCP协议的重传机制(并且受close()函数影响,重传机制弱化,也就是如果出现类似系统资源不足这样的问题,调用过close()函数进 行关闭的套接口所对应的这些数据会优先丢弃)等,因为如果网络不好可能导致TCP协议放弃继续重传或在意外收到对端发送过来的数据时连接被重置导致未成功 发送的数据全部丢失(后面会看到这种情况)。

注意:

当一条TCP连接被多个进程共享时,如果其中一个进程调用close()函数关闭其对应的套接口时,调用到内核里仅仅只是减少对应的引用计数,而不会对 TCP连接做任何关闭操作(即在前面路径就已经返回了);只有当最后一个进程进行close()关闭时,引用计数变为0时才进行真正的套接口释放操作(也 即此时才会深调到tcp_close()函数内)。而函数shutdown()不一样,它是套接口类型描述符所特有的操作,直接作用于套接口连接,根本就 没有考虑引用计数的影响,这从它的调用路径就可以基本看出这一点。

SO_LINGER行为:

Linux提供了一个套接口选项SO_LINGER,可以改变在套接口上执行close()函数时的默认行为。选项SO_LINGER用到的相关参数主要是一个linger结构体:

1

2

3

4

5

50: Filename : \linux-3.4.4\include\linux\socket.h

51: struct linger {

52:     int     l_onoff;    /* Linger active        */

53:     int     l_linger;   /* How long to linger for   */

54: };

注释很清楚,字段l_onoff标记是否启用Linger特性,非0为启用,0为禁用(即内核对close()函数采取默认行为);字段 l_onoff为非0的情况下,字段l_linger生效,如果它的值为0,则导致所有数据丢失且连接立即中止即发送RST;

如果字段l_linger的值为非0(假 定为t秒),那么此时函数close()将被阻塞(假定为阻塞模式)直到:
1) 待发送的数据全部得到了对端确认,返回值为0;
2) 超时返回,返回值为-1,errno被设置为EWOULDBLOCK。
上面两点是很多介绍TCP/IP协议的经典书(比如Richard Steven的《Unix网络编程》)上所描述的,但是却并不适合Linux系统上的实现(《Unix网络编程》应该是根据BSD上的实现来讲的,所以有 些结论不适合Linux系统上的实现,这很正常)。在Linux系统上,应该是函数close()将被阻塞(假定为阻塞模式)直到:
1) 待发送的数据全部得到了对端确认,返回值为0;
2) 发生信号中断或异常(比如意外收到对端发送过来的数据)或超时,返回值为0;

也就是说,在Linux系统上,针对SO_LINGER选项而言,不论哪种情况,函数close()总是返回0(注意我所针对的情况,我并没有说在 Linux系统上,函数close()就总是返回0,如果你关闭一个无效的描述符,它同样也会返回-EBADF的错误),并且对于情况2),Linux内核不会清空缓存区,更加不会向对端发送RST数据包(此处指的是发送缓冲区有数据时,而接受缓冲区没有数据时,一旦接受缓冲区也有数据时就会直接发送RST。原因:tcp连接可以半开半闭,描述符close()掉后,缓存的数据是可以继续发送出去的,因此“Linux内核不会清空缓存区,更加不会向对端发送RST数 据包”,但对应的描述符不能收数据了(因为close()掉了),所以,如果此时对方发了数据过来就会发送RST以通知对方,如果要发数据过来,必须重置连接。),即执行close()函数的后半部分代码时不会因此发送任何特别的流程变化(当然,因为 close()函数阻塞了一段时间,在这段时间内,套接口相关字段可能被TCP协议栈修改过了,所以导致相关判断结果发生变化,但这并不是由于情况2)直 接导致)。你可以说这是Linux内核实现的BUG,但从Linux 2.2+ 开始,它就一直存在,但从未被修复,个人猜测原因有二:第一,基本不会有“通过检测close()返回值来判断待发送数据是否发送成功”这种需求,检测 close()返回值更多的是用来判断当前关闭的描述符是否有效等;第二,即便判断出数据没有发送成功,此时套接口的相关资源已经释放(当然,也可以实现 对资源先不释放,但如果这样完全保留,那么将导致系统不必要的资源浪费),应用程序也无法做出更多补救措施,除了打印一条错误日志以外。更重要的是,实现 “判断待发送数据是否成功发送”的需求有更好的不深度依赖Linux内核的应用层实现方式,即后面将提到的shutdown()函数,至于close() 函数,做好套接口关闭这一单独的功能就好。所以,即便Linux内核对启用SO_LINGER选项的套接口调用函数close()的各种情况统一返回0也 并无特别严重之处。

那么,在Linux系统上,选项SO_LINGER是否就没有什么实用的价值了?当然不是,首先,它完全实现了l_onof非0而l_linger 为0情况下的逻辑;其次,它的确阻塞了close()函数,直到待发送的数据全部得到了对端确认或信号中断、异常、超时返回;在阻塞的这一段时间内,套接 口尚且还处在正常状态,即此时还没有打上SOCK_DEAD的标记,因此TCP重传等各种机制还能平等使用,保证待发送数据发送成功的概率更大。

 

 

那么,编写TCP网络程序涉及到的一个重要问题凸显出来了,即:如何尽全力(不可能做到百分之百保证,比如如果网络断了,那自然没法)保证 write()写出的数据正确的到达对端TCP协议栈的接收队列而又不被其意外丢弃?如果要求正确到达对端应用层的对应程序,那么自然就需要在应用程序内 做相互确认,而这只适应我们对客户端和服务器端都可控的情况;对于nginx而言,我们可控的只有服务器端,所以这里不讨论这种需求情况。

在Linux系统上,前面已经说明了单独的选项SO_LINGER对此是无能为力的,所以需要结合选项SO_LINGER、函数close()、函数shutdown()、函数read()做配合设计:
1) 设置SO_LINGER选项参数l_onof非0而l_linger为0;
2) 调用函数shutdown(sock_fd, SHUT_WR);
3) 设置超时定时器,假定为t秒;
4) 调用函数read(sock_fd)阻塞等待,直到读到EOF或被定时器超时中断。
5) 执行函数close(sock_fd)或者调用exit(0)进程退出。

这个设计较好的解决了前面讨论的使用SO_LINGER选项与close()函数的两个问题,第一:调用close()关闭套接口时或后,如果接收队列里 存在有对端发送过来的数据,那么根据文档RFC 2525,此时需给对端发送一个RST数据包;假定有这样一种场景(以HTTP的pipelining情况为例,HTTP协议有点特殊,它基本是 request/response的单向数据发送形式,如果是其它交互同时性更强的应用,出现问题的概念更大,但因为我们对HTTP应用比较熟悉,所以就 用它为例以更容易理解):
1) 客户端应用程序在同一条TCP连接里连续向服务器端发送120个请求(访问很多门户网站的首页时,请求的文件可能还不止这个数目)。
2) 客户端的所有请求数据顺序到达服务器端,服务器端应用程序即开始逐个从内核里读取请求数据处理并把响应数据通过网络发回给客户端。
3) 服务器端应用程序(假定为nginx)限制了在一条连接上只能处理100个请求,因此在处理完第100个请求后结束,调用close()函数关闭连接。
4) 服务器端内核执行对应的tcp_close()函数时发现接收队列还有请求数据(即请求101-120)因此发送一个RST数据包到客户端。
5) 客户端应用程序依次从内核TCP接收队列读取服务器端发回的响应数据,但恰好正在读取第85个请求的响应数据时,客户端内核收到服务器端的RST数据包,因而丢掉所有接收内容,这包括已被服务器端正常处理了的请求86-100的响应数据。

也就是,上面这种场景下,服务器端write()写出的数据已经正确的到达对端TCP协议栈的接收队列,但却因为服务器端的原因而导致其被意外丢弃。设置SO_LINGER选项是徒劳的,因为在这种情况下,服务器端照样会发送RST数据包到。
调用函数shutdown(fd, SHUT_WR)是解决第一个问题的关键,它仅设置套接口不可写,即向对端发送一个FIN数据包,表示本端没有数据需要继续发送,但是还可以接收数据,所 以此时的套接口对应接收队列里有数据或后续收到对端发送过来的数据都不会导致服务器端发送RST数据包,避免了客户端丢弃已正确收到的响应数据。

接着的第二个问题就是:对数据发送是否成功的判断以及如何对超时连接进行及时释放?前面阐述了利用close()函数无法达到这个目的,即便辅助使用 SO_LINGER选项。在这里,我们设计等待让对端先关闭,当然,这个等待是有时限的,所以需设置一个定时器,然后阻塞read(),如果读到EOF, 也就是对端进行了主动关闭,发送了FIN数据包过来,那么意味着我们发送的数据已经被对端成功接收,此时执行close()函数将会直接关闭:

如果定时器超时(如果是信号中断,可继续阻塞read(),直到超时为止),那么说明数据多半没有发送成功(因为在正常情况下,一旦对端收到我们发送过去 的FIN数据包,即便多做了一些其它处理,它也应该会很快的执行close()进行套接口关闭),在这种情况下,我们执行函数close()进行套接口关 闭,由于SO_LINGER选项设置的影响(参数l_onof非0而l_linger为0),此时将直接发送RST包强行中断,因为此时的连接已经超时异 常,没必要再做常规的四次挥手流程,把资源及时释放更好。

总结:

默认close时,存在两个问题;

1、 如果在close时,发送缓冲区中有数据还没有发送出去,本端应用程序无法确认,对端tcp是否能收到(仅由tcp的机制实现安全交付)

2、 如果close时,接收缓冲区还有数据没有被应用程序读时,会直接发送RST包到对端,有可能导致对端的应用程序正在读取tcp缓冲区数据,却突然收到RST包,导致对端tcp丢弃发送和接收缓冲区里的所有数据,应该程序会丢数据

 

在linux中so_linger的作用:

它完全实现了l_onof非0而l_linger为0情况下的逻辑(直接发送RST所有数据丢失且连接立即中止;其次,它的确阻塞了close()函数,直到待发送的数据全部得到了对端确认或信号中 断、异常、超时返回;在阻塞的这一段时间内,套接口尚且还处在正常状态,即此时还没有打上SOCK_DEAD的标记,因此TCP重传等各种机制还能平等使 用,保证待发送数据发送成功的概率更大。但是无论怎么样返回值都为0,应用程序判断不出来,具体是那种情况。

 

 

正确做法:

在Linux系统上,前面已经说明了单独的选项SO_LINGER对此是无能为力的,所以需要结合选项SO_LINGER、函数close()、函数shutdown()、函数read()做配合设计:
1) 设置SO_LINGER选项参数l_onof非0而l_linger为0;
2) 调用函数shutdown(sock_fd, SHUT_WR);
3) 设置超时定时器,假定为t秒;
4) 调用函数read(sock_fd)阻塞等待,直到读到EOF或被定时器超时中断。
5) 执行函数close(sock_fd)或者调用exit(0)进程退出。

 

这里有个细节需要注意,如果read是因为读到对方的fin唤醒后,我们在close fd 仅仅清除fd资源,因为TCP连接双方已经正常关闭;如果是read超时了而唤醒啦,那么说明数据多半没有发送成功(因为在正常情况下,一旦对端收到我们发送过去的FIN数据包,即便多做了一些其它处理,它也应该会很快的执行close()进行套接口关闭),在这种情况下,我们执行函数close()进行套接口关闭,由于SO_LINGER选项设置的影响(参数l_onof非0而l_linger为0),此时将直接发送RST包强行中断,因为此时的连接已经超时异常,没必要再做常规的四次挥手流程,把资源及时释放更好。

上一篇:sql转百分比并保留两位小数


下一篇:.NET Core 2.1 Preview 2发布 - April 10, 2018