跳到正文

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 再关)EOFTIME_WAIT=16LISTEN=1
服务端优雅关(读完就关)EOF=160 行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,不是我设的 1000ms keepaliveinterval;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 期间用同一源端口连同一目的地,connect 0.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 黑洞重传节奏(回环上造不出黑洞,对外不做黑洞实验);跨主机真实链路下的握手/挥手时序。