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.

168 lines
16 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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\_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参数控制。
![](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上的超时时间是怎样与系统配置参数协作的欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。