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 后,只是告诉客户端「我知道了」,但服务端可能还有数据没发完。所以服务端不会立刻发 FIN,而是先发 ACK。这就是为什么挥手需要四次,而不是三次。

第三次挥手:服务端发送 FIN + ACK

等服务端把自己的数据也发完了,它才会发送 FIN 报文。这个 FIN 报文通常和 ACK 合并在一起发送,所以叫 FIN+ACK。服务端进入 LAST_ACK 状态。

第四次挥手:客户端回复 ACK

客户端收到 FIN 后,回复一个 ACK,确认号是 ack=v+1。然后客户端进入 TIME_WAIT 状态。服务端收到这个 ACK 后,直接进入 CLOSED 状态。

注意:客户端在发送完最后一个 ACK 后,并不会立刻进入 CLOSED 状态,而是要在 TIME_WAIT 状态等待 2MSL(Maximum Segment Lifetime)时间。这是 TCP 协议里最容易让人困惑的地方,我们后面会详细讲。

3.2 状态变迁详解

挥手过程中的状态变迁,我建议你画一张图贴在工位上。我自己当年就是这么干的,面试前看一眼,心里踏实。

TCP四次挥手状态变迁图 TCP 四次挥手状态变迁 客户端(主动关闭方) ESTABLISHED FIN_WAIT_1 FIN_WAIT_2 TIME_WAIT CLOSED 服务端(被动关闭方) ESTABLISHED CLOSE_WAIT LAST_ACK CLOSED ① FIN ② ACK ③ FIN+ACK ④ ACK 注意:客户端在 TIME_WAIT 状态等待 2MSL 后才进入 CLOSED,服务端收到最后一个 ACK 后直接进入 CLOSED

这张图我建议你多看几遍。每个状态之间的转换,背后都对应着一次报文的收发。我当年排查一个连接泄漏的问题,就是靠这张图一步步定位到服务端卡在了 CLOSE_WAIT 状态。

3.3 为什么需要 TIME_WAIT 状态?

这个问题,面试官特别喜欢问。说白了,TIME_WAIT 存在的理由有两个:

理由一:保证最后一个 ACK 能被对方收到

客户端发送完最后一个 ACK 后,这个 ACK 可能在网络中丢失。如果服务端没收到 ACK,它会重发 FIN。如果客户端直接进入 CLOSED 状态,就收不到这个重发的 FIN,也就无法再回复 ACK。结果就是服务端永远收不到确认,一直卡在 LAST_ACK 状态。

核心逻辑:TIME_WAIT 让客户端在发送完最后一个 ACK 后,再等待一段时间(2MSL),确保如果服务端重发 FIN,客户端还能处理并重新回复 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);  // 关闭连接
我的建议:半关闭虽然灵活,但会增加代码复杂度。除非你的应用层协议明确需要(比如 HTTP 的 keep-alive),否则直接用 close() 更省心。我在做网关开发时用过一次半关闭,后来维护的同学看了半天才看懂,从那以后我就尽量不用了。

3.6 小结

四次挥手,说白了就是 TCP 的「好聚好散」。每个状态、每次报文交换,背后都有它的道理。尤其是 TIME_WAIT,虽然让人头疼,但它是 TCP 可靠性的重要保障。

嗯,这一章的内容就到这里。记住那张状态变迁图,下次遇到连接问题,你就能快速定位到问题出在哪个阶段了。