TCP 连接管理
环境:Windows 11 x64,GCC 13.1.0(MinGW-w64),
gcc -O2 -lws2_32 -liphlpapi。所有实验都在 127.0.0.1 上自建 server/client(客户端源端口固定在 20000–28000,这样每个连接都能在内核表里被单独数出来),状态用GetTcpTable读、报文用自写 mini-tcpdump 抓。没有增删路由、防火墙或任何注册表项(TCPIP\Parameters下只做了只读查询)。同期还有其它 agent 在这台机器上跑,所以所有时延给中位数+区间,并标注了采集时刻。
一、三次握手:帧、序号、选项,一次都不省
一条连接从建到关,抓包只有 9 帧(客户端主动关,tcp2.exe fin c,源端口 20000):
[ 0] TCP 20000->19800 .S.... seq=3522022378 win=65535 hlen=32 [MSS=65495] [wscale=8] [sackok] frame=56
[ 47] TCP 19800->20000 .S..A. seq=21036017 ack=3522022379 win=65535 hlen=32 [MSS=65495] [wscale=8]
[ 68] TCP 20000->19800 ....A. seq=...379 ack=21036018 win=255 hlen=20 frame=44
[ 152] TCP 20000->19800 ...PA. payload=2 (应用数据)
[ 177] TCP 19800->20000 ....A. ack=...381
[ 204] TCP 20000->19800 F...A. ← 客户端 FIN
[ 224] TCP 19800->20000 ....A. ← 对 FIN 的 ACK
[ 254] TCP 19800->20000 F...A. ← 服务端 FIN
[ 292] TCP 20000->19800 ....A. ← 最后一次 ACK,客户端进 TIME_WAIT
握手 3 帧、68 微秒;整条连接的一生 292 微秒。两个必须盯住的字段:SYN 里的 win=65535 到了第三个包变成 win=255——因为 wscale=8,255<<8 = 65280,窗口缩放只在双方都带选项时生效;以及回环上的 MSS=65495(65535−20−20,由 IPv4 总长上限决定,不是 MTU,见《分层模型与抓包入门》)。
GetTcpTable 的可见性有个坑,实测很干脆:非阻塞 connect 之后用 50ms 间隔轮询整张表,2 秒内始终查不到这条 SYN_SENT 行(tcp2.exe synx),也就是说”连不上时先查表”是查不到的,只能靠 SO_ERROR:
t= 10.5ms 表内状态=表里没有这一行 SO_ERROR=0
t= 2074.7ms 表内状态=表里没有这一行 SO_ERROR=10061 WSAECONNREFUSED
★应用侧拿到错误的时刻 = 2074.7ms
顺带把 MIB_TCPROW.dwLocalPort 的字节序坑一次:它是 32 位字段里装 16 位网络序端口,必须 ntohs((u_short)dw),用 ntohl() 会得到 218497024 这种数(同一行 netstat 显示的是 127.0.0.1:3334)。我第一次写的统计函数因此把所有状态都数成了 0,白跑一轮。
二、connect 被拒的 2 秒:RST 早就到了,应用还在等
这是本节最反直觉的一条。目的地址是 127.1.2.3:19710(本机 127.0.0.1:19710 有监听,但这个地址不属于它),抓包显示 RST/ACK 在 28 微秒就到了:
[ 0] TCP 20996->19710 .S.... seq=3522022378 win=65535 [MSS=65495]
[ 28] TCP 19710->20996 ..R.A. seq=0 ack=3522022379 win=0
可应用侧 connect() 却在 2074.7ms / 2057.5ms(两次独立运行)之后才返回 WSAECONNREFUSED。抓包看原因:客户端在这 2 秒里把 SYN 重传了 4 次,每次都以同样的 RST/ACK 收场。另一组数据(backlog 打满,源端口 20005)把节奏量得更清楚:
[ 2020] 20005->19800 .S.... [ 2031] 19800->20005 ..R.A. win=0
[ 514413] 20005->19800 .S.... [ 514436] 19800->20005 ..R.A.
[ 1026434] 20005->19800 .S.... [ 1026458] 19800->20005 ..R.A.
[ 1537311] 20005->19800 .S.... [ 1537342] 19800->20005 ..R.A.
重传间隔 ≈ 512ms,共 5 个 SYN(1 次初始 + 4 次重传),正好对上 netsh int tcp show global 里的”最大 SYN 重新传输次数 : 4”;同一份输出里”初始 RTO : 1000”是数据段用的值,SYN 在这条 RST 路径上用的是 ~512ms,两者不是一回事(未被应答、也无 RST 的纯黑洞 SYN 本机造不出来,其重传节奏未实测)。
实践含义:连一个”拒绝”的端口,Windows 上要花 2.03–2.07 秒(backlog 实验里 25 个被拒客户端各自 2015.97–2053.93ms,跨度 38ms)。任何”多点几个后端挑一个活的”的客户端逻辑,都会为每个死端口付这 2 秒;connect 超时设成 1 秒等于永远看不到真正的 refused。
三、backlog 到底管多少人:半连接队列的实测形状
listen(fd, 5),应用侧先不 accept,12 个客户端一起冲(tcp2.exe backlog 5 12):
连接结果:成功=5 被 RST(10061)=25 其他=0 ← 30 客户端那轮
抓包计数:SYN->19800 = 40 SYN/ACK = 5 第三次 ACK = 5 RST/ACK = 35
三行数字互相咬合:SYN/ACK 恰好 5 条 = backlog 值;被拒的 7 个客户端各发 5 个 SYN,共 35 个,每个都吃到一条 RST/ACK(win=0)。换 30 个客户端跑,结果仍是 成功=5。所以本机的行为是:
- backlog 限制的是应用还没 accept 就能建成的连接数,队列满时不是静默丢 SYN,而是明确回 RST/ACK(Linux 上常见的”悄悄丢、让客户端重传”在这里不成立,至少这条回环路径不成立)。
- 被拒的那一方不是立刻知道:见第二节,2.03 秒。
- 事后把队列里的连接 accept 出来,
成功=5条全在(事后 accept 成功=5),没有任何一条握手是”骗过去”的——Windows 不会在应用没准备好时先把连接塞进 ESTABLISHED。
至于半连接队列本身的容量,本机拿不到数:netsh int tcp show security 只输出两行(内存压力保护 : enabled、配置文件 : enabled),没有每接口/每目标的半开上限;TCPIP\Parameters 里 MaxNumTcpHalfOpenConnections、BacklogRetryCount、TcpNumConnections、NonBacklogMaxSockets 全部是”未设置”。所以本节只能给形状、给不了容量:这是本机未实测项,不要引用任何”半连接队列 = 128/1024”之类的数字当作本机事实。
SYN 攻击的防御面因此在本机看得很清楚:每条恶意 SYN 只消耗”回一条 RST + 一个 2 秒的 TCB 尝试”,没有 backlog 之外的排队代价;真正的资源风险在应用 accept 之后(见第五节 CLOSE_WAIT)。
四、四次挥手:谁先关,TIME_WAIT 就落在谁头上(本机是 2 分钟)
四种关闭姿势,各 16 条连接,客户端/内核表两侧分别数(T+0.2s 与 T+2s 两次采样结果完全一致):
| 场景 | 对端 recv 看到 | 客户端侧状态 | 服务端侧状态 |
|---|---|---|---|
| 客户端优雅关(服务端读到 EOF 再关) | EOF | TIME_WAIT=16 | LISTEN=1 |
| 服务端优雅关(读完就关) | EOF=16 | 0 行 | TIME_WAIT=16 LISTEN=1 |
| 服务端带着未读数据 close | 被 RST=16(10054,不是 0) | 0 行 | LISTEN=1,无 TIME_WAIT |
客户端 SO_LINGER{1,0} | — | 0 行 | 0 行,无 TIME_WAIT |
第三行是”connection reset by peer”的机制本身:接收缓冲区里还有没读走的数据时 close,协议栈发 RST 而不是 FIN。应用表现为 recv 返回错误 10054 而不是 0(16/16 全部如此),代价是双方都不会进入 TIME_WAIT——表面上”更快”,实际上对端读到了你还没写完的东西,或者你以为发出去的数据其实丢了。第四行是同一个效果的主动版:SO_LINGER{onoff=1, l_onoff=1, l_linger=0} 直接跳过挥手。
TIME_WAIT 的时长是这一节必须实测的数字。16 条连接,客户端主动关,每秒查一次内核表:
关闭 16 条连接后开始计时
t= 0.0s TIME_WAIT 条数=15
t= 119.2s TIME_WAIT 条数=16 (轮询期间稳定在 15–16)
t= 120.2s TIME_WAIT 条数=0
归零发生在 119.2–120.2 秒之间,即本机 TIME_WAIT ≈ 2 分钟。注册表 HKLM\...\TCPIP\Parameters\TcpTimedWaitDelay 是”未设置”(该键下实际只有 DataBasePath、Domain、ForwardBroadcasts、ICSDomain、IPEnableRouter、NameServer、Hostname、Health、MaxUserPort、PortTrackerEnabledMode 这些值),所以上面的就是系统默认行为。资料里常见的”4 分钟/240 秒”在本机不成立,写部署文档或连接池回收周期时,要按 2 分钟这台机器算。
TIME_WAIT 期间能不能复用同一个源端口?实测不能,而且是立刻失败:
第 1 次重连(关后累计等待 2500ms) : r=-1 err=10048 WSAEADDRINUSE connect 用时 0.04ms
第 2 次重连(3000ms) : r=-1 err=10048 第 3 次(4500ms): r=-1 err=10048 第 4 次(10500ms): r=-1 err=10048
同一四元组(127.0.0.1:20999 → 127.0.0.1:19800)在 TIME_WAIT 里占着,connect 0.04ms 就报 10048,10.5 秒内 4 次全失败。这不是我特意设计的实验——第二轮跑 fin 测试时,上一轮留下的 200 条 TIME_WAIT 直接把新客户端的源端口占死,connect 全报 10048,我才回头把它做成正式测量。端口占用的账要按四元组算,不是按端口算。
五、CLOSE_WAIT 不会自己消失:一个 37 秒还不掉的例子
服务端 accept 之后永远不 closesocket(真实故障:忘了关、或者句柄泄漏),客户端正常关闭,12 条连接:
T+0.2s [客户端侧] 命中行=12: FIN_WAIT_2=12
T+0.2s [服务端侧] 命中行=13: LISTEN=1 CLOSE_WAIT=12
T+2s [客户端侧] 命中行=12: FIN_WAIT_2=12 [服务端侧] LISTEN=1 CLOSE_WAIT=12
T+37s [服务端侧 CLOSE_WAIT 是否老化] LISTEN=1 CLOSE_WAIT=12
37 秒后一条都没少。 两个状态各自卡死:服务端在 CLOSE_WAIT 等应用关(这是纯应用责任,内核没有超时权),客户端在 FIN_WAIT_2 等对端的 FIN。所以:
netstat里大量 CLOSE_WAIT 一定是本机应用的 bug,不是”网络慢”,也不是”内核在回收”。- FIN_WAIT_2 的对偶:只要对端不关,主动关闭方就停在 FIN_WAIT_2 而不是 TIME_WAIT,这条路径不产生 TIME_WAIT,因此也躲开了第四节的端口占用——但换成句柄和内存长期被占。
- 本机未实测的部分:FIN_WAIT_2 的超时(
KeepAliveTime等参数全部”未设置”,观察窗只有 37 秒,不能推断它的默认值)。
六、连接复用与保活:5.9 倍的差距,3.01 秒的探测
同一台机器、同一个 echo 线程(服务端读到请求就回显),200 个请求:
(a) 每请求新建连接 200 次:总 26.9ms,平均 0.134ms/次,其中"发+收"中位 0.026ms
churn 刚结束 [客户端侧] 命中行=200: TIME_WAIT=200
churn +1.5s [客户端侧] 命中行=200: TIME_WAIT=200
(b) 单连接复用 200 次:建连 0.15ms,200 次往返总 4.5ms,平均 0.023ms/次,单次往返中位 0.021ms
比值:新建-用完-关闭 的每请求成本 / 复用 = 5.9 倍
拆开看更有说服力:复用时的净往返中位 21µs,而 churn 模式下每请求总成本 134µs——多出来的 113µs 全是握手 + 挥手 + 套接字建立/销毁,跟数据传输本身没关系。回环上尚且差 5.9 倍,真实链路上再加一个 RTT 会更难看。
再叠上端口预算:netsh int ipv4 show dynamicport tcp 本机是起始 1024、共 13977 个(顺带一条:UDP 是 49152 起 16384 个,TCP 和 UDP 的动态端口范围不同)。13977 个源端口 ÷ 120 秒 TIME_WAIT ≈ 每秒 116 次新建连接的可持续上限(这是把两个本机实测值相除得到的,不是厂商给的数)。超过它,connect 就会像第四节那样报 10048/无法取端口。
保活这块,SIO_KEEPALIVE_VALS(onoff=1, time=3000, interval=1000) 的实测结果和字面期待不一样:
[ 3010955] TCP 20998->19800 ....A. seq=3038677380 ack=21036018 win=255 payload=1 ← 第 1 次探测
[ 3010974] TCP 19800->20998 ....A. [sack=3038677380-3038677381] ← 19µs 后回 SACK
[ 6015816] / [ 9029559] / [ 12031373] / [ 15043161] / [ 18053323] 同样的探测
- 首次探测 3.011 秒(=
keepalivetime),之后每 3.005–3.014 秒一次,也就是周期用的是keepalivetime,不是我设的 1000mskeepaliveinterval;interval管的是”探测没被确认时”的重传间隔,而这里每次探测都在 19–26µs 内被确认,所以永远用不上。想按 1 秒一次发保活,得把keepalivetime也设成 1000。 - 探测段的形状:
seq回退 1(等于 ISN)、带 1 字节载荷的 ACK,对端用一个恰好覆盖这 1 个字节的 SACK 块(3038677380-3038677381)来确认。这不是”空的 ACK”,抓包时看到payload=1而应用从没发过数据,就是保活。 - 只开
SO_KEEPALIVE不设参数的那条连接(20997,空闲 6 秒):一个探测包都没有。注册表KeepAliveTime/KeepAliveInterval都是”未设置”,所以默认走系统值(文档口径 2 小时/1 秒)——本机没有等满 2 小时,这个默认时长属于未实测项。
七、小结
- 一次连接的一生可以是 9 帧、292 微秒;
wscale生效时第三个包的win=255实际是 65280;SYN_SENT 在GetTcpTable里查不到(2 秒轮询全程无行),排障只能看SO_ERROR。 - 被拒的 connect 要等 2.03–2.07 秒:RST/ACK 28µs 就到了,但栈仍按 ~512ms 间隔把 SYN 重传 4 次(1+4=5 个 SYN,与
最大 SYN 重新传输次数 : 4一致)。初始 RTO 1000ms是数据段用的,别套到 SYN 上。 - backlog 就是”能建成几条”:backlog=5 → SYN/ACK 恰好 5 条、第三次 ACK 5 条,其余 35 条 SYN 各回一条
win=0的 RST/ACK;队列满时本机不静默丢包。半连接队列容量本机不可测(netsh int tcp show security只有两行开关,相关注册表值全部未设置)。 - TIME_WAIT 落在主动关闭方,本机实测 119.2–120.2 秒(约 2 分钟),
TcpTimedWaitDelay未设置即为默认;“240 秒”在本机不成立。TIME_WAIT 期间用同一源端口连同一目的地,connect0.04ms 就报 10048。 - close 时缓冲区里还有未读数据 → 发 RST:对端
recv拿到 10054 而不是 0,且两侧都没有 TIME_WAIT(16/16 复现)。SO_LINGER{1,0}是同一效果的主动版。 - CLOSE_WAIT 没有定时器:服务端不 close 就 37 秒后原样 12 条,对端 12 条停在 FIN_WAIT_2。看到成片 CLOSE_WAIT,去查应用,别查网络。
- 复用比每请求新建快 5.9 倍(回环,200 请求:0.134ms vs 0.023ms,净往返中位 21µs);配合 TCP 动态端口 1024 起共 13977 个,本机”新建连接”的可持续速率约 116 次/秒。
- 保活周期用的是
keepalivetime(实测 3.005–3.014 秒),keepaliveinterval只在探测未被确认时才起作用;探测是”seq 退 1、带 1 字节”的 ACK,对端用覆盖那 1 字节的 SACK 回应。 - 本节未在本机实测:半连接队列容量(无出口可查);FIN_WAIT_2 / 保活默认 2 小时(观察窗只有 37 秒 / 6 秒,等不起);无 RST 的纯 SYN 黑洞重传节奏(回环上造不出黑洞,对外不做黑洞实验);跨主机真实链路下的握手/挥手时序。