四次挥手与连接释放
为什么需要四次挥手
TCP 连接是全双工的——双方可以同时发送和接收数据。关闭连接时,每个方向的关闭必须独立进行,因此需要四次报文交换。
生活例子:两人打电话,一方说"我说完了"(FIN),另一方说"我知道了"(ACK),但另一方可能还有话要说;等另一方也说"我说完了"(FIN),最初的一方回复"我知道了"(ACK),双方才都挂断。
四次挥手完整流程
第一步:FIN(主动关闭方)
客户端(假设主动关闭)发送:
FIN=1, Seq=u
客户端进入 FIN_WAIT_1 状态。FIN 消耗一个序列号。
第二步:ACK(被动关闭方)
服务端回复:
ACK=1, Ack=u+1
服务端进入 CLOSE_WAIT 状态。此时服务端仍可发送数据(半关闭状态)。
第三步:FIN(被动关闭方数据发完)
服务端数据发送完毕后:
FIN=1, Seq=w
服务端进入 LAST_ACK 状态。
第四步:ACK(主动关闭方)
客户端回复:
ACK=1, Ack=w+1
客户端进入 TIME_WAIT 状态;服务端收到后进入 CLOSED。
TIME_WAIT 状态:为什么等 2MSL
TIME_WAIT 状态持续 2MSL(Maximum Segment Lifetime,通常 2 分钟,故 TIME_WAIT 约 4 分钟)。设计目的:
- 确保最后一个 ACK 被服务端收到:如果 ACK 丢失,服务端会重发 FIN,客户端需能响应
- 防止旧连接报文干扰新连接:等待旧报文从网络中彻底消失,避免序列号混淆
MSL 是什么:报文在网络中存活的最大时间,RFC 793 建议 2 分钟。Linux 默认 60 秒,故 TIME_WAIT 约 120 秒。
TIME_WAIT 过多的问题与调优
高并发服务器(如 Web 服务器)主动关闭大量连接后,会产生大量 TIME_WAIT,导致:
- 本地端口耗尽:同一 {目的IP:目的端口} 组合下,TIME_WAIT 连接占用本地端口
- 内存占用:每个 TIME_WAIT 连接占用少量内核内存
调优参数(谨慎使用)
# 允许复用 TIME_WAIT 连接(仅出站连接,安全)
sysctl net.ipv4.tcp_tw_reuse=1
# 缩短 TIME_WAIT 时间(Linux 2.6 后默认 60 秒)
sysctl net.ipv4.tcp_fin_timeout=30
# 注意:tcp_tw_recycle 在 NAT 环境下有风险,Linux 4.12+ 已移除
根本解决方案:
- HTTP Keep-Alive:复用 TCP 连接,减少新建连接
- 连接池:数据库/缓存客户端维护长连接
- 服务端避免主动关闭:让客户端主动关闭,服务端不进入 TIME_WAIT
同时关闭:CLOSING 状态
双方同时主动关闭的罕见场景:
双方同时发送 FIN,都进入 FIN_WAIT_1;收到对方 FIN 后发送 ACK 并进入 CLOSING;再收到对方 ACK 后进入 TIME_WAIT。
半关闭:shutdown 函数
Socket API 提供 shutdown() 实现半关闭:
shutdown(sockfd, SHUT_WR); // 关闭写方向,仍可读
shutdown(sockfd, SHUT_RD); // 关闭读方向,仍可写
shutdown(sockfd, SHUT_RDWR); // 双向关闭
shutdown(SHUT_WR) 发送 FIN,进入 FIN_WAIT_2 状态(只等对方发 FIN),而非完全关闭连接。
本篇小结
- 四次挥手原因:TCP 全双工,每个方向独立关闭
- 流程:FIN → ACK(被动方仍可发数据)→ FIN → ACK
- TIME_WAIT 持续 2MSL:确保 ACK 送达 + 防止旧报文干扰
- TIME_WAIT 过多:端口耗尽,用 Keep-Alive/连接池/服务端不主动关闭解决
- CLOSING:双方同时主动关闭的罕见状态
- 半关闭:shutdown(SHUT_WR) 只关闭写方向
动手实践
Wireshark 抓包观察四次挥手:
- 过滤
tcp.flags.fin==1找 FIN 报文 - 观察 TIME_WAIT 前的最后一个 ACK
- 过滤
查看本机 TIME_WAIT 连接数量:
ss -tan | grep TIME-WAIT | wc -l查看 TIME_WAIT 相关内核参数:
sysctl net.ipv4.tcp_tw_reuse sysctl net.ipv4.tcp_fin_timeout思考:为什么服务端收到 FIN 后不能立即回复 FIN+ACK,而是先回复 ACK,等数据发完再发 FIN?如果合并成三次挥手会有什么风险?