TCP四次挥手详解:从状态机到异常排查与编程实践

📅 2026/8/5 10:00:36
TCP四次挥手详解:从状态机到异常排查与编程实践
1. 从一次“优雅”的断线说起为什么需要四次挥手做网络开发或者运维的朋友对“三次握手”建立连接这个概念应该都不陌生毕竟这是TCP可靠传输的基石。但说到连接关闭时的“四次挥手”很多人可能就觉得有点“啰嗦”了既然三次就能建立为什么断开要四次是不是设计得不够优雅恰恰相反四次挥手是TCP协议设计精妙、考虑周全的体现。它要解决的是一个比建立连接更复杂的问题如何在双方都还有数据要发送的情况下安全、有序、无丢失地终止一个全双工的通信通道。你可以把TCP连接想象成两个人打电话。三次握手是“喂听得到吗”“听得到你呢”“我也听得到。”—— 连接建立可以开始聊天了。这是一个从无到有的过程目标明确。而四次挥手则是通话结束时的场景。假设A想挂电话了但B可能还有最后一句话没说完。这个过程必须是A说“我要说的都说完了我准备挂电话了。”FINB听到后先确认“好的我知道你说完了。”ACK但此时B可能自己还有话要说。B把剩下的话说完然后说“我也说完了我也准备挂了。”FINA最后确认“好的收到那我们都挂了吧。”ACK这个多出来的一次交互就是为了处理“一方已无话可说但另一方还有数据要发送”这种常见情况。如果强行用三次挥手即A发FINB回FINACKA回ACK那就等于B在收到FIN后必须立刻停止发送自己的数据这在实际的网络通信中比如服务器在发送最后一个数据包是行不通的会导致数据丢失。所以四次挥手的核心目的是允许连接处于“半关闭”状态。当A发送FIN后进入FIN_WAIT_1状态这表示A到B的数据流关闭了但B到A的数据流还可以继续直到B也发送自己的FIN。这种设计确保了任何一方在主动关闭时都不会影响对方尚未发送完毕的数据从而实现了真正意义上的可靠连接释放。理解这一点是理解整个挥手过程状态变迁和后续所有“坑”的基础。2. 状态变迁图一张地图看懂挥手全流程光说流程有点抽象我们直接上TCP标准的状态机图。这不是枯燥的理论而是你以后排查网络问题、分析netstat命令输出、理解连接池行为的“地图”。我把四次挥手涉及的关键状态和变迁结合Linux下的实际表现给你梳理一遍。主动关闭方Active Closer通常是客户端或主动调用close()的一方其典型状态流如下ESTABLISHED - FIN_WAIT_1 应用层调用close()系统发出第一个FIN报文。此时它不能再发送数据但还能接收数据。FIN_WAIT_1 - FIN_WAIT_2 收到对端对第一个FIN的确认ACK。此时它只能接收数据不能发送。连接进入“半关闭”。FIN_WAIT_2 - TIME_WAIT 收到对端发来的FIN报文。此时它知道对端也发完了数据。它立刻回复一个ACK并进入TIME_WAIT状态。TIME_WAIT - CLOSED 等待2MSLMaximum Segment Lifetime报文最大生存时间Linux下通常为60秒后状态变为CLOSED连接资源完全释放。被动关闭方Passive Closer通常是服务器或先收到FIN的一方其状态流如下ESTABLISHED - CLOSE_WAIT 收到对端的FIN报文并回复ACK。此时应用层已经感知到对端关闭例如read()返回0。但本端可能还有数据要发。CLOSE_WAIT - LAST_ACK 当本端应用层也调用close()时系统发出自己的FIN报文。此时进入LAST_ACK状态等待对方最后一个ACK。LAST_ACK - CLOSED 收到对端的最终ACK连接关闭。这里有几个关键状态需要特别关注CLOSE_WAIT 这是一个“被动”状态意味着你的应用已经知道对方不想说话了但你的代码可能还没有调用close()来关闭自己这一侧。如果服务器上出现大量CLOSE_WAIT连接几乎可以断定是应用程序的Bug——没有正确关闭socket。这会导致文件描述符泄漏是线上常见故障。TIME_WAIT 这是“主动”关闭方最后停留的状态。它的存在有两个至关重要的目的可靠地终止TCP连接 最后发出的ACK可能会丢失导致被动方重传FIN。TIME_WAIT状态持续2MSL足以让这个重传的FIN到达并再次回应ACK确保连接能彻底关闭。让旧连接的“迷途报文”在网络中消逝 防止具有相同四元组源IP、源端口、目的IP、目的端口的新连接收到属于旧连接的延迟报文造成数据混乱。注意 很多运维人员讨厌TIME_WAIT因为它占用了端口和内存。但在高并发短连接场景下如压测、爬虫主动关闭方会产生大量TIME_WAIT可能导致端口耗尽。解决方案通常不是简单地调小net.ipv4.tcp_tw_recycle该参数在现代Linux内核中已废弃且不安全而是考虑使用SO_REUSEADDR套接字选项、连接池、或者将关闭连接的主动权交给客户端让服务端避免成为主动关闭方从而避免TIME_WAIT。3. 抓包实战用Wireshark透视挥手细节理论说再多不如抓个包看看。我们用一个最简单的Python客户端-服务器例子在本地127.0.0.1上建立连接然后由客户端主动关闭用Wireshark捕获lo环回接口的流量。服务器端代码 (server.py):import socket s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 允许地址重用方便测试 s.bind((127.0.0.1, 12345)) s.listen(1) print(Server listening...) conn, addr s.accept() print(fConnected by {addr}) data conn.recv(1024) # 等待接收客户端数据 print(fReceived: {data.decode()}) conn.send(bHello from server) # 发送回应 # 服务器不主动close等待客户端FIN input(Press Enter after client closes to exit server...) conn.close() s.close()客户端代码 (client.py):import socket import time s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 12345)) s.send(bHello from client) data s.recv(1024) print(fReceived: {data.decode()}) time.sleep(2) # 等待一下方便在Wireshark中观察 s.close() # 客户端主动关闭连接 print(Client closed.)运行服务器再运行客户端。在Wireshark中过滤tcp.port 12345你会看到类似下面的序列序号为假设Frame 1-3: 三次握手建立连接。Frame 4: [PSH, ACK] 客户端发送 “Hello from client”。Frame 5: [ACK] 服务器确认收到数据。Frame 6: [PSH, ACK] 服务器发送 “Hello from server”。Frame 7: [ACK] 客户端确认收到数据。Frame 8: [FIN, ACK] 客户端发送FIN主动关闭第一步。注意这个FIN报文通常也会携带一个ACK确认之前收到的数据。Wireshark会标记为[FIN, ACK]。Frame 9: [ACK] 服务器回复ACK确认客户端的FIN第二步。此时服务器进入CLOSE_WAIT客户端进入FIN_WAIT_2。Frame 10: [FIN, ACK] 服务器应用层close()被调用在我们这里是手动按回车后发送自己的FIN第三步。Frame 11: [ACK] 客户端回复最终ACK第四步。客户端进入TIME_WAIT服务器关闭。抓包分析要点序列号与确认号 这是理解TCP可靠性的关键。每次发送数据SEQ都会增加。ACK号表示“我期望收到的下一个字节的序号”即对之前所有数据的确认。在挥手的FIN报文中FIN本身占用一个序列号。所以对FIN的ACK其确认号是收到的FIN的序列号1。标志位 重点关注FIN和ACK。FIN表示发送方数据结束。几乎所有的FIN报文都会同时置位ACK因为它总在确认之前的数据。为什么是四次在抓包中看得最清楚。第二步和第三步是分开的两个报文因为服务器在收到FIN和发送自己的FIN之间有一个CLOSE_WAIT状态这给了应用程序处理剩余数据的时间。如果服务器没有数据要发理论上它可以合并第二个ACK和第三个FIN即发送[FIN, ACK]这就是“延迟确认”与FIN合并在某些情况下会变成“三次挥手”。但在协议设计上必须支持四次因为合并不是总能发生。4. 异常情况与疑难杂症排查理想情况下的四次挥手很清晰但网络世界充满意外。下面这些异常情况才是真正考验你对TCP理解深度的地方。4.1 对方不回复ACK或FIN怎么办连接卡住这是最常见的问题之一。假设主动关闭方发出第一个FIN后收不到ACK。主动方的行为 它会重传FIN。在Linux中重传次数和超时由net.ipv4.tcp_orphan_retries和指数退避算法控制。如果始终收不到ACK最终会放弃连接进入CLOSE状态。但在此期间socket资源会被占用。排查思路检查对端进程是否存活ps或systemctl status。检查中间网络设备防火墙、负载均衡是否丢弃了FIN或ACK报文。可以尝试在两端同时抓包对比报文是否对称。检查对端机器是否有大量SYN_RECV或CLOSE_WAIT状态连接这可能意味着对端处理能力不足或程序Bug。4.2 大量CLOSE_WAIT状态如前所述CLOSE_WAIT是被动关闭方收到FIN后等待应用层调用close()的状态。如果服务器出现成千上万的CLOSE_WAIT基本可以断定是代码问题。根本原因 应用层没有正确关闭Socket。例如在Java中没有在finally块中关闭InputStream/OutputStream和Socket在Go中没有检查err并关闭连接在HTTP服务器中没有完整读取请求体就返回导致底层连接无法干净关闭。排查与解决使用连接分析工具ss -tanop | grep CLOSE-WAIT可以查看处于CLOSE_WAIT状态的连接及其对应的进程PID。lsof -p PID可以查看该进程打开的所有文件描述符。代码审查 检查所有网络I/O代码路径确保在所有错误处理和正常退出路径上都有关闭连接的操作。设置超时 为Socket设置SO_LINGER选项或读/写超时防止连接因对端无响应而永远挂起。使用连接池 对于需要复用的连接确保连接归还到池子前状态是健康的对于已关闭的连接要从池中剔除。4.3 大量TIME_WAIT状态TIME_WAIT是主动关闭方的正常状态但过多会占用资源。影响 每个TIME_WAIT连接会占用一个本地四元组源IP:端口 目标IP:端口。在高并发短连接场景下快速重复使用相同端口可能失败导致“Address already in use”错误。Linux内核优化参数谨慎调整net.ipv4.tcp_tw_reuse 允许将处于TIME_WAIT的socket重新用于新的OUTBOUND连接即作为客户端。这相对安全因为TIME_WAIT的主要目的之一是防止旧连接的报文干扰新连接而作为发起方初始序列号是随机的降低了风险。可以设置为1。net.ipv4.tcp_tw_recycle强烈不建议启用。它曾用于快速回收TIME_WAIT连接但基于时间戳的机制在NAT环境下会导致严重问题如手机客户端连接失败且在现代内核中已废弃。net.ipv4.tcp_max_tw_buckets 限制系统全局TIME_WAIT连接的最大数量。超出后系统会直接销毁最早的TIME_WAIT连接。这是一个“兜底”参数治标不治本。更优的解决方案使用长连接 将短连接通信模式改为长连接如HTTP/1.1 Keep-Alive, gRPC, 自定义协议从根本上减少连接建立和关闭的次数。客户端负载均衡 如果是服务间调用让客户端调用方成为被动关闭方。这样TIME_WAIT就分散在大量的客户端机器上而不会集中在少数服务端。设置SO_LINGER 将SO_LINGER的l_onoff设为1l_linger设为0。这样调用close()时会发送RST报文而非FIN直接重置连接跳过TIME_WAIT。但这是不优雅的关闭会丢弃发送缓冲区中的数据且对端会收到连接重置错误仅用于对可靠性要求不高的场景或故障处理。4.4 连接重置RST在挥手过程中如果一方收到完全不该出现的报文如序列号不在窗口内或者一方想立即强行关闭连接如服务崩溃就会发送RST报文。场景 你在ESTABLISHED状态下向一个已经调用close()的对端写数据对端内核会回复RST。你的下一次read()或write()调用会返回错误如Connection reset by peer。与FIN的区别FIN是礼貌的告别“我说完了”。RST是粗暴的挂断“出错了别再联系我”。收到RST的连接会立即被销毁没有挥手过程。5. 编程实践如何写出健壮的连接关闭代码理解了协议最终要落到代码上。下面以Python为例展示几种常见的连接关闭模式及其陷阱。5.1 基础关闭模式# 客户端示例 import socket def simple_client(): s None try: s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((host, port)) # ... 发送和接收数据 ... s.shutdown(socket.SHUT_WR) # 发送FIN关闭写端 # 继续读取服务器可能发来的剩余数据 while True: data s.recv(1024) if not data: break # 处理数据... except Exception as e: print(fError: {e}) finally: if s: s.close() # 最终关闭socket如果之前shutdown了这里会发FIN如果还没发的话要点 先shutdown(SHUT_WR)通知对端“我写完了”然后继续recv()直到读到EOF空数据最后再close()。这是一种比较优雅的关闭方式确保收到了对端的所有数据。5.2 处理对端意外关闭def read_until_closed(sock): data_buffer [] try: while True: chunk sock.recv(4096) if not chunk: # 收到EOF (对方发送了FIN) print(Connection closed gracefully by peer.) break data_buffer.append(chunk) except ConnectionResetError: print(Connection was reset by peer (RST received).) except Exception as e: print(fOther error: {e}) finally: return b.join(data_buffer)要点recv()返回空字节串意味着收到了FIN正常关闭。捕获ConnectionResetError异常意味着收到了RST异常关闭。必须区分处理。5.3 设置超时与优雅关闭def robust_client(): s socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: s.settimeout(10.0) # 设置读写超时 s.connect((host, port)) # ... 通信 ... # 优雅关闭先关写再读最后关socket s.shutdown(socket.SHUT_WR) # 设置一个较短的超时来读取对端的剩余数据 s.settimeout(2.0) try: while True: data s.recv(1024) if not data: break except socket.timeout: print(Timeout while waiting for peers FIN, proceeding to close.) finally: s.close()要点 为socket设置超时防止在recv()或send()时永久阻塞。在优雅关闭的读阶段也可以设置一个合理的超时避免因为对端故障而长时间等待。5.4 服务端防止CLOSE_WAIT泄漏# 服务端工作线程/协程示例 def handle_connection(conn, addr): print(fHandling connection from {addr}) try: with conn: # 使用上下文管理器确保退出时conn.close()被调用 # 必须完整读取请求即使不关心内容 request b while True: chunk conn.recv(1024) if not chunk: break # 客户端关闭了连接 request chunk # 这里可以添加基于协议如HTTP头的解析判断请求是否完整 # if request_is_complete(request): break # 处理请求并生成响应 response process_request(request) conn.sendall(response) # sendall确保所有数据发出 # with语句结束conn.close()自动调用发送FIN except Exception as e: print(fError handling connection from {addr}: {e}) # 无论是否异常conn都会因为with语句而被关闭要点 使用with语句上下文管理器是防止资源泄漏的最佳实践。确保完整读取客户端请求直到recv()返回空这是许多HTTP服务器框架如Go的net/http自动做的事情。如果提前返回底层连接可能因为还有未读数据而无法进入正常的关闭流程虽然内核最终会清理但不够优雅。6. 高级话题TIME_WAIT、端口重用与负载均衡在实际的高并发服务部署中四次挥手带来的TIME_WAIT问题会与负载均衡器产生复杂的交互。场景 一个Nginx作为反向代理后接多个应用服务器。客户端请求通过Nginx转发。默认情况 Nginx与后端服务器建立短连接。请求处理完毕Nginx主动关闭连接成为主动关闭方产生一个TIME_WAIT连接在Nginx机器上目标端口是后端服务器端口。问题 如果QPS很高Nginx机器上会产生大量TIME_WAIT连接可能耗尽本地端口。解决方案开启上游长连接 在Nginx配置中使用upstream模块并设置keepalive指令。这会让Nginx与后端服务器维持一个连接池复用TCP连接 dramatically减少握手和挥手次数。upstream backend { server 10.0.1.2:8080; keepalive 32; # 保持最多32个空闲长连接 } server { location / { proxy_http_version 1.1; # 必须使用HTTP/1.1 proxy_set_header Connection ; proxy_pass http://backend; } }调整内核参数 在Nginx服务器上可以设置net.ipv4.tcp_tw_reuse 1允许出向连接复用TIME_WAIT套接字。使用SO_REUSEADDR 在服务端程序包括Nginx绑定端口时设置SO_REUSEADDR选项。这允许一个新的服务实例快速绑定到同一个端口即使该端口上还有处于TIME_WAIT状态的旧连接。这对于服务重启非常关键。# Python示例 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((0.0.0.0, port))一个常见的误区 很多人试图在后端服务器上解决TIME_WAIT过多的问题但往往搞错了方向。如果TIME_WAIT集中在Nginx或负载均衡器上那么优化应该主要在Nginx和负载均衡器这一侧使用长连接、调整参数而不是后端应用服务器。理解四次挥手不仅仅是记住四个报文。它是你理解TCP连接生命周期、诊断网络超时与重置、设计高并发服务、编写稳健网络代码的基石。下次当你看到CLOSE_WAIT或TIME_WAIT飙升时希望你能立刻想到这张状态变迁图并准确地找到问题的根源。