# 10 | 如何提升TCP四次挥手的性能? 你好,我是陶辉。 上一节课,我们介绍了建立连接时的优化方法,这一节课再来看四次挥手关闭连接时,如何优化性能。 close和shutdown函数都可以关闭连接,但这两种方式关闭的连接,不只功能上有差异,控制它们的Linux参数也不相同。close函数会让连接变为孤儿连接,shutdown函数则允许在半关闭的连接上长时间传输数据。TCP之所以具备这个功能,是因为它是全双工协议,但这也造成四次挥手非常复杂。 四次挥手中你可以用netstat命令观察到6种状态。其中,你多半看到过TIME\_WAIT状态。网上有许多文章介绍怎样减少TIME\_WAIT状态连接的数量,也有文章说TIME\_WAIT状态是必不可少、不能优化掉的。这两种看似自相矛盾的观点之所以存在,就在于优化连接关闭时,不能仅基于主机端的视角,还必须站在整个网络的层次上,才能给出正确的解决方案。 Linux为四次挥手提供了很多控制参数,有些参数的名称与含义并不相符。例如tcp\_orphan\_retries参数中有orphan孤儿,却同时对非孤儿连接也生效。而且,错误地配置这些参数,不只无法针对高并发场景提升性能,还会降低资源的使用效率,甚至引发数据错误。 这一讲,我们将基于四次挥手的流程,介绍Linux下的优化方法。 ## 四次挥手的流程 你想没想过,为什么建立连接是三次握手,而关闭连接需要四次挥手呢? 这是因为TCP不允许连接处于半打开状态时就单向传输数据,所以在三次握手建立连接时,服务器会把ACK和SYN放在一起发给客户端,其中,ACK用来打开客户端的发送通道,SYN用来打开服务器的发送通道。这样,原本的四次握手就降为三次握手了。 ![](https://static001.geekbang.org/resource/image/74/51/74ac4e70ef719f19270c08201fb53a51.png) 但是当连接处于半关闭状态时,TCP是允许单向传输数据的。为便于下文描述,**接下来我们把先关闭连接的一方叫做主动方,后关闭连接的一方叫做被动方。**当主动方关闭连接时,被动方仍然可以在不调用close函数的状态下,长时间发送数据,此时连接处于半关闭状态。这一特性是TCP的双向通道互相独立所致,却也使得关闭连接必须通过四次挥手才能做到。 **互联网中往往服务器才是主动关闭连接的一方。**这是因为,HTTP消息是单向传输协议,服务器接收完请求才能生成响应,发送完响应后就会立刻关闭TCP连接,这样及时释放了资源,能够为更多的用户服务。 这就使得服务器的优化策略变得复杂起来。一方面,由于被动方有多种应对策略,从而增加了主动方的处理分支。另一方面,服务器同时为成千上万个用户服务,任何错误都会被庞大的用户数放大。所以对主动方的关闭连接参数调整时,需要格外小心。 了解了这一点之后,我们再来看四次挥手的流程。 ![](https://static001.geekbang.org/resource/image/e2/b7/e2ef1347b3b4590da431dc236d9239b7.png) **其实四次挥手只涉及两种报文:FIN和ACK。**FIN就是Finish结束连接的意思,谁发出FIN报文,就表示它将不再发送任何数据,关闭这一方向的传输通道。ACK是Acknowledge确认的意思,它用来通知对方:你方的发送通道已经关闭。 当主动方关闭连接时,会发送FIN报文,此时主动方的连接状态由ESTABLISHED变为FIN\_WAIT1。当被动方收到FIN报文后,内核自动回复ACK报文,连接状态由ESTABLISHED变为CLOSE\_WAIT,顾名思义,它在等待进程调用close函数关闭连接。当主动方接收到这个ACK报文后,连接状态由FIN\_WAIT1变为FIN\_WAIT2,主动方的发送通道就关闭了。 再来看被动方的发送通道是如何关闭的。当被动方进入CLOSE\_WAIT状态时,进程的read函数会返回0,这样开发人员就会有针对性地调用close函数,进而触发内核发送FIN报文,此时被动方连接的状态变为LAST\_ACK。当主动方收到这个FIN报文时,内核会自动回复ACK,同时连接的状态由FIN\_WAIT2变为TIME\_WAIT,Linux系统下大约1分钟后TIME\_WAIT状态的连接才会彻底关闭。而被动方收到ACK报文后,连接就会关闭。 ## 主动方的优化 关闭连接有多种方式,比如进程异常退出时,针对它打开的连接,内核就会发送RST报文来关闭。RST的全称是Reset复位的意思,它可以不走四次挥手强行关闭连接,但当报文延迟或者重复传输时,这种方式会导致数据错乱,所以这是不得已而为之的关闭连接方案。 安全关闭连接的方式必须通过四次挥手,它由进程调用close或者shutdown函数发起,这二者都会向对方发送FIN报文(shutdown参数须传入SHUT\_WR或者SHUT\_RDWR才会发送FIN),区别在于close调用后,哪怕对方在半关闭状态下发送的数据到达主动方,进程也无法接收。 **此时,这个连接叫做孤儿连接,如果你用netstat -p命令,会发现连接对应的进程名为空。而shutdown函数调用后,即使连接进入了FIN\_WAIT1或者FIN\_WAIT2状态,它也不是孤儿连接,进程仍然可以继续接收数据。**关于孤儿连接的概念,下文调优参数时还会用到。 主动方发送FIN报文后,连接就处于FIN\_WAIT1状态下,该状态通常应在数十毫秒内转为FIN\_WAIT2。只有迟迟收不到对方返回的ACK时,才能用netstat命令观察到FIN\_WAIT1状态。此时,**内核会定时重发FIN报文,其中重发次数由tcp\_orphan\_retries参数控制**(注意,orphan虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有FIN\_WAIT1状态下的连接都有效),默认值是0,特指8次: ``` net.ipv4.tcp_orphan_retries = 0 ``` 如果FIN\_WAIT1状态连接有很多,你就需要考虑降低tcp\_orphan\_retries的值。当重试次数达到tcp\_orphan\_retries时,连接就会直接关闭掉。 **对于正常情况来说,调低tcp\_orphan\_retries已经够用,但如果遇到恶意攻击,FIN报文根本无法发送出去。**这是由TCP的2个特性导致的。 * 首先,TCP必须保证报文是有序发送的,FIN报文也不例外,当发送缓冲区还有数据没发送时,FIN报文也不能提前发送。 * 其次,TCP有流控功能,当接收方将接收窗口设为0时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过将接收窗口设为0,导致FIN报文无法发送,进而导致连接一直处于FIN\_WAIT1状态。 解决这种问题的方案是调整tcp\_max\_orphans参数: ``` net.ipv4.tcp_max_orphans = 16384 ``` 顾名思义,**tcp\_max\_orphans 定义了孤儿连接的最大数量。**当进程调用close函数关闭连接后,无论该连接是在FIN\_WAIT1状态,还是确实关闭了,这个连接都与该进程无关了,它变成了孤儿连接。Linux系统为防止孤儿连接过多,导致系统资源长期被占用,就提供了tcp\_max\_orphans参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送RST复位报文强制关闭。 当连接收到ACK进入FIN\_WAIT2状态后,就表示主动方的发送通道已经关闭,接下来将等待对方发送FIN报文,关闭对方的发送通道。这时,**如果连接是用shutdown函数关闭的,连接可以一直处于FIN\_WAIT2状态。但对于close函数关闭的孤儿连接,这个状态不可以持续太久,而tcp\_fin\_timeout控制了这个状态下连接的持续时长。** ``` net.ipv4.tcp_fin_timeout = 60 ``` 它的默认值是60秒。这意味着对于孤儿连接,如果60秒后还没有收到FIN报文,连接就会直接关闭。这个60秒并不是拍脑袋决定的,它与接下来介绍的TIME\_WAIT状态的持续时间是相同的,我们稍后再来回答60秒的由来。 TIME\_WAIT是主动方四次挥手的最后一个状态。当收到被动方发来的FIN报文时,主动方回复ACK,表示确认对方的发送通道已经关闭,连接随之进入TIME\_WAIT状态,等待60秒后关闭,为什么呢?我们必须站在整个网络的角度上,才能回答这个问题。 TIME\_WAIT状态的连接,在主动方看来确实已经关闭了。然而,被动方没有收到ACK报文前,连接还处于LAST\_ACK状态。如果这个ACK报文没有到达被动方,被动方就会重发FIN报文。重发次数仍然由前面介绍过的tcp\_orphan\_retries参数控制。 如果主动方不保留TIME\_WAIT状态,会发生什么呢?此时连接的端口恢复了自由身,可以复用于新连接了。然而,被动方的FIN报文可能再次到达,这既可能是网络中的路由器重复发送,也有可能是被动方没收到ACK时基于tcp\_orphan\_retries参数重发。这样,**正常通讯的新连接就可能被重复发送的FIN报文误关闭。**保留TIME\_WAIT状态,就可以应付重发的FIN报文,当然,其他数据报文也有可能重发,所以TIME\_WAIT状态还能避免数据错乱。 我们再回过头来看看,为什么TIME\_WAIT状态要保持60秒呢?这与孤儿连接FIN\_WAIT2状态默认保留60秒的原理是一样的,**因为这两个状态都需要保持2MSL时长。MSL全称是Maximum Segment Lifetime,它定义了一个报文在网络中的最长生存时间**(报文每经过一次路由器的转发,IP头部的TTL字段就会减1,减到0时报文就被丢弃,这就限制了报文的最长存活时间)。 为什么是2 MSL的时长呢?这其实是相当于至少允许报文丢失一次。比如,若ACK在一个MSL内丢失,这样被动方重发的FIN会在第2个MSL内到达,TIME\_WAIT状态的连接可以应对。为什么不是4或者8 MSL的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。 **因此,TIME\_WAIT和FIN\_WAIT2状态的最大时长都是2 MSL,由于在Linux系统中,MSL的值固定为30秒,所以它们都是60秒。** 虽然TIME\_WAIT状态的存在是有必要的,但它毕竟在消耗系统资源,比如TIME\_WAIT状态的端口就无法供新连接使用。怎样解决这个问题呢? **Linux提供了tcp\_max\_tw\_buckets 参数,当TIME\_WAIT的连接数量超过该参数时,新关闭的连接就不再经历TIME\_WAIT而直接关闭。** ``` net.ipv4.tcp_max_tw_buckets = 5000 ``` 当服务器的并发连接增多时,相应地,同时处于TIME\_WAIT状态的连接数量也会变多,此时就应当调大tcp\_max\_tw\_buckets参数,减少不同连接间数据错乱的概率。 当然,tcp\_max\_tw\_buckets也不是越大越好,毕竟内存和端口号都是有限的。有没有办法让新连接复用TIME\_WAIT状态的端口呢?如果服务器会主动向上游服务器发起连接的话,就可以把tcp\_tw\_reuse参数设置为1,它允许作为客户端的新连接,在安全条件下使用TIME\_WAIT状态下的端口。 ``` net.ipv4.tcp_tw_reuse = 1 ``` 当然,要想使tcp\_tw\_reuse生效,还得把timestamps参数设置为1,它满足安全复用的先决条件(对方也要打开tcp\_timestamps ): ``` net.ipv4.tcp_timestamps = 1 ``` 老版本的Linux还提供了tcp\_tw\_recycle参数,它并不要求TIME\_WAIT状态存在60秒,很容易导致数据错乱,不建议设置为1。 ``` net.ipv4.tcp_tw_recycle = 0 ``` 所以在Linux 4.12版本后,直接取消了这一参数。 ## 被动方的优化 当被动方收到FIN报文时,就开启了被动方的四次挥手流程。内核自动回复ACK报文后,连接就进入CLOSE\_WAIT状态,顾名思义,它表示等待进程调用close函数关闭连接。 内核没有权力替代进程去关闭连接,因为若主动方是通过shutdown关闭连接,那么它就是想在半关闭连接上接收数据。**因此,Linux并没有限制CLOSE\_WAIT状态的持续时间。** 当然,大多数应用程序并不使用shutdown函数关闭连接,所以,当你用netstat命令发现大量CLOSE\_WAIT状态时,要么是程序出现了Bug,read函数返回0时忘记调用close函数关闭连接,要么就是程序负载太高,close函数所在的回调函数被延迟执行了。此时,我们应当在应用代码层面解决问题。 由于CLOSE\_WAIT状态下,连接已经处于半关闭状态,所以此时进程若要关闭连接,只能调用close函数(再调用shutdown关闭单向通道就没有意义了),内核就会发出FIN报文关闭发送通道,同时连接进入LAST\_ACK状态,等待主动方返回ACK来确认连接关闭。 如果迟迟等不到ACK,内核就会重发FIN报文,重发次数仍然由tcp\_orphan\_retries参数控制,这与主动方重发FIN报文的优化策略一致。 至此,由一方主动发起四次挥手的流程就介绍完了。需要你注意的是,**如果被动方迅速调用close函数,那么被动方的ACK和FIN有可能在一个报文中发送,这样看起来,四次挥手会变成三次挥手,这只是一种特殊情况,不用在意。** 我们再来看一种特例,如果连接双方同时关闭连接,会怎么样? 此时,上面介绍过的优化策略仍然适用。两方发送FIN报文时,都认为自己是主动方,所以都进入了FIN\_WAIT1状态,FIN报文的重发次数仍由tcp\_orphan\_retries参数控制。 ![](https://static001.geekbang.org/resource/image/04/52/043752a3957d36f4e3c82cd83d472452.png) 接下来,双方在等待ACK报文的过程中,都等来了FIN报文。这是一种新情况,所以连接会进入一种叫做CLOSING的新状态,它替代了FIN\_WAIT2状态。此时,内核回复ACK确认对方发送通道的关闭,仅己方的FIN报文对应的ACK还没有收到。所以,CLOSING状态与LAST\_ACK状态下的连接很相似,它会在适时重发FIN报文的情况下最终关闭。 ## 小结 我们对这一讲的内容做个小结。 今天我们讲了四次挥手的流程,你需要根据主动方与被动方的连接状态变化来调整系统参数,使它在特定网络条件下更及时地释放资源。 四次挥手的主动方,为了应对丢包,允许在tcp\_orphan\_retries次数内重发FIN报文。当收到ACK报文,连接就进入了FIN\_WAIT2状态,此时系统的行为依赖这是否为孤儿连接。 如果这是close函数关闭的孤儿连接,那么在tcp\_fin\_timeout秒内没有收到对方的FIN报文,连接就直接关闭,反之shutdown函数关闭的连接则不受此限制。毕竟孤儿连接可能在重发次数内存在数分钟之久,为了应对孤儿连接占用太多的资源,tcp\_max\_orphans定义了最大孤儿连接的数量,超过时连接就会直接释放。 当接收到FIN报文,并返回ACK后,主动方的连接进入TIME\_WAIT状态。这一状态会持续1分钟,为了防止TIME\_WAIT状态占用太多的资源,tcp\_max\_tw\_buckets定义了最大数量,超过时连接也会直接释放。当TIME\_WAIT状态过多时,还可以通过设置tcp\_tw\_reuse和tcp\_timestamps为1 ,将TIME\_WAIT状态的端口复用于作为客户端的新连接。 被动关闭的连接方应对非常简单,它在回复ACK后就进入了CLOSE\_WAIT状态,等待进程调用close函数关闭连接。因此,出现大量CLOSE\_WAIT状态的连接时,应当从应用程序中找问题。当被动方发送FIN报文后,连接就进入LAST\_ACK状态,在未等来ACK时,会在tcp\_orphan\_retries参数的控制下重发FIN报文。 至此,TCP连接建立、关闭时的性能优化就介绍完了。下一讲,我们将专注在TCP上传输数据时,如何优化内存的使用效率。 ## 思考题 最后,给你留一个思考题。你知道关闭连接时的SO\_LINGER选项吗?它希望用四次挥手替代RST关闭连接的方式,防止浏览器没有接收到完整的HTTP响应。请你思考一下,SO\_LINGER会怎么影响主动方连接的状态变化?SO\_LINGER上的超时时间,是怎样与系统配置参数协作的?欢迎你在留言区与我一起探讨。 感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。