滑动窗口与拥塞控制
环境: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],rawwin=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],rawwin=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 无从对比。