4、TCP四次挥手与Socket:四次挥手过程、TIME_WAIT状态、端口复用与SO_REUSEADDR

聊完三次握手,咱们来看看挥手。说实话,握手是建立连接,挥手是断开连接,但挥手比握手复杂得多。我刚开始写网络程序时,总觉得断开连接不就是close()一下吗?结果线上服务端口被占用了,半天起不来——嗯,这就是没搞懂四次挥手和TIME_WAIT的代价。

4.1 四次挥手过程:谁先走?

TCP是全双工的,意味着双方都能独立地发送和接收数据。所以断开连接时,每一方都要单独关闭自己的发送通道。这就导致了四次交互。

假设客户端主动发起关闭,流程是这样的:

  1. 客户端发送FIN:客户端调用close(),发送一个FIN包,告诉服务端“我没数据要发了”。此时客户端进入FIN_WAIT_1状态。
  2. 服务端回复ACK:服务端收到FIN,回复ACK,表示“我知道了”。服务端进入CLOSE_WAIT状态,客户端收到ACK后进入FIN_WAIT_2状态。
  3. 服务端发送FIN:服务端处理完剩余数据后,也调用close(),发送FIN包,告诉客户端“我也没数据了”。服务端进入LAST_ACK状态。
  4. 客户端回复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的位置:

TCP四次挥手状态转换图 客户端(主动关闭) ESTABLISHED FIN_WAIT_1 FIN_WAIT_2 TIME_WAIT CLOSED 服务端(被动关闭) ESTABLISHED CLOSE_WAIT LAST_ACK CLOSED ① FIN ② ACK ③ FIN ④ ACK TIME_WAIT持续2MSL(Linux默认60秒),确保最后一个ACK可达,防止旧数据干扰新连接

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堆积,如果实在无法避免,可以考虑调整内核参数或者改用长连接。