4、TCP四次挥手与Socket:四次挥手过程、TIME_WAIT状态、端口复用与SO_REUSEADDR
聊完三次握手,咱们来看看挥手。说实话,握手是建立连接,挥手是断开连接,但挥手比握手复杂得多。我刚开始写网络程序时,总觉得断开连接不就是close()一下吗?结果线上服务端口被占用了,半天起不来——嗯,这就是没搞懂四次挥手和TIME_WAIT的代价。
4.1 四次挥手过程:谁先走?
TCP是全双工的,意味着双方都能独立地发送和接收数据。所以断开连接时,每一方都要单独关闭自己的发送通道。这就导致了四次交互。
假设客户端主动发起关闭,流程是这样的:
- 客户端发送FIN:客户端调用close(),发送一个FIN包,告诉服务端“我没数据要发了”。此时客户端进入
FIN_WAIT_1状态。 - 服务端回复ACK:服务端收到FIN,回复ACK,表示“我知道了”。服务端进入
CLOSE_WAIT状态,客户端收到ACK后进入FIN_WAIT_2状态。 - 服务端发送FIN:服务端处理完剩余数据后,也调用close(),发送FIN包,告诉客户端“我也没数据了”。服务端进入
LAST_ACK状态。 - 客户端回复ACK:客户端收到FIN,回复ACK,然后进入
TIME_WAIT状态。服务端收到ACK后关闭连接。
关键点:第二步和第三步之间,服务端可能还有数据要发送。这就是为什么挥手需要四次,而不是三次。如果服务端在收到FIN时已经没有数据要发了,可以把ACK和FIN合并发送,变成三次挥手——但这种情况很少见。
4.2 TIME_WAIT状态:为什么非要等2MSL?
四次挥手里最让人头疼的就是TIME_WAIT。客户端发送最后一个ACK后,不会立即关闭,而是等待2MSL(Maximum Segment Lifetime,最大报文段生存时间)。MSL在Linux上默认是30秒,所以TIME_WAIT通常持续60秒。
为什么要等?两个原因:
- 保证最后一个ACK能到达服务端:如果ACK丢了,服务端会重发FIN。客户端在TIME_WAIT期间能收到这个重发的FIN,并再次回复ACK。如果客户端直接关闭了,收到重发的FIN后会回复RST,服务端就会认为连接异常关闭。
- 防止旧连接的数据干扰新连接:网络中有可能残留上一个连接的数据包。等2MSL后,这些数据包都消失在网络中了,不会污染使用相同四元组的新连接。
我曾经踩过的坑:有一次压测,客户端频繁创建和关闭连接,结果TIME_WAIT堆积到几万个,导致新连接无法建立——因为端口被占满了。后来我调整了net.ipv4.tcp_tw_reuse和net.ipv4.tcp_tw_recycle参数才解决。不过注意,tcp_tw_recycle在NAT环境下有严重问题,Linux 4.12之后已经移除了这个参数。
4.3 端口复用与SO_REUSEADDR
说到端口被占用,就不得不提SO_REUSEADDR。这个Socket选项是干嘛的?说白了,就是允许你重用处于TIME_WAIT状态的端口。
我举个例子:你写了一个HTTP服务,监听8080端口。服务崩溃了,你立刻重启。如果没有SO_REUSEADDR,bind()会失败,因为8080端口还在TIME_WAIT状态。加上这个选项,就能立即绑定成功。
使用方式:在bind()之前设置
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
但要注意,SO_REUSEADDR不是万能的。它只允许绑定到当前处于TIME_WAIT的端口,而且要求新绑定的地址和原地址完全一致(包括IP和端口)。如果你要绑定一个已经被其他进程占用的端口,那还是不行。
另外,TCP有一个更严格的选项叫SO_REUSEPORT(Linux 3.9+),它允许多个进程绑定到同一个IP和端口,内核会自动做负载均衡。这个在实现多进程服务器时很有用。
4.4 四次挥手状态转换图
下面这张图展示了四次挥手过程中客户端和服务端的状态变化,我建议你仔细看看TIME_WAIT的位置:
4.5 常见问题与避坑指南
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 端口被占用(Address already in use) | 端口处于TIME_WAIT状态 | 设置SO_REUSEADDR |
| 大量TIME_WAIT堆积 | 短连接频繁创建和关闭 | 使用长连接、调整tcp_tw_reuse |
| CLOSE_WAIT堆积 | 服务端没有正确关闭连接 | 检查代码,确保close()被调用 |
| 连接重置(Connection reset) | 一方发送RST包 | 检查对端是否异常关闭 |
我曾经遇到的坑:有一次排查线上服务,发现大量CLOSE_WAIT连接。查了半天,原来是业务代码里有个分支忘记调用close()了。连接一直挂着,文件描述符泄漏,最终导致服务无法接受新连接。所以写网络程序时,一定要确保每个连接都有对应的close(),最好用RAII或者try-finally来保证。
4.6 总结
四次挥手是TCP优雅关闭的保证,TIME_WAIT虽然烦人,但它是协议设计中的必要代价。理解了这些状态转换,你就能更好地处理Socket编程中的各种边界情况。我个人习惯在写服务器时,一定加上SO_REUSEADDR,避免重启时端口被占用。至于TIME_WAIT堆积,如果实在无法避免,可以考虑调整内核参数或者改用长连接。