bigywater.tech
网络协议 · 49

滑动窗口与流量控制:接收窗口通告

接收窗口是怎么告诉发送方“慢一点”的?接收方不读数据会发生什么?窗口大小怎么决定吞吐?这篇抓接收方每个 ACK 里的窗口,看它随缓冲区变化,再用 netlab 的 100 ms 往返验证吞吐 ≈ 窗口 ÷ RTT。

342,976 → 15,360
接收方不读数据时,ACK 里通告的窗口(字节);最后小于一个 MSS,发送方停发
10.79 vs 10.62 MB/s
往返 100 ms、接收缓冲区 1 MiB 时的实测吞吐与“窗口 ÷ RTT”的理论上限
~3 MB/s
往返 100 ms 时,SO_RCVBUF 设成 16 / 64 / 256 KiB 的吞吐几乎一样:macOS 没有照这些设置给窗口

流量控制

实测 · 本机执行输出
  接收方(客户端,connect 之前设的 SO_RCVBUF)通告的窗口缩放系数 3(窗口字段 × 2^3);SO_RCVBUF 读回 342972 字节;发送方 SO_SNDBUF 146988 字节
    第几个 ACK   确认号(已收到字节)   窗口字段(原始)   实际窗口 = 字段 × 2^3
         1                0            42872       342976
         2            16332            40831       326648
         3            32664            38790       310320
         4            48996            36749       293992
         5            65328            34708       277664
         6                …                …            …
         7           294808             6028        48224
         8           311140             3987        31896
         9           327472             1946        15568
        10           327680             1920        15360
  共 23 种(确认号, 窗口)的 ACK(只算接收方读数据之前的);窗口恰好为 0 的出现 0 种,窗口没有恰好降到 0:最小是 15360 字节(小于一个 MSS,发送方因此停发)
  接收方什么都没读时,接收缓冲区里可读的字节数(FIONREAD)327680;最后一个 ACK 通告的窗口 15360 字节,小于一个 MSS(16344)——此时发送方不会再发小包;发送方的 send() 一共让内核收下了 474460 字节 ≈ 接收缓冲区 327680 + 发送方自己的发送缓冲区(SO_SNDBUF 146988),再往后 send() 返回 EAGAIN——这就是流量控制;接收方后来把全部数据读完共 474460 字节

接收方在每个 ACK 里通告还能收多少(窗口字段 × 缩放系数)。接收方不读,窗口越来越小,小于一个 MSS 时发送方停发,send() 最终返回 EAGAIN。

读速

实测 · 本机执行输出
  接收方读速约 1 MB/s:发送方在 2 秒里 send() 出去 4.8 MB(含进入缓冲区的),接收方读走 1.9 MB,折合接收方 0.9 MB/s
  接收方读速约 20 MB/s:发送方在 2 秒里 send() 出去 35.8 MB(含进入缓冲区的),接收方读走 33.0 MB,折合接收方 16.5 MB/s
  接收方读速不限速:发送方在 2 秒里 send() 出去 5926.1 MB(含进入缓冲区的),接收方读走 5926.1 MB,折合接收方 1974.4 MB/s
  发送速率被接收方的读速限制住,不会无限塞满网络和对方内存:接收窗口是对端告诉你“我还能收多少”

发送方的实际速率被接收方的读速限制住,不会无限制地塞满对方的内存。

窗口与吞吐

实测 · 本机执行输出
  接收方 SO_RCVBUF =    16384(读回 326640):3 秒读到 9.06 MB,平均 3.02 MB/s;理论上限 读回的窗口 ÷ RTT = 3.27 MB/s
  接收方 SO_RCVBUF =    65536(读回 342972):3 秒读到 9.13 MB,平均 3.04 MB/s;理论上限 读回的窗口 ÷ RTT = 3.43 MB/s
  接收方 SO_RCVBUF =   262144(读回 277644):3 秒读到 9.16 MB,平均 3.05 MB/s;理论上限 读回的窗口 ÷ RTT = 2.78 MB/s
  接收方 SO_RCVBUF =  1048576(读回 1061580):3 秒读到 32.37 MB,平均 10.79 MB/s;理论上限 读回的窗口 ÷ RTT = 10.62 MB/s
  窗口越小,在往返时间长的链路上越吃亏:发送方每个往返最多发一个窗口的数据,然后必须等 ACK(见 BDP 一篇)

每个往返最多发一个窗口的数据,吞吐 ≈ 窗口 ÷ RTT;往返越长,窗口越要大。macOS 对小于约 256 KiB 的 SO_RCVBUF 设置没有照办。

窗口更新

实测 · 本机执行输出
  接收方读了 400 次、每次 100 字节(共 40000 字节):这段时间里抓到“窗口更新”ACK(确认号不变、窗口字段变了)1 个;窗口更新的前几次(实际窗口,字节):[(7688, 269000)]
  每次窗口变大的幅度:[261312]...——接收方不会读走 100 字节就立刻通告,而是等空出来的空间累积到一定程度(这里与 MSS 16344 / 缓冲区的一半相关)才发一个窗口更新,避免发送方反复发很小的包(糊涂窗口综合征,RFC 1122)

读走少量数据时接收方不会马上通告新窗口,避免糊涂窗口综合征。

分步演示

滑动窗口与流量控制
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。第一、二、四节在 lo0 上;第三节的往返 100 ms 由 netlab 注入;接收方都在 connect 之前设置 SO_RCVBUF(设在监听套接字上不会被接受后的连接继承);窗口 = 窗口字段 × 2^缩放系数(这里是 3);第一节的 ACK 只统计接收方开始读数据之前的
    • 没有验证:SO_RCVBUF 小于约 256 KiB 时内核给出的窗口为什么在 28~34 万字节(可能是接收缓冲区自动调整的下限);窗口更新为什么一次增加 261312 字节;发送方的拥塞窗口对吞吐的限制(见拥塞控制);en0 真实网络上的窗口行为
    京ICP备2026067502号-1京公网安备11010502063487号