Skip to content
Published on

把 TCP 拥塞控制看出来 — CUBIC 与 BBRv3,以及跑在浏览器里的 gVisor 网络栈

分享
Authors

引言 — 在一个浏览器标签页里跑起来的两套 TCP 协议栈

2026 年 7 月 28 日,Hacker News 上出现了一条名为 Simulating TCP loss and congestion in browser using Go/WASM 的链接。打开会看到 一个叫 ccsim 的页面,而在那里跑着的并不是演示用的动画。源码仓库 的说明很准确 — 它把 gVisor 的用户态 TCP/IP 协议栈(gvisor.dev/gvisor/pkg/tcpip) 两套,分别立成发送端和接收端,中间塞进一个令牌桶式的瓶颈链路模型,再把整个事件驱动的模拟器编译成 WebAssembly,放进浏览器 worker 里执行。

作者一方的 技术博客 里写了动机。他们在隧道基础设施里用 gVisor 网络栈来注入数据包,而联合创始人家里那条不稳定的线路上探针一直挂掉。他们心里有数:在有丢包的链路上 CUBIC 表现不好;为了确认这一点,他们干脆在 netstack 之上自己实现了 BBRv3,还顺手做出了验证框架。模拟器就是那套验证框架的副产品。

之所以不能把它当成浏览器演示小看,理由在仓库的验证文档里。CUBIC 的窗口增长曲线以 C=0.410R²=0.9999 拟合 RFC 9438 的三次函数,丢包响应函数落在规格值的 0.68 到 0.95 倍之间,RED 的标记曲线则用精确的斜坡概率质量函数配合卡方检验来比对。而且原生构建和 WASM 构建的输出样本流 逐字节完全一致。既是教具,也是回归测试。

本文以这个模拟器为切入口,但真正的价值放在它底下的算法上。拥塞控制解同一个问题已经解了三十多年,而屏幕上那个锯齿形在真实服务器上会表现成什么症状,才是我们最终需要知道的东西。

骨架 — slow start、congestion avoidance、fast retransmit

TCP 发送端同时盯着两个窗口。一个是接收端通告的接收窗口(rwnd),即「接收方能承受的量」;另一个是拥塞窗口(cwnd),即「发送端 估计 网络能承受的量」。实际能发出去的量取两者中较小的那个。所谓拥塞控制算法,归根到底就是一套关于何时、以多大幅度抬高和压低 cwnd 的规则。

Slow start(慢启动)和名字相反,是指数级地快。连接开始时把 cwnd 设为初始窗口(如今的 Linux 按 RFC 6928 取 10 MSS),每来一个 ACK 就把 cwnd 加 1 MSS。一个 ACK 会放行两个新报文段,于是每个 RTT cwnd 翻一倍。这是在完全不了解路径容量的状态下,用接近对数时间去逼近它的一次搜索。这个阶段一直持续到抵达 ssthresh 或检测到丢包为止。

Congestion avoidance(拥塞避免)紧随其后。此时认为已经在容量附近,于是把增长速度降到每 RTT 大约 1 MSS。一旦遇到丢包,就用乘法压低 cwnd。这套 AIMD(additive increase, multiplicative decrease)结构正是把互联网从崩溃中救出来的核心 — 多条流共享同一个瓶颈时,AIMD 会朝公平性收敛,而乘法增长不会。

Fast retransmit(快速重传)是为了远早于重传超时(RTO)抓住丢包而设的装置。接收端收到乱序报文段时,会反复 ACK 自己最后一次连续收到的位置。发送端连着收到三个重复的同一个 ACK,就不等 RTO 直接立刻重传那个报文段,进入 fast recovery,把 cwnd 压到约一半后继续发。若是干等 RTO,这里至少要白白扔掉几百毫秒。

这里有一点必须点明。上面几段是教科书里的 Reno 版本,而 如今 Linux 实际做的丢包检测是 RACK-TLP(RFC 8985)。它不数重复 ACK,而是比较每个报文段的发送时刻与 SACK 确认的到达顺序,只要「比这个报文段更晚发出去的已经到了,而且乱序窗口也过了」,就判定为丢包。原因是三个重复 ACK 这条规则对乱序很脆弱,对尾部丢包(最后一个报文段消失,压根产生不了重复 ACK)更是无能为力。ccsim 也是开着 RACK/TLP 跑的。

CUBIC 在优化什么

作为 Linux、Windows、Apple 各家协议栈默认值的 CUBIC 以 RFC 9438 在 2023 年 8 月完成标准化(废止 RFC 8312)。正如其名,它把窗口增长定义成一个三次函数。

W_cubic(t) = C * (t - K)^3 + W_max

  t     : 距上一次拥塞事件的经过时间(秒)
  W_max : 拥塞事件发生前一刻的窗口大小
  C     : 缩放常数 (默认 0.4)
  K     : cbrt(W_max * (1 - beta) / C),   beta = 0.7

这条曲线的形状就是全部设计意图。丢包之后先 凹形快速 恢复到 W_max 附近,抵达 W_max 后几乎变平、在那一带长时间停留并试探,如果什么都没发生就在其上 凸形加速,去寻找新的容量。减小系数不是 Reno 的 0.5 而是 0.7,所以一次丢包丢掉的量也更少。

要说它优化了什么,有两点。

第一,高 BDP 链路的利用率。Reno 每 RTT 增加 1 MSS,在带宽大、时延长的路径上慢得让人绝望。CUBIC 的增长是经过 时间 的函数,因此没有直接绑在 RTT 上。

第二,也正因为如此,RTT 公平性。在 Reno 一系里,RTT 短的流在同样的时间内增长更多,也就抢到更多带宽。作为时间函数的 CUBIC,这种偏置要小得多。

作为代价,CUBIC 放弃的东西也很明确。CUBIC 的拥塞信号仍然只有丢包这一个。队列积了多少、时延涨了多少,它都不看。一路往上推到丢包为止,丢包了就往下压。后面要讲的两个症状,全都从这一句话里派生出来。

BBR 看的是另一样东西 — 不是丢包,而是路径模型

BBR 把问题换掉了。它不问「什么时候会丢包」,而是持续估计「这条路径的瓶颈带宽和最小往返时延各是多少」,并试图守在两者之积(BDP)附近。ccsim 的界面说明把这层对比压缩得很好 — 1 BDP 是最优点,而 CUBIC 是越过丢包这条线去找到那个点,BBR 则是估计路径然后待在那个点附近

这个最优点也是 Kleinrock 早在 1979 年就整理出来的。in-flight 少于 BDP,链路就空着;多于 BDP,超出的部分全部堆进瓶颈队列,只把时延推高,吞吐还是原样。麻烦在于带宽和最小时延没法同时测 — 要量带宽的最大值就得制造队列,要量最小时延就得把队列排空。所以 BBR 把两个值 轮流 测量。

ccsim 实现的 BBRv3 遵循 draft-ietf-ccwg-bbr 的状态机(目前是第 06 版,状态为 Experimental)。把仓库文档里列出的常数一并搬过来就是这样。

  • Startup — pacing 增益 710/256(约 2.77),cwnd 增益 2.0。一直往上推,直到带宽估计值不再增长。
  • Drain — pacing 增益 88/256(约 0.34)。把 Startup 阶段造出来的队列排空。
  • ProbeBW — DOWN(232/256) → CRUISE(1.0) → REFILL(1.0) → UP(1.25) 的循环。大部分时间都花在这里,只在 UP 区间稍微多推一下,确认带宽是不是变大了。
  • ProbeRTT — cwnd 增益 0.5,持续约 200ms。至少每隔 5 秒调度一次,通过排空队列来刷新 min_rtt 滤波器。min_rtt 本身用 10 秒的窗口保持。

在此之上,BBRv3 相对 v1 增加的是 对丢包和 ECN 的显式响应边界。它设置了 inflight_hiinflight_lobw_lo 这类上限,一旦路径真的回送丢包或 CE 标记,就不再硬推自己的模型,而是往后退。这是对「BBRv1 与 CUBIC 共存时过于激进」这一批评的回应。

而真要把 BBR 用起来,必然跟着来的是 pacing。只把 cwnd 开大、成串爆发地发出去,瓶颈队列会瞬间被撑爆,所以必须按估计出来的带宽把数据包在时间轴上均匀地铺开。在 Linux 上,fq qdisc 或内核内部的 pacing 干这件事。ccsim 把它放在 CC 之外的发送整合层,通过虚拟时钟定时器以 min(pacing_rate * 1ms, 64KB) 大小的量子为单位发出。

工程师真正会看到的三种症状

上面是理论,下面才是它变成工单时的模样。把 ccsim 仓库里的场景实测值原样搬过来,三种症状就以数字的形式显形了。

场景条件结果
cubic-single100Mbps、无丢包96.5 Mbps、cwnd 削减 3 次、RTO 0 次
random-loss随机丢包 1%CUBIC 3.0 Mbps 对 BBRv3 57 Mbps
bufferbloat很深的 taildrop 缓冲、base RTT 30msCUBIC 稳态 srtt 1,070ms 对 BBRv3 32ms
ecn-codelCoDel + ECNCE 标记 654 次、丢包与重传 0 次、srtt 为 base 的 2.1 倍以下
rate-step带宽阶跃变化立刻交付 24 Mbps,max-bw 滤波器 2 个周期后为新 BDP 的 1.07 倍
fairness一条 100Mbps 链路上 CUBIC 与 BBR 共存链路占用 96%,分配约为 33% 对 67%

症状 1 — 丢包链路上的吞吐崩塌。在 1% 随机丢包下,CUBIC 一路瘫到 3.0 Mbps。在 100Mbps 的链路上这是 3%。这不是 bug,而是丢包型设计的直接后果,Mathis 近似公式原样就能预测出来。

throughput  ~  MSS / (RTT * sqrt(p))  * C

  MSS 1448B, RTT 40ms, p = 0.01, C ~ 1.22
  -> 1.22 * 1448 * 8 / (0.04 * 0.1)  ~  3.5 Mbps

公式里关键的一点是丢包率是以平方根进入的。丢包从 0.1% 涨十倍到 1%,吞吐不是掉到三分之一,而是掉到约三点一六分之一。而且,这个丢包究竟是因为拥塞,还是因为无线干扰或线路质量,CUBIC 区分不了。这正是穿过无线段或长距离线路的流莫名其妙就慢下来的典型原因,也是 BBR 能跑出 57 Mbps 的原因 — 只要交付率的估计值还稳得住,BBR 不会因为丢包就把模型晃得太厉害。

症状 2 — bufferbloat。在挂着很深缓冲的瓶颈上,CUBIC 的稳态 srtt 是 1,070ms。路径实际的往返时延是 30ms。整整一秒都是排队等待时间。而这里反倒几乎不丢包 — 缓冲够深,超出的部分不是被丢掉而是被吸收成时延。于是 CUBIC 判断「一切正常」,就在那个状态上稳住了。同样条件下 BBRv3 维持在 32ms。base RTT 是 30ms,也就是说队列基本是空的。

症状 3 — 把缓冲区调大反而更糟。把上面两段合起来就自动得出这一条。丢包型 CC 的稳态队列占用量 由缓冲区大小决定。缓冲区翻倍,稳态时延大致也翻倍。「看到丢包了,那就加缓冲吧」这种直觉只是把丢包换成了时延,从交互式流量的角度看这是笔更糟的买卖。路由器缓冲越来越便宜、这个错误因此蔓延到整个互联网,就是 bufferbloat 问题的历史。

处方在队列规律,而不在缓冲大小。表里 ecn-codel 那一行就是这幅图 — CoDel 盯着队列时延提前打 CE 标记,于是 丢包和重传都是 0 次,而 srtt 被压到 base 的 2.1 倍以下。这是用时延信号替换掉了丢包这个信号;在 Linux 上,fq_codelcake 干这件事。至于消费级路由器,这基本上是唯一可行的解法。

最后一行的公平性数字在实务上同样有意义。CUBIC 和 BBR 共享同一条链路时,分成 33% 对 67%。链路填到了 96%,所以没有浪费,但也谈不上均等。就算 BBRv3 比 v1 温和了,这也意味着只把服务器集群的一部分换成 BBR,剩下的 CUBIC 流会吃亏。切换最好按域整体来做,而不是切一部分。

在真实主机上查看 cwnd 和重传的方法

模拟器画得漂漂亮亮的那些值,在 Linux 主机上全都看得到。不需要写埋点代码。

# 当前默认算法与可用列表
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control

# 要用 BBR,模块和 pacing qdisc 得一起就位
sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
tc qdisc show dev eth0        # 确认是 fq 还是 fq_codel

# 每个套接字的状态: cwnd、ssthresh、rtt、重传、交付率、pacing rate 全都在这里
ss -tin state established
#   cubic wscale:7,7 rto:236 rtt:35.6/2.1 mss:1448 cwnd:73 ssthresh:52
#   bytes_sent:... retrans:0/14 delivery_rate:76.4Mbps busy:... pacing_rate:...

# 只看特定目的地
ss -tin dst 203.0.113.10

# 全主机的重传计数器 (只看增量)
nstat -az | grep -Ei 'TcpRetransSegs|TcpExtTCPLostRetransmit|TcpExtTCPTimeouts|TcpExtTCPFastRetrans'

ss -tin 输出里实务上最重要的三个值是这些。

  • cwnd — 单位是包。把 cwnd * mss 除以 rtt,得到那一瞬间的吞吐上限。再和 delivery_rate 一比,就能分清是应用没喂满(app-limited),还是网络在挡着。
  • retrans:X/Y — 前一个是当前未解决的重传,后一个是累计值。后面那个数字相对 bytes_sent 占百分之几,就是上一节里的丢包率 p
  • rtt:A/B — A 是平滑后的 RTT,B 是波动(mdev)。如果 A 远大于路径的最小 RTT 而重传几乎没有,那就是 bufferbloat 的签名。

如果想周期性地采样、用眼睛看 cwnd 的锯齿形,下面这些就够了。

# 每 200ms 只抽出 cwnd/rtt/retrans,作为时间序列留存
while :; do
  date +%s.%N | tr -d '\n'
  ss -tin dst 203.0.113.10 | tr '\n' ' ' \
    | grep -oE 'cwnd:[0-9]+|rtt:[0-9.]+|retrans:[0-9]+/[0-9]+' | tr '\n' ' '
  echo
  sleep 0.2
done

要往内核事件层面下探,tracepoint 早就准备好了。

# 把拥塞状态迁移和重传当作事件来看
sudo bpftrace -e '
tracepoint:tcp:tcp_retransmit_skb { @retrans[comm] = count(); }
tracepoint:tcp:tcp_cong_state_set { @state[args->cong_state] = count(); }'

# 搭出复现环境: 丢包 1% + 时延 20ms + 浅队列
sudo tc qdisc replace dev eth0 root netem loss 1% delay 20ms limit 100
# 复现 bufferbloat: 把队列做得非常深
sudo tc qdisc replace dev eth0 root netem delay 20ms limit 10000
# 验证处方: 换成 AQM
sudo tc qdisc replace dev eth0 root fq_codel

netem 造出丢包,再把同一个文件分别用 CUBIC 和 BBR 传一遍,上一节表里的 3.0 Mbps 对 57 Mbps 是哪一类差距,30 秒之内就能体感到。Linux 网络栈内部结构的全貌,我另外写在了 Linux 网络内部 一篇里;TCP 本身的行为则在 TCP 深度解析 一篇里。

模拟器展示不了的东西

作为教具,ccsim 的强项很明确。它跑的是真实的协议实现,所以 SACK 记分板、RACK 的乱序判定、ECN 回显这些都是实物而不是近似;而且它是确定性(deterministic)的,同一个种子出同样的结果。拖动滑块时,模拟仍在运行中参数就变了,于是带宽突然掉下去时两种算法的反应差多少,在一屏之内就看得见。

同时,模拟的边界也同样清楚。

  • 流只有一条或两条。真实的瓶颈上有成千上万条流,其中大多数短到连 slow start 都出不去。稳态行为在互联网流量里占的比重,比想象中小。
  • 瓶颈只有一个而且固定。真实路径上瓶颈会移动,无线段的容量则以秒为单位剧烈起伏。
  • 没有中间盒。会抹掉 ECN 比特的设备、会弄坏 SACK 的防火墙、会替你终结 TCP 的代理,在真实互联网上都很常见。开了 BBR 却拿不到预期效果,相当一部分原因就在这里。
  • 没有 CPU 和中断处理。GRO、TSO、网卡队列,以及哪个核在处理软中断,在现实中会大幅改变时延分布。

所以从模拟器里该带走的不是「BBR 在我们环境里能跑多少 Mbps」,而是 这个算法对哪些信号有反应、对哪些没有反应。有了这份感觉,你从 ss -tin 输出的一行里能读出来的东西会明显不一样。

结语 — 拥塞控制至今仍是一个估计问题

总结一下。

  • cwnd 是发送端对网络容量的 估计值。算法之间的差别,就是拿什么当证据去更新这个估计的差别。
  • CUBIC 只拿丢包一样东西当证据。所以它会把无线丢包误认成拥塞,而在很深的缓冲面前又收不到证据,于是把队列填满后就那样稳住了。
  • BBR 用交付率和最小 RTT 建起路径模型,瞄准 BDP 附近。BBRv3 在此之上加了丢包与 ECN 的响应边界,改善了共存性,但和 CUBIC 分享链路时的分配依然不均等。
  • 把缓冲区调大,是一笔把丢包换成时延的买卖。答案不在队列大小,而在 fq_codel 这类 AQM。
  • 上面这一切,都可以用 ss -tinnstattc netem 三样工具在自己的主机上复现并观测。

很久没碰拥塞控制的人接到一张写着「慢」的工单,通常会先怀疑带宽。实际上更常见的情况是 cwnd 被丢包率锁住了,或者 RTT 被队列撑胖了。这两种情况,加带宽都不会让任何事情变好。

参考资料