TCP/IP(八):TCP 拥塞控制——从慢启动到 BBR

写在前面

上一篇把 TCP 的可靠性机制讲完了:序号与确认保证"发出去的都到了",超时重传和快重传补上丢失的段,滑动窗口和流量控制让发送方不会淹没接收方。但这里有个被刻意回避的问题:如果丢包不是因为接收方缓存满了,而是中间网络本身堵了呢

流量控制只盯着两端的收发能力,对中间路径一无所知。一个连接如果只顾自己往里灌数据,路由器缓冲区一旦撑满就开始丢包,丢包触发重传,重传又加剧拥堵——这就是 1986 年著名的"互联网拥塞崩溃"(congestion collapse)的成因。本篇就讲 TCP 怎么主动探测网络的承受能力 ,也就是拥塞控制(congestion control)的四个经典算法,以及从 Reno 到 BBR 的思路演进。


一、流量控制 vs 拥塞控制:保护对象不同

这是最容易混的一对概念,先一张表说清:

流量控制 Flow Control拥塞控制 Congestion Control
保护谁接收端(别撑爆接收缓存)中间网络(别压垮路由器 / 链路)
信号来自哪接收端通过 ACK 头里的窗口字段(rwnd)告诉发送端发送端自己(没人直接告诉你网络堵没堵)
窗口rwnd(接收窗口)cwnd(拥塞窗口,发送端自己维护)
关系协议里明文协商纯靠发送端算法估算

一句话:流量控制是接收端"明示"你慢一点,拥塞控制是发送端"自觉"慢一点 。两者并不互斥,实际能往网络里塞多少数据,取两者的较小值(见下一节)。


二、两个窗口:cwnd 与 rwnd

发送端在任一时刻"在途未确认"的数据量(FlightSize),不能超过两个窗口的较小值:

1
实际发送上限 = min(cwnd, rwnd)
  • cwnd(congestion window,拥塞窗口):发送端自己估算的"网络能吞多少",是拥塞控制的核心状态变量,动态变化。
  • rwnd(receive window,接收窗口):接收端在 ACK 报文里捎带的"我还能收多少",上一篇流量控制讲过。
1
2
3
4
5
6
7
8
9
              发送端能发多少?
        ┌───────────┴───────────┐
        │                       │
     cwnd ── 网络承受力         │
        │                       │
        └──────── min ──────────┘
              rwnd ── 接收端承受力

rwnd 是被动接收的(接收端说了算),而 cwnd 完全由发送端的拥塞控制算法自己驱动——下面四个算法就是围绕"cwnd 怎么涨、怎么砍"展开的。


三、经典四算法:慢启动、拥塞避免、快重传、快恢复

这是 RFC 5681 定义的 TCP 拥塞控制基本盘,几乎所有教科书都按这套讲(Reno / NewReno 实现)。核心是两个变量:cwndssthresh(slow start threshold,慢启动阈值)。

1. 慢启动(Slow Start):指数增长

连接刚建立,发送端对网络一无所知,先"小心翼翼地试探":

  • 初始 cwnd = IW(Initial Window)。老协议是 1 个 MSS,RFC 6928 把它提到 IW10 = min(10×MSS, 14600 字节) ,现代 Linux 默认就是 IW10。
  • 每收到一个确认 ACK,cwnd 增加 1 个 MSS。
  • 一个 RTT 内能收到的 ACK 数 ≈ cwnd / MSS,所以经过一个 RTT 后 cwnd 大约翻倍——指数增长 (1 → 2 → 4 → 8 → …)。

“慢"启动并不慢,它只是"起步慢、加速快”。指数增长会很快把网络塞满,所以必须有个刹车——就是 ssthresh:

  • 当 cwnd ≥ ssthresh 时,退出慢启动,进入拥塞避免。

2. 拥塞避免(Congestion Avoidance):线性增长

进了拥塞避免,发送端认为已经接近网络容量,改成"谨慎试探":

  • 每经过一个 RTT,cwnd 才增加 1 个 MSS(实现上:每个 ACK 让 cwnd 加 SMSS×SMSS/cwnd)。
  • 也就是 线性增长 ,和慢启动的指数增长形成鲜明对比。

为什么从指数切到线性?因为指数增长以 2 的幂次逼近极限,越往后越容易瞬间冲破网络容量;线性增长让 cwnd 在接近容量时缓慢上探,给网络留缓冲。

3. 快重传(Fast Retransmit):3 个重复 ACK

慢启动和拥塞避免回答"cwnd 怎么涨",那什么时候砍?最直观的信号是超时(RTO),但等 RTO 太慢(往往要几百毫秒到秒级)。快重传用一个更早的信号:重复 ACK

  • 接收端收到失序的段会立即回一个重复 ACK(ACK 号还是上次那个最大的连续确认号)。
  • 发送端如果连续收到 3 个重复 ACK ,就判定中间那个段丢了,不等超时、立即重传 丢失的段。
  • 为什么是 3 个?因为单个重复 ACK 可能只是乱序(网络重排),连收 3 个基本能确认是真的丢了。

4. 快恢复(Fast Recovery):砍半不归零

快重传之后 cwnd 怎么调?这里有个关键区分,取决于"丢包是哪种信号触发的":

  • 超时(RTO):认为网络出现了更严重的拥塞信号 → ssthresh 按规范根据 FlightSize 计算,cwnd 降到不超过 1 SMSS(一个完整报文段),再从慢启动增长。这里不是降到 0,也不等于恢复到现代常见的 IW10。
  • 快重传(3 dup ACK):网络其实还能传(毕竟重复 ACK 说明后面的段都到了),只是局部丢包 → 不必回到慢启动,只把 cwnd 砍半。

快恢复的具体步骤(Reno):

1
2
3
4
5
6
7
8
9
收到第 3 个重复 ACK 时:
  1. ssthresh = max(cwnd / 2, 2)      # 新阈值砍半
  2. 重传那个丢失的段                    # 快重传
  3. cwnd = ssthresh + 3              # 临时抬高 3 个 MSS
                                      #(3 个 dup ACK 意味着有 3 个段已离开网络)
  此后每再收到一个重复 ACK:cwnd += 1   # 窗口膨胀,允许继续发新数据
  直到收到一个新的(非重复)ACK:
     cwnd = ssthresh                  # 膨胀结束,回到阈值
     进入拥塞避免(线性增长)            # 注意:不回慢启动

这就是"快恢复"的含义:砍半,但不归零 ——比超时那条路温和得多。

ssthresh 的角色

ssthresh 是慢启动和拥塞避免的分水岭,本身是动态的:

  • 初始值通常很大(或设为任意大)。
  • 每次发生拥塞(快重传或超时)就更新:ssthresh = 当前 cwnd 的一半。
  • 于是 cwnd 在网络抖动中不断"涨上去 → 砍一半 → 再涨",ssthresh 像个跟随学习的天花板。

四、cwnd 随时间变化示意图

把上面几个阶段画在一张图上,cwnd 的典型轨迹长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  cwnd
    │                  ╮   收到 3 个重复 ACK
    │                ╭─╯   → 快重传 + 快恢复
    │              ╭─╯      ssthresh = cwnd/2
    │            ╭─╯         cwnd 落回 ssthresh
    │ 指数增长  ╭─╯           后继续线性(拥塞避免)
    │(慢启动) ╭─╯
    │       ╭─ ← cwnd ≥ ssthresh:转为线性增长
    │     ╭─╯
    │   ╭─╯
    │ ╭─╯
    └─┴──────────────────────────────────→ 时间
       建立连接

如果是超时而不是快重传,就把图上的“落到 ssthresh”换成“cwnd 落到 1 SMSS,再重新走指数慢启动”。看懂这张图就抓住了经典 Reno 的主线:指数上探 → 遇阻降低窗口 → 线性再上探。两条下降路径里,快重传后的恢复较温和,超时后的窗口收缩更激进,但都不是把 cwnd “归零”。


五、丢包作为拥塞信号的局限

上面整套 Reno 逻辑,都是把丢包当作拥塞的唯一信号 。这在 1980 年代的网络里成立——路由器缓存小,满了就丢。但在现代网络里这套假设出了三个问题:

1. Bufferbloat(缓冲膨胀)

现代路由器 / 交换机缓冲区都很大(为了尽量不丢包)。基于丢包的算法会一直加 cwnd,直到把缓冲区填满 才触发丢包——而这意味着每个包都要在满缓冲区里排上几百毫秒,延迟暴涨。结果就是:吞吐看着不低,但延迟和抖动高得离谱 。这就是著名的 Bufferbloat 问题,对交互应用(SSH、游戏、视频通话)是灾难。

2. 把随机丢包误判为拥塞

无线网络(Wi-Fi、4G / 5G)会因信号波动随机丢包,这根本不是"网络堵了"。但 Reno / Cubic 照样砍 cwnd,导致无线场景下吞吐上不去。

3. 高 BDP 链路利用不足

长肥管道(卫星、跨洲骨干)的"带宽 × 延迟"(BDP)很大,基于丢包的算法要"填满"整条管道才能逼近满速,而一旦丢包砍半又要很久爬回来。

Reno 的直接改进是 Cubic(Linux 2.6.19 起,2006 年至今的默认算法):把"砍半后线性恢复"换成一条三次函数曲线,恢复更快、对高 BDP 更友好。但它依然是基于丢包的 ,根本局限没动。


六、BBR:基于带宽与 RTT 的模型

BBR(Bottleneck Bandwidth and RTt)是 Google 在 2016 年提出的拥塞控制算法(Linux 4.9 起可选)。它换了个根本思路:不把丢包当唯一信号,而是直接建模网络的带宽和延迟

核心思路

BBR 持续测量并维护两个核心估计量:

  • BtlBw(Bottleneck Bandwidth):瓶颈链路的可用带宽——发送端通过观察 ACK 的到达速率(单位时间被确认的数据量)取一个滑动窗口内的最大值。
  • RTprop(Round-trip propagation time):链路的最小往返传播时延——在没有排队的情况下测到的 RTT 最小值。

两者的乘积就是 BDP(Bandwidth-Delay Product),也就是"把管道填满但不溢出"的理想在途数据量:

1
理想 cwnd ≈ BtlBw × RTprop = BDP

BBR 以 BtlBw 和 RTprop 为模型基础,用 **pacing(按速率平滑发送)**和拥塞窗口上限控制在途数据,并通过不同增益阶段周期性探测带宽。它的目标是在维持高吞吐的同时减少不必要的排队,但在途量并不恒等于一个 BDP,也不能保证缓冲区始终“半空”或“近空”。

和 Reno / Cubic 的根本区别

Reno / Cubic(基于丢包)BBR(基于模型)
主要控制依据丢包(及 ECN)带宽、RTT 模型;新版本也参考丢包 / ECN
发送控制主要调整 cwndpacing + cwnd 上限 + 周期性探测
延迟表现容易在深缓冲路径积累排队目标是减少持续排队,但不保证所有路径都更低延迟
随机丢包照砍 cwnd(误判)不轻易砍
适用场景传统有线、公平性友好高 BDP、长距、无线、低延迟

现状

  • BBR 在 Linux 上通过 sysctl net.ipv4.tcp_congestion_control=bbr 开启,需要内核 ≥ 4.9。
  • 主流 Linux 发行版的默认仍是 Cubic ,BBR 作为可选;部分对吞吐 / 延迟敏感的场景(CDN、跨洲链路、Google 内部、YouTube)已大规模启用 BBR。
  • BBR 自身也在演进(v1、v2、v3),v2 起更注重和 Cubic 的公平性、对丢包 / ECN 做出更克制的反应。

一句话总结 BBR:与其等"丢包"这个事后信号,不如主动测出网络的真实容量


小结

  • 流量控制 保护接收端(rwnd),拥塞控制 保护网络(cwnd),实际发送上限 = min(cwnd, rwnd)。
  • 经典四算法:慢启动(指数增长)→ 越过 ssthresh 进入拥塞避免(线性增长)→ 丢包时快重传(3 个重复 ACK)→ 快恢复(窗口明显降低但不回到 1 SMSS);超时后则把 cwnd 降到不超过 1 SMSS,再回慢启动。
  • ssthresh 是慢启动与拥塞避免的分水岭,每次拥塞后动态更新。
  • Reno / Cubic 主要依赖丢包(也可结合 ECN)感知拥塞,在 Bufferbloat、随机丢包和高 BDP 场景可能遇到局限。
  • BBR 主要用带宽(BtlBw)与最小 RTT(RTprop)建模,通过 pacing 与窗口上限控制发送;它不以丢包作为首要信号,但新版本仍会参考丢包 / ECN,实际效果取决于路径与版本。

下一篇:TCP/IP(九):TCP 进阶 —— SACK 选择确认、Nagle 与延迟 ACK 的冲突、keepalive 探活、SO_REUSEADDR / REUSEPORT 端口复用,以及 QUIC 与 MPTCP 简介。


参考:

Licensed under CC BY-NC-SA 4.0