如果你写过带 socket 的服务端程序,十有八九撞上过这类报错:bind: Address already in use,或者 only one usage of each socket address。明明上一个进程已经退出了,端口却被系统占着不放。这时候你要是只背过“四次挥手是 FIN、ACK、FIN、ACK”这个口诀,大概率一脸懵——流程没错,但为什么端口不释放?为什么会有个叫 TIME_WAIT 的状态赖着不走?
这篇文章不打算再给你画一遍那四行箭头的示意图,而是把四次挥手当成一个真实的工程问题来拆。我会从“挥手到底在释放什么资源”讲起,然后是判断故障必须掌握的状态机,再重点聊 TIME_WAIT 这个几乎所有线上问题都会撞上的状态,最后用几个真实的排查案例收尾。内容面向写 C++、Java、Go、C# 以及玩嵌入式 TCP 通信的开发者,不管你是刚入门还是被线上问题折磨过几轮,这篇应该都能让你对四次挥手有个全新的、能直接用于排障的理解。
1. 教科书没讲的本质:四次挥手拆的是什么
先说一个很多人理解错的地方:TCP 连接是一个全双工管道,不是一根只能单向走水的管子。
你想象一条双向对讲通道,A 端和 B 端各有一根独立的传声筒。A 能对 B 喊话,B 也能对 A 喊话,这两个方向的喊话是互不干扰的。TCP 的四次挥手,本质上是把这两个方向的传声筒分别拆掉,而不是一次性把整条管道砸了。
所以第一次挥手,主动关闭方发出 FIN,意思是“我这个方向的话说完了,我不再发送数据了”。但注意,这不代表我不听了,我仍然可以接收对方发来的数据。对方收到 FIN 后回复 ACK,意思是“收到,我知道你说完了”。这就是前两次挥手。
但此时数据通路并没有完全关闭。被动关闭方可能还有数据要发给主动方,所以它可以继续发。等它把剩下的数据都发完了,才发出自己的 FIN,意思是“我这个方向也说完了”。主动方再回一个 ACK,两个方向才彻底断掉。
这就是为什么挥手需要四次而不是三次。握手只需要三次,是因为服务器的 SYN 和 ACK 可以在同一个报文里发出去(收到客户端 SYN 后,我可以一边确认一边请求同步)。但挥手不行,被动关闭方收到 FIN 后,它不能保证自己已经没有数据要发了,所以 ACK 先回,FIN 得等应用层的数据都写完才能发。
这个区别在排查问题的时候特别关键。我见过有人把四次挥手理解成“双方同时说再见”,然后在写代码时主动方 close 之后立刻去读对端数据,结果读出来一个空包或者直接 connection reset,其实就是没搞懂半关闭(half-close)这个状态。
1.1 FIN 与 ACK 为什么不能像握手那样合并
顺着上面说,被动关闭方收到 FIN 后,能不能立刻把 FIN 和 ACK 一起回出去?从协议栈的角度看,可以,但它不会这么做。
原因是:收到 FIN 只是说明对方不发数据了,并不代表接收缓冲区里已经空了,也不代表本端应用层的发送队列已经清空。如果此时立刻回 FIN,等于强行截断自己还没发完的数据,这会造成数据丢失。协议栈为了可靠,必须等待应用层主动调用 close 或 shutdown 之后,才会把 FIN 发出去。
所以四次挥手的两组报文之间,隔着一段不确定的时间。这个时间短则几微秒,长则数小时。如果被动关闭方的应用层一直不调用 close,那这个连接就会挂在 CLOSE_WAIT 状态上——这是后面要重点讲的泄漏现场。
1.2 挥手期间的数据还能不能发
很多人第一次看到半关闭这个概念会懵。我用一个实际场景说明:
你在写一个自定义协议的服务端,客户端传完数据后调了 shutdown(SHUT_WR),表示“我的请求发完了”。此时服务端仍然可以正常往客户端写数据——比如返回处理结果。客户端虽然不再发送,但依然能接收。等服务端把结果写完,再调 close,连接才彻底关闭。
这段过程里,四次的“挥手”并没有一次完成,而是分成了两段:客户端先发出 FIN(第一、二次挥手完成),服务端随后发 FIN(第三、四次挥手完成)。中间夹着一次完整的数据发送。
这个模式在 HTTP/1.1 的 keep-alive 场景里很常见。服务器发完响应后,通过关闭连接来告诉客户端“响应结束了”,跟 Content-Length 起到类似的作用。不理解半关闭,就很难理解为什么连接关闭的时候还能收到数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 挥手状态机:线上排查的第一把钥匙
如果你查过 TCP 连接问题,一定见过 netstat 或 ss 输出里的各种状态,比如 FIN_WAIT_2、CLOSE_WAIT、TIME_WAIT。每个状态都不是随便起的名字,它们共同描述了挥手过程这台“状态机”的每一个停留点。想要排查连接异常,第一步不是去看代码,而是先确定连接卡在哪个状态——这直接决定了排查方向。
四次挥手过程中涉及的状态,我用一张表整理出来:
| 状态 | 出现在哪一端 | 含义 | 正常停留时间 |
|---|---|---|---|
| FIN_WAIT_1 | 主动关闭方 | 已发出 FIN,等待对方的 ACK | 极短,毫秒级 |
| FIN_WAIT_2 | 主动关闭方 | 已收到对方 ACK,等待对方 FIN | 不定,取决于被动方何时 close |
| CLOSE_WAIT | 被动关闭方 | 已收到 FIN,等待本地应用调用 close | 不定,常见泄漏点 |
| LAST_ACK | 被动关闭方 | 已发出 FIN,等待对方最后一个 ACK | 极短,毫秒级 |
| TIME_WAIT | 主动关闭方 | 已收到 FIN 并回复 ACK,等待 2MSL | 通常 60 秒左右 |
| CLOSED | 双方 | 连接彻底关闭 | - |
排查问题最快的方法,就是先回答“这个连接现在停在哪一个状态”。比如:
- 如果大量连接停在 CLOSE_WAIT,说明被动关闭方应用层没有释放 socket。
- 如果大量连接停在 FIN_WAIT_2,说明对端收到了 FIN 但一直没有发起自己的 FIN,通常是对端进程活着但没关 socket。
- 如果大量连接停在 TIME_WAIT,说明这端频繁主动关闭连接,属于高并发短连接的正常现象,但也有优化空间。
2.1 主动关闭方的状态流转
主动关闭方调用 close 后,内核立刻发出 FIN,状态从 ESTABLISHED 进入 FIN_WAIT_1。如果对方的 ACK 先回来,就进入 FIN_WAIT_2;如果对方的 FIN 和 ACK 一起回来(这种情况对端同时调用了 close),就直接进入 TIME_WAIT。
FIN_WAIT_2 是个特别容易让新手慌的状态。表面上看连接好像“挂着”了,但如果它停留在 FIN_WAIT_2,说明对端还能收数据,只是它自己还没发 FIN 而已。比如对端是半关闭状态,还在等你发数据,你这边就可能在 FIN_WAIT_2 等很久。
实际上,Linux 对 FIN_WAIT_2 有一个超时机制,默认 60 秒。超过之后如果还没收到对端 FIN,就会直接关闭连接,防止资源被无限占用。
2.2 被动关闭方的状态流转
被动关闭方收到 FIN 后进入 CLOSE_WAIT,这个状态的名字很直白:等待关闭。等待的是谁?是等待本地应用层的 close 调用。内核已经告诉你了“对端不发了”,但你的程序有没有把 socket 关掉,内核管不着,只能等。
一旦应用层调用 close,内核发出 FIN,状态进入 LAST_ACK,然后等待主动关闭方返回最后的 ACK。收到后进入 CLOSED,挥手过程结束。
LAST_ACK 状态如果堆积,说明主动关闭方回复的 ACK 丢了或者主动关闭方已经消失(比如网络中断),被动方会一直重发 FIN,直到超时。
理解了状态机,再去抓 netstat 输出的时候,你基本就能一眼看出问题在哪一端了。
3. TIME_WAIT 不是垃圾:2MSL 为什么要等
TIME_WAIT 大概是四次挥手里被误解最深、被“优化”最多的一个状态。先说结论:TIME_WAIT 是 TCP 可靠性的基石,不是系统垃圾。它存在的理由有两个,每个都直接关系到数据不出错。
第一个理由,保证最后一个 ACK 能到达对端。主动关闭方发出最后一个 ACK 后,这个 ACK 可能丢失。如果丢了,被动关闭方会认为自己发出的 FIN 对方没收到,于是重发 FIN。如果主动关闭方直接进入 CLOSED,那这个重发的 FIN 就没人回应,被动方会一直重试直到超时,导致连接迟迟无法释放。TIME_WAIT 让主动关闭方在 2MSL 时间内继续“监听”可能重传的 FIN,并能重新回复 ACK。
第二个理由,确保旧的报文段在网络中消亡,不会干扰新连接。如果主动关闭方在发出 ACK 后立刻用同一个端口发起新连接,网络里滞留的旧连接报文可能被新连接接收,造成数据错乱。2MSL 时间足够让一个报文段在网络中往返两次,从而确保所有旧报文都消失。
MSL 是 Maximum Segment Lifetime,报文的极限生存时间。不同系统取值不同,常见的是 30 秒到 2 分钟。一般来说,TIME_WAIT 的状态持续时间是 2MSL,大致在 60 秒到 4 分钟之间。Linux 里 ip netns 对应的内核参数 net.ipv4.tcp_fin_timeout 默认是 60 秒,这就是 Linux 实际采用的 TIME_WAIT 时长。
3.1 大量 TIME_WAIT 到底是不是问题
很多线上偶发故障,一查 netstat 看到一排 TIME_WAIT 就把锅甩给它。这个习惯得改。TIME_WAIT 只是状态,不是故障本身。
你先想清楚一个问题:谁在主动关闭连接?
如果是客户端主动关闭,TIME_WAIT 落在客户端一侧,服务器的连接被正常回收,这基本没有问题。比如你用浏览器访问网站,每次连接结束浏览器那一侧可能有 TIME_WAIT,但服务器无感。
真正需要关注的是:服务器主动关闭短连接。比如一个高并发的服务,每处理完一个请求就主动 close,那么服务器上会出现大量 TIME_WAIT。每个 TIME_WAIT 连接会占用一个五元组(源 IP、源端口、目的 IP、目的端口),对服务器而言,瓶颈通常集中在源端口数量上。一台服务器的临时端口范围默认大概是 28000 多个,如果 TIME_WAIT 把端口占满,新连接就无法建立,表现为 connect 超时或失败。
但注意,现代 Linux 内核早就处理了大部分场景。net.ipv4.tcp_tw_reuse 允许内核在满足条件时重用处于 TIME_WAIT 状态的连接,这主要是给主动发起连接的一端用的,也就是客户端。服务端能不能靠这个参数解决 TIME_WAIT 过多?答案是不能,tcp_tw_reuse 只对出站连接生效。服务端真正能用的优化是打开 SO_REUSEADDR,让监听的 socket 能绑定到处于 TIME_WAIT 的端口上——这也正是前面提到的 bind: Address already in use 的标准解法。
3.2 不要再开 tcp_tw_recycle 了
早些年网上流传一个“性能调优三板斧”:把 tcp_tw_reuse 和 tcp_tw_recycle 都打开。tcp_tw_recycle 曾经被很多人当成治疗 TIME_WAIT 堆积的神药,但它有一个严重副作用:它开启了时间戳的时间倒退检测,如果同一源 IP 下不同主机的 TCP 时间戳不是递增的,就会丢弃这些包。
这个坑在 NAT 环境下表现得非常典型。NAT 后面一堆设备共享同一个公网 IP 访问你的服务器,每个设备的时间戳并不保证单调递增。一旦开了 tcp_tw_recycle,服务器可能把 NAT 内后续设备的 SYN 包丢弃,导致一部分用户完全连不上。
实际案例我也遇到过。某公司一台服务器开了 tcp_tw_recycle,结果有用户反馈时好时坏,断开重连就失败,重启手机能连上。后来把参数关掉,现象立刻消失。这个参数在内核 4.12 之后被标记为废弃,很多发行版直接移除了它。如果你在旧系统上看到它,建议直接关掉。
3.3 SO_LINGER 能“跳过” TIME_WAIT,但别乱用
有些极端场景确实需要立即释放端口,比如压测脚本频繁建立短连接,测试端口耗尽。这时可以通过设置 SO_LINGER,让 close 不再走正常的四次挥手,而是直接发 RST 强制中止连接,从而绕过 TIME_WAIT。
但这相当于对 TCP 协议耍流氓。RST 意味着对端会收到 connection reset,而不是正常的连接关闭。数据可能丢失,对端应用层会报错。生产环境千万别这么干,除非你能接受这些后果。
4. 挥手失败现场:CLOSE_WAIT 泄漏与端口占用实战复盘
说了这么多原理,最后落到实际排障。我把自己踩过的几个坑按重现频率排个序,从最高频的往下说。
4.1 海量 CLOSE_WAIT:你的程序忘了 close
CLOSE_WAIT 泄漏是服务端最常见的问题,没有之一。一次线上故障,服务端连接数飙升,ss -tan state close-wait 一看,几万个连接挂在 CLOSE_WAIT 上。这意味着:对端已经发了 FIN,你的内核也回复了 ACK,但你的应用层一直没有调用 close。
排查思路通常分两步。
第一步,确认是不是代码路径漏了释放。看所有 socket 的读写分支,是否都在 finally 或 defer 里调用了 close。很多泄漏就藏在异常分支——比如读超时直接 return,但没有调用 close。
第二步,如果代码遍历过没发现问题,就要小心线程阻塞。CLOSE_WAIT 的本质是你的进程根本没执行到 close。常见原因是读循环阻塞在一个不会超时的 read 或者 recv 上,应用层以为连接还活着,实际上对端早就发了 FIN,但你要么在处理别的任务,要么被阻塞的 I/O 卡住,没有机会读取这个 FIN。
一个很隐蔽的案例:Java 服务用阻塞 IO,读线程卡在 inputStream.read() 上。对端关闭连接后,这个 read 其实会返回 -1,但如果代码没有处理 -1 的返回值,或者某个中间层把异常吞了,连接就永远停在 CLOSE_WAIT。排查时用 jstack 抓线程栈,看是否有线程卡在 socket 读取上,比一行行啃代码更高效。
4.2 bind: Address already in use 的两种解法
这个报错应该是 TCP 开发者的老朋友了。它的本质是:你有一个 socket 绑定到了某个端口,然后关闭了,但端口还处于 TIME_WAIT 状态。此时如果你尝试用默认方式重新绑定同样的端口,内核会拒绝。
解决方法是给监听 socket 设置 SO_REUSEADDR。在 C/Go/Python/Java 里都有对应的 API,设置之后,即使端口上还有 TIME_WAIT 状态,新监听 socket 也能绑定成功。
这里要区分一个细节:服务器端和客户端对这个选项的语义不一样。
服务器端,SO_REUSEADDR 允许绑定处于 TIME_WAIT 的端口,这是完全安全的,因为 TIME_WAIT 状态的连接不会再接受新数据,只是占着端口号用于防止旧报文串扰。客户端,SO_REUSEADDR 常用于允许两个 socket 绑定同一个本地端口,但一般不建议,除非你有特殊需求。
在 Linux 上,如果你设置了 SO_REUSEADDR 仍然 bind 失败,可以查一下 ss -tan 看这个端口到底是 TIME_WAIT、ESTABLISHED 还是 LISTEN 状态。如果是 ESTABLISHED,说明真的还有活跃连接,不是 SO_REUSEADDR 能解决的问题。
4.3 一次由“对端崩溃”引发的 RST 排查
正常情况下四次挥手靠 FIN 完成,但如果对端进程崩溃、系统断电、或者 socket 被异常强制关闭,TCP 栈会直接发送 RST 报文。收到 RST 的一端,表现为 read 或 recv 返回错误,常见的是 Connection reset by peer。
这个现象,我在排查微服务调用超时的时候遇到过。一个服务 A 调用服务 B,B 处理逻辑抛异常后没有正确关闭 socket,而是进程被信号杀死,内核发 RST。A 端收到 RST,连接直接断开,但 A 端的连接池里还缓存着这个失效连接。下次复用时就报 connection reset。
这类问题的根治,一是要保证服务端异常路径也能 close socket,二是客户端要能正确处理一次性的 RST,把它当作“这个连接不能用了”,从连接池里剔除并重试。
4.4 重启后端口仍被占用:docker 和端口映射的坑
如果你用 Docker,可能会碰到一个很经典的现象:容器停了,端口还是被占用,重启时报 ports are not available: exposing port TCP 0.0.0.0:xxxx。
这不是 Docker 本身的问题,而是宿主机的端口被处于 TIME_WAIT 状态的连接占用了。Docker 的端口映射本质是宿主机上的 iptables 规则和 docker-proxy 进程监听端口。如果 docker-proxy 退出后,端口仍处于 TIME_WAIT,再次映射就可能失败。
解决思路:等 TIME_WAIT 过期(通常 60 秒内);或者设置 Docker daemon 的 userland-proxy 参数;再或者宿主机上调整 net.ipv4.ip_local_port_range 避免端口冲突。但最实际的建议是,重启容器前先 ss -tan | grep <端口> 看一下状态,确认是否有连接还挂在 TIME_WAIT 上,再决定是等还是处理。
5. 从 netstat 到 tcpdump:验证四次挥手的标准动作
理论讲得再多,不如亲手抓一次包来得实在。我建议每个做网络编程的人都自己抓一次完整的四次挥手,让纸上谈兵变成亲眼所见。
抓包工具首选 tcpdump,命令很简单:
bash复制sudo tcpdump -i eth0 -nn 'tcp port 8080' -w handshake.pcap
启动抓包后,用一个客户端去连接服务器,然后在客户端或者服务器端执行关闭操作。比如你写一个简单的 echo server,客户端连接后立即退出,就能抓到完整的四次挥手过程。
抓完用 Wireshark 打开,你会看到这样一个序列:
[FIN, ACK]:主动关闭方发出的 FIN,通常带 ACK 标志是因为它可能还在确认之前收到的数据[ACK]:被动关闭方的确认[FIN, ACK]:被动关闭方的 FIN[ACK]:主动关闭方的最终确认
注意看,第一次挥手通常是 FIN, ACK,不是单独的 FIN。因为 TCP 的 ACK 是累积的,主动关闭方在发送 FIN 的时候,如果还有需要确认的数据,就会把 ACK 一起带上。所以网上说的“四次挥手是 FIN、ACK、FIN、ACK”这个口诀,前两步更准确的说法是:第一次是 FIN+ACK,第二次是 ACK。
如果你抓包发现只有三次报文,比如 FIN+ACK、ACK、FIN+ACK,没有最后一个 ACK,那说明主动关闭方可能丢弃了最后的 ACK,或者被动方的 FIN 由于某种原因没能触发最后一个 ACK——这种连接通常最终会在超时后被系统清理。
5.1 用 ss 快速判断当前连接处于哪个状态
不抓包的时候,ss 是排查 TCP 状态最顺手的工具。几个常用的查看方式:
bash复制# 查看所有 TCP 连接的状态统计
ss -tan
# 只看处于 TIME_WAIT 状态的连接
ss -tan state time-wait
# 只看 CLOSE_WAIT 状态的连接
ss -tan state close-wait
# 统计各状态数量
ss -tan | awk '{print $1}' | sort | uniq -c
排查顺序通常是:先统计各状态数量,定位异常状态;再筛选出该状态的连接详情,看对端 IP 和端口;然后对照自己的服务日志,判断是哪个业务逻辑造成了这种状态堆积。
5.2 一个小实验:亲手制造一次半关闭
最后分享一个可以在自己电脑上做的小实验。用 Python 启动一个服务器,收到客户端的数据后不立即关闭,而是先 shutdown(SHUT_WR),观察客户端的状态变化。
python复制import socket
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('0.0.0.0', 9000))
srv.listen(5)
conn, addr = srv.accept()
data = conn.recv(1024)
print('received:', data)
# 半关闭:告诉客户端"我不会再发了",但仍然可以接收
conn.shutdown(socket.SHUT_WR)
conn.close()
运行后,用任意 TCP 客户端连上来发一条消息,然后观察服务器端和客户端的连接状态。你会发现服务器端经历了一次完整的“发送 FIN 但等待 ACK”的过程,这比任何文字描述都直观。
这个实验还能帮你理解一个实际场景:为什么有些协议要在关闭连接前先 shutdown 发送方向?因为优雅地关闭一个方向,是对端能明确感知“数据发完了”的唯一可靠信号,比任何应用层的心跳都要可靠。
四次挥手看似简单,但它是 TCP 可靠传输的收尾保障。理解了 FIN 和 ACK 为什么不能合并,理解了 TIME_WAIT 为什么必须存在,理解了 CLOSE_WAIT 泄漏是怎么发生的,你在排查线上连接问题时就有了一个清晰的坐标系。记住:状态机是要背的,但更要背的是每个状态背后对应的“谁在等谁”。下次再遇到连接问题,先看状态,再查代码,最后动手抓包,这个顺序能帮你省下大量时间。
