模块二 · 运输层
TCP 握手与挥手
你知道「三次握手、四次挥手」,但说不出为什么是 3 和 4,也分不清 TIME_WAIT 和 CLOSE_WAIT 哪个是故障。
本章要解决的问题:你知道「三次握手、四次挥手」,但说不出为什么是 3 和 4,
也分不清 TIME_WAIT 和 CLOSE_WAIT 哪个是故障。
本章的证据来源:captures/lab_tcp_states_output.txt(亲手建连再拆连)
和 captures/lab_timewait_output.txt(实测 TIME_WAIT 时长)。
先从一个例子理解概念
打电话要先接通,挂电话也要让双方知道。TCP建立和关闭的类比类似,但双方保存的是通信状态,并没有一根专门属于它的物理线。
双方共同维护的通信状态:连接
TCP连接是一组由两端维护的状态,包括对方是谁、字节编号到哪儿、哪些已收到。TCP提供有序字节流,但故障时连接仍会失败。
准备开始与结束:握手、关闭
互相交换并确认建立所需信息叫握手;停止某个方向发送叫关闭发送方向。一端不再发送,仍可能继续接收,这叫半关闭。
控制消息上的标记:SYN、ACK、FIN
SYN用于建立时同步序号;ACK标明确认字段有效;FIN表示发送端这个方向没有后续数据。它们是报文里的标志,不是三个程序。
用编号确认内容:seq与ack
seq(序号)表示本段在字节流中的编号;ack(确认号)表示下一个期待的序号。编号帮助识别重复与缺失,不是发送时间。
先认状态的中文意思
CLOSE_WAIT是收到了对方的结束消息,本端还没结束发送;TIME_WAIT是正常关闭后保留一段时间的状态。它们都是状态名,单次看到前者不自动证明代码泄漏。
下面再用这些概念看原理、命令和结果。规范与官方资料列在本章末尾,类比只帮助理解,不代替协议规则。
先想清楚:为什么需要「握手」
TCP提供可靠、有序的双向字节流;故障时可能重试后报告连接失败,并不承诺网络中断后仍无限保证送达,也不证明对端业务已处理。 要兑现这些承诺,双方必须各自维护一份状态:序号到哪了、对方收到哪了、还能发多少。 连接 = 双方共同维护的一份状态,不是某一方单方面的想法。
所以建立连接不能是「我发了就是连上了」,必须是双方都确认过。 握手就是这个确认过程。
只有三个标志位真正参与
TCP 报文头很复杂,但握手挥手只用得到这几个:
| 字段 | 作用 |
|---|---|
| SYN | 我想建立连接,同时给出我的初始序号 |
| ACK | 确认号表示下一个期望序号;仅无数据SYN/FIN消耗一个序号时体现+1 |
| FIN | 我没有数据要发了(注意:只是我这一个方向) |
| seq / ack | 序号与确认号,保证可靠有序 |
关键细节:SYN 和 FIN 各自占用一个序号。 确认SYN或FIN时通常需多加1;确认数据则按收到的字节数推进 —— 它们虽然是控制标志,但在序号空间里各占一格。
三次握手 —— 实测
跑 python labs\lab_tcp_states.py,阶段 1 的真实输出:
bind() + listen() 完成,服务端监听端口 = 56619
[listen() 之后:服务端处于 LISTEN]
127.0.0.1:56619 -> 0.0.0.0:0 LISTENING
客户端 connect() 已返回,客户端本地端口 = 56620
服务端 accept() 已返回
[connect() 返回之后:两端都是 ESTABLISHED]
127.0.0.1:56619 -> 0.0.0.0:0 LISTENING
127.0.0.1:56619 -> 127.0.0.1:56620 ESTABLISHED
127.0.0.1:56620 -> 127.0.0.1:56619 ESTABLISHED三个要点:
① LISTENING 行的对端是 0.0.0.0:0 监听套接字还没有对端,所以对端地址填 0。这里的远端0.0.0.0:0表示尚无对端;只有绑定在本地列的0.0.0.0才表示任意本地IPv4地址, :0 表示「还没有端口」。这条是监听套接字,不是连接。
② 一条连接在 netstat 里出现两次 因为两端都在本机:
127.0.0.1:56619 -> 127.0.0.1:56620 ESTABLISHED ← 服务端视角
127.0.0.1:56620 -> 127.0.0.1:56619 ESTABLISHED ← 客户端视角如果对端在别的机器上,你只能看到自己这一侧。
③ 在这个阻塞实验中,connect成功返回表示本端完成TCP建立。 非阻塞connect可先返回“正在进行”,失败也会以错误返回;本端成功不证明最终ACK已经到达对端,更不证明应用服务正常。连接超时只能说明在限定时间内未完成,不能断言SYN已发出、已被服务端收到或是哪个设备丢弃。
三个包到底完成了什么
客户端 服务端
|---- SYN seq=x ----------------------->| ① 我想连,我的序号从 x 开始
|<--- SYN+ACK seq=y, ack=x+1 -----------| ② 收到,我的序号从 y 开始
|---- ACK ack=y+1 ---------------------->| ③ 收到你的序号了为什么不是两次?
理由一:服务端要确认客户端的接收能力。 第三次 ACK 的作用是让服务端知道「我的 SYN+ACK 你收到了」。 少了这一步,服务端只知道「你能发」,不知道「你能收」。
理由二:防止历史失效的 SYN 建立无用连接。 (这条来自教材的经典论证,属于理论分析,不是我在本机测出来的) 假设网络严重拥塞,一个客户端很久以前发的 SYN 迟到了。如果只有两次握手, 服务端一收到这个 SYN 就认为连接建立了,于是一直等着一个早已不存在的客户端, 白白占用资源。有了第三次 ACK,服务端就能判断这个连接是否真的活着。
本质:三次恰好能让双方各确认一次自己的初始序号被对方收到。 常见正常流程使用三个报文交换并确认初始序号;同时打开或重传会出现不同数量,不能把抓到四个报文直接判成错误。
四次挥手 —— 为什么比握手多一次
这是本章最值得搞懂的一点。先看证据(阶段 4):
已建立新连接:服务端 56624 <-> 客户端 56625
客户端调用 shutdown(SHUT_WR):只关闭自己的发送方向
服务端 recv() 返回:b'' ← 空字节串,表示读到了 EOF
[客户端 shutdown 之后]
127.0.0.1:56624 -> 127.0.0.1:56625 CLOSE_WAIT
127.0.0.1:56625 -> 127.0.0.1:56624 FIN_WAIT_2
服务端仍然 sendall(b'BYE') 成功
客户端 recv() 拿到:b'BYE'看清楚这里发生了什么:客户端已经发了 FIN(表示"我不发了"), 但它仍然成功收到了服务端发来的 BYE。
客户端 服务端
|---- FIN seq=u ----------------------->| ① 我不发了
|<--- ACK ack=u+1 ----------------------| ② 知道了
|<--- BYE(真实数据,照样能收到!)---------| ← 关键:半关闭
|<--- FIN seq=v ------------------------| ③ 我也不发了
|---- ACK ack=v+1 ---------------------->| ④ 知道了因为 TCP 是全双工的,两个方向是独立关闭的。 每个方向各自需要一对 FIN + ACK,所以是 4 次:
FIN+ACK= 关掉「客户端 → 服务端」这个方向FIN+ACK= 关掉「服务端 → 客户端」这个方向
为什么常见关闭图画成四步? 两个发送方向独立关闭,收到FIN时应用可能仍有数据要发送,因此ACK与自己的FIN可能分开发。 但协议允许ACK与FIN合并;关闭也有同时关闭、复位和重传等情况,不是永远恰好四个包。SYN在TCP Fast Open等机制下也可以携带数据。
实测:谁进 TIME_WAIT
客户端先关闭(阶段 2)
[客户端 close() 之后,服务端还没 close()]
127.0.0.1:56619 -> 0.0.0.0:0 LISTENING
127.0.0.1:56619 -> 127.0.0.1:56620 CLOSE_WAIT
127.0.0.1:56620 -> 127.0.0.1:56619 FIN_WAIT_2注意:客户端此刻是 FIN_WAIT_2,不是 TIME_WAIT。
这是整个实验最容易看错的地方,也是很多人对挥手理解错误的根源:
- 客户端的
FIN发出去后,服务端内核自动回了ACK(内核不需要等应用),所以客户端从FIN_WAIT_1立刻进到FIN_WAIT_2 - 但服务端的应用程序还没调用
close(),内核就不会替它发FIN - 在正常关闭状态转换中,进入TIME_WAIT需相应FIN/ACK事件;连接也可能因复位、超时或系统策略被清理。
继续让服务端关闭:
[服务端也 close() 之后]
127.0.0.1:56619 -> 0.0.0.0:0 LISTENING
127.0.0.1:56620 -> 127.0.0.1:56619 TIME_WAIT服务端发完自己的 FIN、走完 LAST_ACK 就消失了; 客户端这时才收到那个 FIN、回完最后一个 ACK、进入 TIME_WAIT。
服务端先关闭(阶段 3)
[服务端先 close() 之后]
127.0.0.1:56621 -> 127.0.0.1:56622 FIN_WAIT_2
127.0.0.1:56622 -> 127.0.0.1:56621 CLOSE_WAIT
[客户端也 close() 之后]
127.0.0.1:56621 -> 127.0.0.1:56622 TIME_WAIT这次换成服务端停在 TIME_WAIT。
结论
一个重要推论
如果对端应用一直不 close(),主动关闭方会长时间卡在 FIN_WAIT_2。
本机普查数据里 FinWait2 = 33 —— 有 33 条连接处于这个状态, 意味着这些连接的对端收到了 FIN 却迟迟不关。 实测其中 28 条属于 FlClashCore(见 2.7 节),是代理主动关闭后的正常残留。
TIME_WAIT 为什么必须存在
TIME_WAIT为正常关闭保留状态,以便再次确认对端重传的FIN,并降低旧连接报文干扰同四元组新连接的风险。规范要求的等待用2×MSL描述;具体系统实现、超时和复用策略需要查版本与配置。RFC 9293 §3.6、§3.10
历史记录:约116秒的轮询结果
原日志确实记录了计时111秒时仍看到相应端口、116秒时不再看到。但原脚本在close、延时、状态查询和注册表查询之后才开始计时,并只用本地端口识别,没有完整四元组;查询本身也耗时。
因此可以说“轮询期间约116秒后不再观察到该端口”,不能说完整TIME_WAIT寿命严格落在(111,116],也不能据此否定系统可能采用120秒配置。 本轮没有重跑该实验。
更严谨的实验应以单调时钟记录关闭/状态进入与每次查询的起止时间,按完整四元组匹配,并报告采样误差。网上通用数字也不应代替目标系统文档或这种受控测量。
CLOSE_WAIT 和 TIME_WAIT:哪个该慌
| 状态 | 含义 | 何时需要调查 |
|---|---|---|
| CLOSE_WAIT | 收到对端FIN,本端应用尚未关闭发送方向 | 长期不退出、持续增长、与应用设计不符或耗尽资源 |
| FIN_WAIT_2 | 本端FIN已被确认,等待对端FIN | 持续时间、数量增长和系统清理策略异常 |
| TIME_WAIT | 正常关闭后保留状态 | 端口压力、连接失败或资源限制,不能只看数量 |
CLOSE_WAIT是合法的协议状态,短暂出现并不证明泄漏。 TIME_WAIT同样可能在大量短连接时造成资源压力;不能不看负载就说什么都不用做。
原快照报告CLOSE_WAIT 35、FIN_WAIT_2 33、TIME_WAIT 111,但后续进程统计并非同一时刻,不能把它们强行作总数分解。进程分布提供排查线索,不证明代理残留一定正常或各应用已经存在泄漏。RFC 9293 §3.3.2、§3.6
排错:怎么定位到进程
# 看所有 CLOSE_WAIT,并显示归属进程
Get-NetTCPConnection -State CloseWait |
Select-Object LocalAddress, LocalPort, RemoteAddress, RemotePort, State,
@{n='进程'; e={ (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName }},
OwningProcess |
Sort-Object 进程 |
Format-Table -AutoSize
# 按进程统计,看谁最多
Get-NetTCPConnection -State CloseWait |
Group-Object OwningProcess |
Sort-Object Count -Descending |
Select-Object Count, Name传统写法(一样有效,且在任何系统上都能用):
netstat -ano -p TCP | findstr CLOSE_WAIT-o 显示 PID,-p TCP 只看 TCP。拿到 PID 后用任务管理器或 Get-Process -Id <PID> 查出是哪个程序。
本章小结
| 结论 | 性质 |
|---|---|
| TCP提供可靠有序字节流,但连接可失败且不确认业务处理 | 协议范围 |
| 常见建立流程为三次握手,关闭时FIN/ACK可以合并 | 协议机制 |
| 半关闭后仍可接收对端发送的数据 | 协议机制与历史实验 |
| 阻塞connect成功仅表示本端TCP建立,不证明业务可用 | API边界 |
| 顺序关闭主动端通常进入TIME_WAIT,同时关闭可两端进入 | 状态机 |
| 历史轮询约116秒不等于严格测得完整寿命 | 实验限制 |
| CLOSE_WAIT是否泄漏应看持续时间、趋势和应用行为 | 诊断方法 |
自测题
5 题 · 先自己答,再展开对照
connect()阻塞了 30 秒后超时。此刻对端的内核有没有收到过 SYN?不能确定。SYN可能未发出、在去程丢失、到达后回应丢失或被过滤;connect不是等待HTTP等应用响应的调用。需两端抓包或日志判断。netstat 里一条连接显示
FIN_WAIT_2,它已经发过几个 FIN?收到过几个 FIN?发过 1 个FIN(收到对端 ACK 后才进入 FIN_WAIT_2),
收到过 0 个FIN(一旦收到对端 FIN 就会进 TIME_WAIT)。
所以FIN_WAIT_2= 「我关了,还在等你关」。一台服务器上有 5000 个
CLOSE_WAIT,另一个有 5000 个TIME_WAIT。哪个更严重?长期堆积且应用不应半关闭时,CLOSE_WAIT更值得排查;但单次5000条也不足以确定泄漏。TIME_WAIT可能正常,也可能造成端口压力,均需结合负载、趋势和失败现象。为什么主动关闭方要等 2MSL,被动关闭方不用等?
普通顺序关闭中,进入TIME_WAIT的一端为最后ACK和旧包清理保留状态;对端在LAST_ACK等待确认。同时关闭时两端都可能进入TIME_WAIT。客户端
close()后立刻netstat,为什么看到的是FIN_WAIT_2而不是TIME_WAIT?因为它的FIN已被对端内核自动ACK(内核不需要等应用),
所以它从FIN_WAIT_1立刻进到FIN_WAIT_2;
而TIME_WAIT只有在收到对端的 FIN之后才会进入,
对端应用还没调close(),那个 FIN 就还没发出来。
参考资料与适用范围
修订核对日期:2026-10-13。规则按所列协议与实现范围理解;文中的原始命令输出是历史观察,不表示本次重新实测。跨版本、代理/隧道及不同捕获位置需要另行核对。