跳到正文

滑动窗口与拥塞控制

环境:Windows 11 x64,GCC 13.1.0(D:/Qt/Tools/mingw1310_64/bin/gcc.exe,-O2 -lws2_32),所有流量都在 127.0.0.1 上自建 server/client,报文用 Npcap(C:\Windows\System32\Npcap\wpcap.dll)抓,内核侧数据用 WSAIoctl(SIO_TCP_INFO) 读 TCP_INFO_v1。承袭《TCP 连接管理》的口径:回环 MSS=65495、本机 TIME_WAIT≈119–120 秒、TCP 动态端口 1024 起共 13977 个。同期还有 4–5 个 agent 在这台 6 核机器上跑,所以时延只给中位数与区间;本节刻意把结论压在与负载无关的量上:段数、ACK 数、窗口字段、字节数、netsh 读回的默认值。

一、先分清三个”限速器”:TCP_INFO 会直接告诉你今天是谁卡的

一条 TCP 连接发不出去,只有三个原因:拥塞窗口满了、对端窗口满了、应用自己没把数据喂进 socket。Windows 的 TCP_INFO_v1 把这三笔账分别记在 SndLimBytesCwnd / SndLimBytesRwin / SndLimBytesSnd 里,读一次就定性,比猜靠谱:

发送 8388608 字节(8 MB)用时 6.1 ms => 1318.6 MB/s,采样 128 次
结束 state=4 mss=65495 rtt=38us minRtt=14us cwnd=654950 sndWnd=261888 rcvWnd=65535 rcvBuf=65535
     out=8388648 retrans=0 fastRetrans=0 dupAckIn=0 timeoutEp=0 synRetrans=0
结束 发送受限统计: cwnd 次数=0 时间=0 字节=0 | rwin 次数=1 时间=0 字节=65495 | 应用 次数=2 时间=7 字节=8323115

三个数字连起来读:“应用”这一栏吃下了 8323115 字节,“rwin” 只有 65495,“cwnd” 干脆是 0。也就是说这场传输从头到尾不是网络限制,是我这个发送程序 send() 调用本身来不及喂——单次 send() 系统调用就要十几微秒(见《TCP 连接管理》里”净往返中位 21µs”),而回环 RTT 只有 14–38µs。拥塞窗口全程 654950 字节没动过(峰值采样也是 654950),因为它压根没成为瓶颈,慢启动也就无从表现。

顺带一条单位坑:SndLimTime* 这几个时间字段的单位,资料写法并不统一;本机零窗实验里 SndLimTimeRwin 读回 3004,而同一次实测的应用侧阻塞是 3004.7 ms——数值完全对齐毫秒。所以在本机这几个字段要按毫秒读才对得上;拿不准时只用它们的次数和字节数,时间字段必须和抓包或应用计时互相核对。

二、接收窗口才是流控的本体:一次零窗的完整形状

flow.exe s 19411 16384 4096 3000:服务端在 listen() 前把 SO_RCVBUF 设成 16384,读满 4096 字节就停 3 秒不读;客户端连发 262144 字节。抓包(相对微秒):

[       46] 19411->4046 .S..A. win=16384 [MSS=65495] [wscale=0] [sackok]      ← 服务端主动把缩放因子降成 0
[      144] 4046->19411 ....A. seq=...321 totlen=16424 payload=16384          ← 段大小被窗口夹到 16384,不是 65495
[      158] 19411->4046 ....A. ack=...7705 win=16384
[      170] 4046->19411 ...PA. payload=16384
[      197] 4046->19411 ....A. payload=16384
[      211] 19411->4046 ....A. ack=2177090473 win=0        ← 累计收下 49152 字节后,通告窗口归零
[   315223] 4046->19411 ....A. seq=2177090473 payload=1    ← 第 1 次零窗探测
[   315253] 19411->4046 ....A. win=0                       ← 30µs 后回答:还是 0
[   920725] 4046->19411 ....A. payload=1                  ← 第 2 次:605.5ms 后
[  2132135] 4046->19411 ....A. payload=1                  ← 第 3 次:1211.4ms 后(翻倍)
[  3004224] 19411->4046 ....A. ack=2177090473 win=16384   ← 应用恢复读,窗口更新自己冒出来
[  3004274] 4046->19411 ...PA. payload=16384              ← 50µs 后数据继续流
client:发出 262144 字节用时 3004.7ms;单次 send 阻塞(>1ms)次数=1 累计阻塞=3004.1ms
srv   :T+0.0ms 读到 4096 字节后停读 3000 ms;恢复读后 0.9ms 内又读走 258048 字节

四条可以背下来的结论,全部是这台机器的实测:

  • 窗口字段是”字节预算”,不是段大小上限。 通告 16384 时,发送端把段切成 payload=16384(totlen=16424),而不是 MSS 的 65495。窗口比 MSS 小时更别扭:只能发 1 字节的探测段。
  • 手动设 SO_RCVBUF 会让这条连接退出窗口缩放。 SYN/ACK 里是 [wscale=0],raw win=16384 就是 16384 字节;而默认 socket 是 [wscale=8] + win=255 = 65280 字节(《TCP 连接管理》里那个”第三个包 win=255”就是它)。两边 TCP_INFO 交叉确认:客户端 sndWnd=16384,正好等于服务端请求的缓冲区值——Windows 不会把你要求的值偷偷翻倍或减半。
  • 零窗探测从 ~315ms 起,每次翻倍。 三次探测的间隔是 315.0 / 605.5 / 1211.4 毫秒(比值 1.00/1.92/2.00)。这个起点和 netsh int tcp show supplemental 读回的”最小 RTO (毫秒) : 300”是同一个数量级,而不是 netsh int tcp show global 里那个”初始 RTO : 1000”。
  • 窗口更新不需要对方先说话。 t=3004224 那条 win=16384 之前没有任何来自发送端的新数据,接收端应用一恢复读就主动发出;50µs 后数据立刻续上。排障时在抓包里看到”零窗后突然冒出一条 win 变大的纯 ACK”,那是应用侧恢复,不是网络恢复。
  • 结束时的 TCP_INFO:cwnd=654950 sndWnd=16384 rtt=35us minRtt=20us,受限统计 cwnd 次数=0 | rwin 次数=1 时间=3004 字节=262147 | 应用 次数=1。整场一次 cwnd 限制都没有,全在 rwin 上。

BytesRetrans=3 这一栏值得单说。抓包数出来:那 3 次零窗探测,每次 1 字节、seq 都停在 2177090473,窗口打开后这 1 字节又随完整段重发一遍——所以 BytesRetrans=3 完全来自探测,TimeoutEpisodes=0、FastRetrans=0、DupAcksIn=0 也印证没发生任何真丢包。看到 BytesRetrans>0 就喊”网络丢包”是错的,得先看它是不是零窗探测的重复计入。

三、自动调谐把窗口抬到 261888,缩放因子让它装得下

同一个 8 MB 传输里,服务端(收端,SO_RCVBUF 没设)通告窗口的 raw 字段分布是:

服务端通告 win 字段:1023 出现 59 次,511 出现 3 次,65535 出现 1 次(SYN/ACK)
客户端通告 win 字段:255 出现 125 次
服务端侧 TCP_INFO:rcvWnd=262140 rcvBuf=262140 sndWnd=65280 cwnd=654950 rtt=65us

wscale=8 时 raw 字段每 +1 就是 +256 字节,所以 511→130816、1023→261888;而收端 TCP_INFO 报的 RcvWnd=262140、RcvBuf=262140 就是同一个东西的字节口径(261888 + 尾巴上的零头)。默认值这块本机实测:新建 TCP socket 的 SO_RCVBUF/SO_SNDBUF 都是 65536,TCP_NODELAY 按 1 字节读回 0(Nagle 默认开),SO_KEEPALIVE 默认 0。接收窗口自动调节级别 : normal(netsh int tcp show global),于是 8 MB 场景里内核把接收缓冲从 65536 一路抬到约 262140 字节(≈4 倍默认),发端的 sndWnd 也跟着从 65280 变成 261888。

这也解释了第二节为什么要特意设 SO_RCVBUF:不设就测不出窗口关闭——自动调谐下回环根本塞不满。顺带一个必须记住的读法:SO_RCVBUF 用 getsockopt 读的时候 optlen 是 4;而 TCP_NODELAY、SO_KEEPALIVE 这类 BOOL 选项只回 1 字节,用 4 字节的 int 去接会读到 -1024 这种垃圾值(我第一次就把它当成”默认值很怪”)。

四、延迟确认与 Nagle:本机实测是”每两段一 ACK”,而 Nagle 在回环上失效

netsh int tcp show supplemental 读回两个默认值:延迟 ACK 超时(毫秒) : 40、延迟 ACK 频率 : 2。它们在抓包里的形状非常干净——回到第一节那场 8 MB 传输(客户端 128 次 send(65536),每次被切成 65495+41 两段,共 256 个数据段):

服务端发出 63 条,其中纯 ACK = 61 条;61 条 ACK 的确认量合计 8323114 字节(≈应用实发 8388608)
纯 ACK 的 ack 前进量分布:
      41 字节 : 1 条        65536 字节 : 41 条   (=1 次应用写 = 2 个数据段)
     196608 字节 : 5 条 (3 次写)   262144 字节 : 4 条 (4 次写)   327680 字节 : 3 条 (5 次写)
     393216 字节 : 3 条 (6 次写)   655360 字节 : 1 条 (10 次写)  720896 字节 : 1 条 (11 次写)
一次确认 ≥2 个 MSS 段的 ACK = 17 条

41 条 ACK 各自确认了整 65536 字节,也就是两次应用写被切出来的 2 个段共用一条 ACK,与”频率 2”字面一致;另有 17 条 ACK 一口气确认了 3–11 次写(196608=3×65536、720896=11×)——段来得比 40ms 超时快时,Windows 就把 ACK 一直攒着。数据段的载荷分布也顺手记一下:70 段 65495 + 52 段 41 字节。

一条必须同时记下的取证教训:这场抓包只收到 122/256 个数据段、4586782/8388608 字节(54.7%),而 ACK 侧一条没漏(61 条合计正好等于全部载荷)。也就是说 Npcap 在回环上会漏抓大数据段(机器上同时还有 4–5 个 agent 在抢核)。所以”122 段 / 61 ACK = 0.500”这个比值看着漂亮,其实是双边都漏出来的巧合,不能拿来当证据;真正的证据是上面那张”ACK 前进量分布”表——它只依赖接收侧那 61 条完整捕获的报文。用抓包统计比例之前,先核对抓到的字节数和应用侧的字节数是否相等。

Nagle 侧的实测反而是”没效果”。同一台机器、同一个只读不写的服务端,客户端连续 8 次 1 字节写:

TCP_NODELAY=0(默认,Nagle 开):客户端数据段=8  服务端纯ACK=9   段间隔 71/87/100/114µs(≈14µs 一条)
TCP_NODELAY=1(Nagle 关)      :客户端数据段=8  服务端纯ACK=9   段间隔 86/105/123/142µs(≈18µs 一条)
加大到每次 100 个 1 字节写:两种配置都是 200 个 payload=1 的段,一个都没合并

原因就是第一节的:回环 RTT 14–38µs,而一次 send() 系统调用也要十几微秒,等第二次写进内核时第一个字节早被确认了,Nagle 的触发条件(“有未确认的数据段”)永远不成立,于是没有可合并的东西,也没有可延迟的 ACK。把服务端换成”收满 2 字节才回一个字节”(nagle.exe cfg=0,18 次)也一样:

cfg=0 NODELAY=off reps=18  往返: 中位 106.4 us  min 81.4  max 215.6

没有出现资料里常说的 40ms 级停滞。这不是”Nagle+延迟确认的死锁不存在”,而是这台机器、这条链路上造不出来:死锁要求接收端真的把第一个 ACK 压住 40ms,而本机对单播小段的处理是”来一条答一条”(8 段→8 个独立 ACK),延迟确认只在满 MSS 的段流上表现为频率 2。

五、拥塞控制:本机默认 cubic、初始窗 10 MSS,BBR 不存在所以没得比

netsh 读回的默认值(全部只读查询,没有改任何东西):

netsh int tcp show global
  接收方缩放状态 : enabled        接收窗口自动调节级别 : normal     加载项拥塞控制提供程序 : default
  ECN 功能 : disabled             RFC 1323 时间戳 : allowed        初始 RTO : 1000
  接收段合并状态 : enabled        非 Sack Rtt 复原 : disabled      最大 SYN 重新传输次数 : 4
  快速打开 : enabled              HyStart : enabled                比例费率降低 : enabled   节奏配置文件 : off

netsh int tcp show supplemental        (默认模板 internet)
  最小 RTO (毫秒) : 300    初始拥塞窗口(MSS) : 10    拥塞控制提供程序 : cubic
  延迟 ACK 超时(毫秒) : 40  延迟 ACK 频率 : 2      启用 RACK : enabled   启用尾部丢失探测 : enabled
  启用拥塞窗口重新启动 : disabled

Get-NetTCPSetting -SettingName InternetCustom → InitialRto 1000 / MaxSynRetransmissions 4 / CongestionProvider CUBIC

值得记的是两组 RTO 不是一回事:初始 RTO 1000 是数据段重传的起步值,最小 RTO 300 是地板;第二节的零窗探测从 ~315ms 起步,落在这个地板附近。

cwnd=654950 这个数在本机出现了三次:8 MB 传输的结束值、零窗实验的结束值、以及它恰好等于 10×MSS(10×65495),也就是 初始拥塞窗口(MSS) : 10 的字节化。“读回的默认值”和”内核实际持有的 cwnd”在这里对上了,这比引用 RFC 6928 的”IW=10”更有说服力——不过也仅仅是”对上了初值”:整场 SndLimTransCwnd=0,本机回环上拥塞窗口从没真正限制过发送,所以慢启动指数增长、拥塞避免的加一、cubic 的那条凸曲线,一个都没在这台机器上被观察到。

六、SACK、快重传与 BBR:本节未在本机实测,原因写清楚

握手两侧都带 [sackok],SACK 能力是协商成功的(MSS=65495 / wscale=8 / sackok 三个选项 12 字节,hlen=32)。能力有,用法也能抓到:在 127.0.0.1 上盲抓 4 秒全部 TCP 流量,9 条报文带 SACK 块,形状高度一致——

[   235505] TCP 8768->7890 ....A. ack=1968319497 win=1019 hlen=32 opts=12 payload=0 [nop] [nop] [sack=1968319496-1968319497]
[   250836] TCP 8767->7890 ....A. ack=3564106340 win=1019 hlen=32 opts=12 payload=0 [nop] [nop] [sack=3564106339-3564106340]
[   448421] TCP 3369->7890 ....A. ack=  229334268 win=186  hlen=32 opts=12 payload=0 [nop] [nop] [sack=229334267-229334268]

9 条全是 ...A./....A. 的纯 ACK,每个 SACK 块恰好覆盖 1 个字节(end-start = 1),而且 ack 已经推进到块尾——这正是《TCP 连接管理》里那种”保活探测重传那 1 个字节、对端用 SACK 确认”的形状,win=1019(×256 = 260864 字节)也是自动调谐后的量级。但这些是本机其它服务(7890/5430 一类代理与数据库端口)的连接,不是我的实验对象:我没有办法让内核对我的连接产生乱序,所以只能证明”SACK 在这台机器上确实被用起来”,不能证明”快重传省了多少时间”。

因此以下四项本节未在本机实测,不要当作本机结论引用:

  • 快重传/快恢复的触发细节(3 条重复 ACK、RACK 的实际行为):127.0.0.1 不会丢包,retrans/fastRetrans/dupAckIn/timeoutEp 在 8 MB 与 262144 字节两场实验里全是 0;制造丢包要注入丢弃(NDIS/PfR 之类),既不允许也在禁止清单里。
  • cubic 的慢启动/拥塞避免曲线与 ssthresh 变化:本机回环上发送端始终是应用受限或窗口受限,cwnd 从未参与决定发送速率,Cwnd 全程 654950 不变。
  • BBR 与 cubic 的差异:加载项拥塞控制提供程序 : default、CongestionProvider : CUBIC,且 C:\Windows\System32\drivers\bbr.sys 不存在(netsh int tcp show global 的加载项一栏也没有任何附加提供程序)。本机根本没有第二个提供程序可切,也没有真实带宽-时延产品的外链路,任何”BBR 比 cubic 快多少”的数字在本节都没有实测依据。
  • 真实链路的带宽时延积与窗口增长:需要跨机、跨网段;对外只允许少量只读请求,不做链路压测。

七、小结

  • 先问”谁卡的”:SndLimBytesCwnd/Rwin/SndLimBytesSnd 三栏直接给答案。本机 8 MB 回环传输是应用受限 8323115 字节、rwin 只碰了一次(65495),cwnd 一次都没限制。
  • 窗口字段是字节预算:通告 16384 时段就切成 16384(totlen=16424),不是 MSS 65495。
  • 设了 SO_RCVBUF 就退出缩放:SYN/ACK 从 [wscale=8] 变成 [wscale=0],raw win=16384 就是 16384 字节;默认 socket 是 wscale=8+win=255=65280。
  • 零窗探测 315.0/605.5/1211.4 毫秒翻倍,起点对得上 最小 RTO 300ms,对不上 初始 RTO 1000ms;探测的 3 个字节被计进 BytesRetrans=3,TimeoutEpisodes 仍是 0。
  • 自动调谐实测:接收缓冲从默认 65536 抬到 262140,通告窗口 raw 字段 511→1023(wscale=8 即 130816→261888 字节)。
  • 延迟确认实测:61 条纯 ACK 确认了全部 8323114 字节,其中 41 条各确认 65536 字节(=1 次应用写=2 个数据段),另有 17 条一次确认 3–11 次写,与读回的”频率 2、超时 40ms”一致;但同一场抓包只收到 54.7% 的数据段,所以比例型结论要靠接收侧完整捕获的那一侧算。
  • Nagle 在回环上失效:TCP_NODELAY 开与关都是 8 段 8 ACK(100 次紧写成 200 个 1 字节段),cfg=0 往返中位 106.4µs,没出现 40ms 停滞——条件不成立,不是算法不存在。
  • cwnd 恒为 654950 = 10×MSS,与 初始拥塞窗口(MSS) : 10 对上;本机只有 cubic,bbr.sys 不存在,BBR 无从对比。