You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

16 KiB

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用来打开服务器的发送通道。这样原本的四次握手就降为三次握手了。

但是当连接处于半关闭状态时TCP是允许单向传输数据的。为便于下文描述**接下来我们把先关闭连接的一方叫做主动方,后关闭连接的一方叫做被动方。**当主动方关闭连接时被动方仍然可以在不调用close函数的状态下长时间发送数据此时连接处于半关闭状态。这一特性是TCP的双向通道互相独立所致却也使得关闭连接必须通过四次挥手才能做到。

**互联网中往往服务器才是主动关闭连接的一方。**这是因为HTTP消息是单向传输协议服务器接收完请求才能生成响应发送完响应后就会立刻关闭TCP连接这样及时释放了资源能够为更多的用户服务。

这就使得服务器的优化策略变得复杂起来。一方面,由于被动方有多种应对策略,从而增加了主动方的处理分支。另一方面,服务器同时为成千上万个用户服务,任何错误都会被庞大的用户数放大。所以对主动方的关闭连接参数调整时,需要格外小心。

了解了这一点之后,我们再来看四次挥手的流程。

**其实四次挥手只涉及两种报文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_WAITLinux系统下大约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状态时要么是程序出现了Bugread函数返回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参数控制。

接下来双方在等待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上的超时时间是怎样与系统配置参数协作的欢迎你在留言区与我一起探讨。

感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。