3、TCP连接释放(四次挥手):从挥手到告别
TCP 的连接建立我们聊完了,接下来要面对一个更微妙的话题——连接释放,也就是大家常说的「四次挥手」。
说实话,我在刚入行那会儿,总觉得挥手比握手简单。不就是两边都说一声「我走了」吗?后来线上出了几次问题,我才发现,挥手才是 TCP 状态机里最容易踩坑的地方。尤其是那个 TIME_WAIT,简直是又爱又恨。
3.1 四次挥手的过程详解
先看一个标准的挥手流程。假设客户端主动发起关闭,服务端被动响应:
客户端(主动关闭方) 服务端(被动关闭方)
ESTABLISHED ESTABLISHED
| |
|-------- FIN (seq=u) ----------->| FIN_WAIT_1
| | CLOSE_WAIT
|<------- ACK (ack=u+1) ----------| FIN_WAIT_2
| |
|<------ FIN (seq=v) + ACK -------| LAST_ACK
| |
|-------- ACK (ack=v+1) --------->| CLOSED
TIME_WAIT
|
CLOSED
四次挥手,说白了就是四次报文交换。我习惯把它拆成两个阶段来理解:
第一次挥手:客户端发送 FIN
客户端说:「我的数据发完了,我要关闭连接了。」此时客户端进入 FIN_WAIT_1 状态。注意,这个 FIN 报文里会携带一个序列号,假设是 seq=u。
第二次挥手:服务端回复 ACK
服务端收到 FIN 后,回复一个 ACK,确认号是 ack=u+1。服务端进入 CLOSE_WAIT 状态。客户端收到这个 ACK 后,进入 FIN_WAIT_2 状态。
第三次挥手:服务端发送 FIN + ACK
等服务端把自己的数据也发完了,它才会发送 FIN 报文。这个 FIN 报文通常和 ACK 合并在一起发送,所以叫 FIN+ACK。服务端进入 LAST_ACK 状态。
第四次挥手:客户端回复 ACK
客户端收到 FIN 后,回复一个 ACK,确认号是 ack=v+1。然后客户端进入 TIME_WAIT 状态。服务端收到这个 ACK 后,直接进入 CLOSED 状态。
3.2 状态变迁详解
挥手过程中的状态变迁,我建议你画一张图贴在工位上。我自己当年就是这么干的,面试前看一眼,心里踏实。
这张图我建议你多看几遍。每个状态之间的转换,背后都对应着一次报文的收发。我当年排查一个连接泄漏的问题,就是靠这张图一步步定位到服务端卡在了 CLOSE_WAIT 状态。
3.3 为什么需要 TIME_WAIT 状态?
这个问题,面试官特别喜欢问。说白了,TIME_WAIT 存在的理由有两个:
理由一:保证最后一个 ACK 能被对方收到
客户端发送完最后一个 ACK 后,这个 ACK 可能在网络中丢失。如果服务端没收到 ACK,它会重发 FIN。如果客户端直接进入 CLOSED 状态,就收不到这个重发的 FIN,也就无法再回复 ACK。结果就是服务端永远收不到确认,一直卡在 LAST_ACK 状态。
理由二:防止旧连接的报文干扰新连接
你想想看,如果客户端快速关闭一个连接,然后又立即用相同的 IP 和端口建立新连接。旧连接在网络中延迟的报文,可能会被新连接误认为是自己的数据。TIME_WAIT 的 2MSL 时间,足以让网络中所有属于旧连接的报文都消失。
我曾经遇到过一个案例:一个高并发的短连接服务,TIME_WAIT 堆积到几万个,导致端口被占满,新连接无法建立。后来我们调整了系统参数,启用了 tcp_tw_reuse 和 tcp_tw_recycle(注意,后者在 Linux 4.12 之后已经移除了),才缓解了这个问题。
tcp_tw_recycle,结果 NAT 环境下的客户端连接全部异常。因为 tcp_tw_recycle 依赖时间戳,而 NAT 后面的不同客户端时间戳可能不一致。后来我学乖了,非必要不开启 tcp_tw_recycle,优先考虑调整业务逻辑或使用长连接池。
3.4 挥手过程中的异常场景
实际项目中,挥手不会每次都这么顺利。我整理了几个常见的异常情况:
| 异常场景 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| CLOSE_WAIT 堆积 | 服务端大量连接处于 CLOSE_WAIT | 应用程序没有调用 close() | 检查代码,确保收到 FIN 后及时关闭 socket |
| TIME_WAIT 过多 | 端口被占满,新连接失败 | 短连接频繁创建和关闭 | 使用长连接池,或调整 tcp_tw_reuse |
| 半关闭状态 | 一方关闭,另一方还在发送数据 | 应用层协议设计不合理 | 使用 shutdown() 实现半关闭 |
| FIN 丢失 | 连接长时间卡在 FIN_WAIT_1 | 网络丢包 | TCP 超时重传机制会自动处理 |
3.5 关于半关闭(Half-Close)
TCP 支持半关闭,也就是一方可以关闭自己的发送通道,但保留接收通道。这在某些应用场景下很有用。
举个例子:客户端发送完请求后,调用 shutdown(SHUT_WR) 关闭写方向,但还能继续读取服务端的响应。服务端读取到 EOF 后,知道客户端不会再发数据了,但自己还可以继续发送。
// 半关闭示例(伪代码)
// 客户端
send(sock, request, len, 0);
shutdown(sock, SHUT_WR); // 关闭写方向,发送 FIN
recv(sock, response, sizeof(response), 0); // 还能读
// 服务端
recv(sock, buffer, sizeof(buffer), 0); // 读到 EOF
// 处理请求...
send(sock, response, len, 0);
close(sock); // 关闭连接
3.6 小结
四次挥手,说白了就是 TCP 的「好聚好散」。每个状态、每次报文交换,背后都有它的道理。尤其是 TIME_WAIT,虽然让人头疼,但它是 TCP 可靠性的重要保障。
嗯,这一章的内容就到这里。记住那张状态变迁图,下次遇到连接问题,你就能快速定位到问题出在哪个阶段了。