一、拥塞窗口 cwnd

发送方维护一个变量 cwnd(congestion window),它与流量控制的 rwnd 共同限制在途数据量:

LastByteSent − LastByteAcked  ≤  min(cwnd, rwnd)

发送速率的近似

速率 ≈ cwnd / RTT      (字节/秒)

**因此「控制发送速率」等价于「控制 cwnd」。**整个 TCP 拥塞控制,就是一套调整 cwnd 的规则。

拥塞信号从哪来

TCP 是端到端方案(第 15 讲),它只能观察到两件事:

事件 解读 严重程度
超时(Timeout) 严重拥塞——连续多个包都没到 🔴
3 个重复 ACK 轻微拥塞——只丢了一个,后面的都到了 🟡
ACK 正常到达 网络通畅,可以试着加速 🟢

两种信号严重程度不同,因此响应也不同——这是 Reno 相对于早期 Tahoe 的核心改进。


二、三个阶段

2.1 慢启动(Slow Start)

连接刚建立时,cwnd = 1 MSS。

每收到一个 ACK,cwnd += 1 MSS

效果是每个 RTT 翻倍

RTT 1:  cwnd = 1     发 1 个段,收 1 个 ACK
RTT 2:  cwnd = 2     发 2 个段,收 2 个 ACK
RTT 3:  cwnd = 4
RTT 4:  cwnd = 8
RTT 5:  cwnd = 16
        ⭐ 指数增长

⚠️ 名字有误导性:「慢启动」不慢,它是指数增长。「慢」是相对于 1988 年之前「一上来就用满窗口猛发」的做法而言的。

**为什么要指数?**因为初始的 1 MSS 离可用带宽可能差几万倍(第 11 讲的 BDP 计算),线性增长要花几万个 RTT 才能填满管道。指数增长能在 log(BDP) 个 RTT 内接近目标。

何时退出慢启动

  • cwnd 达到阈值 ssthresh → 转入拥塞避免
  • 发生丢包 → 见第三节

2.2 拥塞避免(Congestion Avoidance)

每个 RTT,cwnd += 1 MSS

实现上(每收到一个 ACK):

cwnd += MSS × (MSS / cwnd)

这就是 AIMD 的「加性增」(第 15 讲)。

**为什么在 ssthresh 之后改成线性?**因为此时已经接近上次出问题的水位,再翻倍就是鲁莽。线性增长是在「小心翼翼地试探还有没有更多带宽」。

2.3 对丢包的响应

信号 ssthresh cwnd 进入的状态
超时 cwnd / 2 1 MSS 慢启动
3 个重复 ACK cwnd / 2 ssthresh + 3 快速恢复

为什么超时要退回 1?

超时意味着连续多个报文段都没能到达,网络可能已经严重拥塞甚至路径中断。此时任何乐观的假设都是危险的,必须从最保守的状态重新探测。

为什么 3 个重复 ACK 只减半?

收到重复 ACK 说明后续的报文段仍在到达——网络还是通的,只是丢了一个。这时把 cwnd 砍到 1 是过度反应,会白白损失大量吞吐量。

📌 **这个区分是 TCP Reno 的核心贡献。**早期的 TCP Tahoe 对两种信号一视同仁,都退回 cwnd=1,在轻微丢包时性能损失很大。

2.4 快速恢复(Fast Recovery)

进入快速恢复后:

cwnd = ssthresh + 3 MSS        (+3 是因为已有 3 个段离开了网络)
每再收到一个重复 ACK:cwnd += 1 MSS   (膨胀,维持数据在途)
收到确认新数据的 ACK:cwnd = ssthresh,进入拥塞避免  ⭐ 收缩
若超时:cwnd = 1,进入慢启动

三、TCP Reno 状态机

                     ┌──────────────────┐
      cwnd=1 MSS ───▶│    慢启动         │
      ssthresh=64K   │  cwnd 每 RTT ×2  │
                     └────┬──────┬──────┘
        cwnd ≥ ssthresh   │      │  超时: ssthresh=cwnd/2, cwnd=1
                          ▼      └──────────┐
                     ┌──────────────────┐   │
              ┌─────▶│    拥塞避免        │   │
              │      │  cwnd 每 RTT +1  │   │
              │      └────┬──────┬──────┘   │
   新数据 ACK │           │      │ 超时      │
   cwnd=ssthresh          │      └───────────┤
              │  3 dup ACK│                  │
              │  ssthresh=cwnd/2             │
              │  cwnd=ssthresh+3             │
              │           ▼                  │
              │      ┌──────────────────┐    │
              └──────┤    快速恢复       │────┘
                     │ 每 dup ACK cwnd+1│ 超时
                     └──────────────────┘

cwnd 演化图

cwnd
 ↑
 │        超时              3 dup ACK
 │         ↓                    ↓
16├      ╱│                  ╱╲
 │     ╱ │                 ╱  │
12├    ╱  │              ╱    │
 │   ╱   │            ╱      ╲
 8├  ╱    │         ╱          ╲___  ← 快速恢复后回到 ssthresh
 │ ╱     │      ╱  ← 拥塞避免(线性)
 4├╱      │   ╱
 │        │ ╱
 1├        ╱  ← 慢启动(指数),从 1 重新开始
 └──────────────────────────────────→ 时间(RTT)

四、TCP 吞吐量公式

4.1 平均吞吐量

在拥塞避免阶段,cwnd 在 W/2 到 W 之间锯齿震荡(W 是丢包时的窗口)。平均值为 0.75W

平均吞吐量 ≈ 0.75 × W / RTT

4.2 与丢包率的关系 ⭐

一个完整的锯齿周期内:

  • cwnd 从 W/2 涨到 W,用了 W/2 个 RTT
  • 期间发送的报文段数 ≈ (W/2)·(W/2) + (1/2)(W/2)² ≈ 3W²/8
  • 每个周期丢 1 个包,所以丢包率 L ≈ 8/(3W²)

解出 W 代回,得到著名的公式:

平均吞吐量 ≈  1.22 × MSS / (RTT × √L)

两条关键结论

  1. 吞吐量 ∝ 1/RTT → 这就是第 15 讲说的 RTT 不公平
  2. 吞吐量 ∝ 1/√L → 丢包率的平方根反比

4.3 这个公式在高速链路上的破产

问题:要在一条 10 Gbps、RTT 100 ms 的链路上跑满,需要多低的丢包率?

需要 cwnd W:
   W = 吞吐量 × RTT / MSS = 10¹⁰ × 0.1 / (1500×8) = 83,333 个报文段

由 L ≈ 8/(3W²):
   L ≈ 8 / (3 × 83333²) ≈ 3.8 × 10⁻¹⁰

⚠️ 需要丢包率低于四十亿分之一。

这在真实网络里不可能达到——即使是纯光纤链路,误码率也高于这个数量级。

后果:经典 TCP Reno 在高带宽长距离链路上根本跑不满

📌 这是 CUBIC 和 BBR 存在的全部理由。

另一个层面的问题:Reno 从 cwnd = 41666(减半后)线性涨回 83333,需要 41667 个 RTT ≈ 70 分钟。一次丢包让你损失一个小时。


五、CUBIC(Linux 默认)

5.1 核心思想

cwnd 不再是「时间的线性函数」,而是「距离上次丢包时间的三次函数」。

W(t) = C(t − K)³ + W_max

  W_max = 上次丢包时的窗口
  K     = 从当前窗口回到 W_max 所需时间
  C     = 缩放常数(0.4)

形状

cwnd
 ↑
 │  W_max ┄┄┄┄┄┄┄┄╭─────╮┄┄┄┄┄┄┄┄
 │              ╱          ╲
 │           ╱   ⭐平台期     ╲
 │        ╱                     ╲___ 快速探测新带宽
 │     ╱  快速回升
 │   ╱
 └──────────────────────────────→ 时间
     ↑丢包

三个阶段

  1. 快速回升:丢包后迅速涨回接近 W_max(不像 Reno 那样慢慢爬)
  2. 平台期:在 W_max 附近缓慢增长,谨慎试探
  3. 快速探测:确认 W_max 已经过时后,加速寻找新的上限

5.2 两个关键性质

1️⃣ RTT 公平性

因为 cwnd 是绝对时间 t 的函数,而不是「每个 RTT 加一次」,所以短 RTT 的连接不再自动占优。这直接修补了第 15 讲说的 RTT 不公平问题。

2️⃣ 减小因子从 0.5 改为 0.7

丢包时 cwnd × 0.7 而不是 × 0.5——在高速链路上,砍一半的代价太大了。

📌 CUBIC 自 Linux 2.6.19(2006)起成为默认拥塞控制算法,是今天互联网上流量占比最大的算法。


六、BBR(Google, 2016)

6.1 对「丢包 = 拥塞」的根本质疑

所有基于丢包的算法(Reno、CUBIC)都建立在一个假设上:丢包意味着拥塞

⚠️ 这个假设有两个问题

问题 1:无线链路的误码也会丢包

TCP 会把无线误码误判为拥塞,无谓地降速。这是移动网络性能差的重要原因(第 25 讲)。

问题 2:缓冲区膨胀(Bufferbloat)

现代路由器的缓冲区很大(因为内存便宜)。基于丢包的 TCP 会一直增长窗口直到把缓冲区填满才收到信号。

    队列
     ↓
 ─────[████████████████████]─────→
      ⭐ 队列被填满,但还没丢包
      → TCP 继续加速
      → 排队时延飙升到几百毫秒甚至几秒
      → 但吞吐量【一点没增加】(链路早就满了)

症状:一边下载大文件,一边打游戏就卡到不能玩。ping 值从 20 ms 涨到 2000 ms。

核心洞察:填满缓冲区不会提高吞吐量,只会增加时延。

6.2 BBR 的模型

BBR 不看丢包,而是主动测量两个量

BtlBw  (Bottleneck Bandwidth)  = 观测到的最大交付速率
RTprop (Round-trip propagation time) = 观测到的最小 RTT

最优工作点

在途数据量 = BtlBw × RTprop = BDP

恰好填满管道,但不填缓冲区。

这个点在理论上是「既跑满带宽,又不产生排队时延」的唯一点(Kleinrock, 1979 就证明了它的最优性,但当时认为无法分布式地达到)。

6.3 BBR 的四个状态

STARTUP   → 指数增长,快速找到 BtlBw
DRAIN     → ⭐ 主动排空 STARTUP 期间产生的队列
PROBE_BW  → 循环:周期性地上探 1.25× 和下探 0.75×,持续校准
PROBE_RTT → 每 10 秒把窗口降到 4 个包,重新测量真实的 RTprop

6.4 效果与争议

✅ 优点 ⚠️ 争议
在有丢包的链路上吞吐量远高于 CUBIC 与 CUBIC 竞争时可能过于激进,抢占更多带宽
显著降低排队时延(缓解 bufferbloat) 在浅缓冲区场景下可能造成较高丢包
不依赖丢包信号,对无线友好 v1 的公平性问题明显,BBRv2/v3 已大幅改进

📌 Google 在 YouTube 和 Google.com 上大规模部署了 BBR,报告的吞吐量提升在部分地区达到 数倍,同时中位 RTT 显著下降。


七、ECN 与主动队列管理

7.1 ECN(显式拥塞通知)

思想:让路由器在队列开始增长但还没满的时候,就标记分组,而不是等到丢包。

IP 头部的 2 个 ECN 位:
   00 = 不支持 ECN
   10 / 01 = 支持 ECN(ECT)
   ⭐ 11 = CE(Congestion Experienced,路由器标记)

流程:
① 发送方把 IP 头标记为 ECT
② 路由器队列增长 → 把 ECT 改为 CE(⭐ 不丢包!)
③ 接收方看到 CE → 在 TCP 头部设置 ECE 标志回传
④ 发送方看到 ECE → 减小 cwnd(如同丢包),并设置 CWR 标志回应

⭐ **收益:在不丢一个包的前提下完成了拥塞信号的传递。**避免了重传的浪费和恢复的时延。

部署情况:现代操作系统普遍支持但默认多为被动模式(对方要求才启用)。数据中心内部(DCTCP)广泛使用,公网上因中间设备兼容问题推进缓慢——又一个协议僵化的例子(第 6 讲)。

7.2 主动队列管理(AQM)

传统路由器用尾丢弃(drop-tail):队列满了就丢新来的。

⚠️ 尾丢弃的两个问题

  1. 全局同步:队列满的瞬间,多条 TCP 连接同时丢包、同时减半、同时重新增长 → 链路利用率周期性坍塌
  2. 偏袒突发流,且缓冲区长期处于满状态(bufferbloat)

AQM 算法

算法 思想
RED(Random Early Detection) 队列超过阈值就按概率随机丢/标记包,打散同步
CoDel(Controlled Delay) ⭐ 不看队列长度,看分组在队列中停留的时间,超过 5 ms 就开始丢
FQ-CoDel CoDel + 按流公平排队,防止一条大流饿死交互式小流

📌 CoDel 的洞察很深刻:队列长度不是问题,队列中的驻留时间才是问题。一个瞬间的大队列(吸收突发)是健康的,一个持续存在的队列才是 bufferbloat。FQ-CoDel 是今天家用路由器上对抗 bufferbloat 最有效的手段。


八、例题(Worked Example)

题目:某 TCP Reno 连接的 cwnd 演化如下(单位:MSS),初始 ssthresh = 8。

传输轮次:  1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
cwnd:      1   2   4   8   9  10  11  12   1   2   4   8   9  10  11

(a) 第 1–4 轮处于什么阶段? (b) 第 5–8 轮处于什么阶段?为什么第 5 轮从 8 变成 9 而不是 16? (c) 第 8 轮之后发生了什么事件?如何判断? (d) 该事件后 ssthresh 变成多少? (e) 若第 8 轮之后发生的是「3 个重复 ACK」而非超时,第 9 轮的 cwnd 会是多少? (f) 第 13 轮的 cwnd 为什么是 9?

解答

(a) 慢启动。cwnd 每轮翻倍(1→2→4→8),且 cwnd < ssthresh。

(b) 拥塞避免。第 4 轮末 cwnd 达到 ssthresh = 8,因此退出慢启动,改为每个 RTT 加 1 MSS:8→9→10→11→12。

(c) 发生了超时。判断依据:cwnd 直接跌到 1。若是 3 个重复 ACK,cwnd 会跌到 ssthresh + 3 而不是 1。

(d) 超时发生时 cwnd = 12,因此:

ssthresh = 12 / 2 = 6
cwnd = 1

(e) 若是 3 个重复 ACK:

ssthresh = 12 / 2 = 6
cwnd = ssthresh + 3 = 9      ← 进入快速恢复

(f) 第 9–12 轮:cwnd 从 1 开始慢启动(1→2→4→8)。第 12 轮 cwnd = 8,已经超过新的 ssthresh = 6。

⚠️ 严格来说,cwnd 在达到 ssthresh=6 时就应转入拥塞避免。按题给数据(1→2→4→8),实现是在每轮结束时判断,因此第 11 轮 cwnd=4 < 6 仍翻倍到 8,第 12 轮起进入拥塞避免,之后每轮 +1:8→9→10→11。所以第 13 轮为 9

📌 考试提示:这类题的判断口诀是——

cwnd 跌到 1        → 超时
cwnd 跌到约一半     → 3 个重复 ACK
cwnd 翻倍          → 慢启动
cwnd 每轮 +1       → 拥塞避免

九、随堂自测

  1. 慢启动为什么叫「慢」?它实际上是什么增长?
  2. 超时和 3 个重复 ACK 的响应为什么不同?分别是什么?
  3. 写出 TCP 吞吐量与丢包率的关系式。它揭示了哪两个问题?
  4. 计算:40 Gbps、RTT 50 ms、MSS 1500 字节,Reno 要跑满需要多低的丢包率?
  5. CUBIC 相对 Reno 的两个关键改进是什么?
  6. 什么是缓冲区膨胀?为什么基于丢包的算法必然导致它?
  7. BBR 测量哪两个量?它的最优工作点是什么?
  8. ECN 相比丢包信号的优势是什么?为什么公网部署缓慢?
  9. CoDel 与 RED 的根本区别是什么?

十、本讲要点回顾

  • 速率 ≈ cwnd / RTT;实际窗口 = min(cwnd, rwnd)
  • 慢启动(每 RTT ×2)→ 达到 ssthresh → 拥塞避免(每 RTT +1)。
  • 超时 → cwnd=1(重);3 个重复 ACK → cwnd=ssthresh+3(轻)。这是 Reno 相对 Tahoe 的核心改进。
  • 吞吐量 ≈ 1.22·MSS / (RTT·√L) → RTT 不公平 + 高速链路需要不可能低的丢包率。
  • CUBIC:cwnd 是绝对时间的三次函数 → RTT 公平、快速回升、减小因子 0.7。
  • BBR:不看丢包,测量 BtlBw 与 RTprop,工作在 BDP 点——跑满带宽但不填缓冲区
  • ECN:路由器在丢包前标记分组,无损地传递拥塞信号。
  • Bufferbloat:大缓冲区 + 基于丢包的 TCP = 巨大排队时延。FQ-CoDel 是有效解药

十一、自测答案

1. 「慢」是相对于 1988 年之前「连接一建立就用满整个通告窗口猛发」的做法而言的——慢启动从 1 MSS 开始,相比之下起步很慢。但它本身是指数增长(每个 RTT 翻倍),因为初始窗口离可用带宽可能相差几万倍,线性增长需要几万个 RTT 才能填满管道。

2. 超时意味着连续多个报文段都没到达,网络可能严重拥塞或路径中断,任何乐观假设都危险,因此 ssthresh = cwnd/2, cwnd = 1,回到慢启动。3 个重复 ACK 意味着后续报文段仍在正常到达,网络是通的、只丢了一个,因此只需 ssthresh = cwnd/2, cwnd = ssthresh + 3,进入快速恢复。信号的严重程度不同,响应的力度就应当不同。

3. 吞吐量 ≈ 1.22 × MSS / (RTT × √L)。揭示的两个问题:① 吞吐量反比于 RTT → 短 RTT 连接系统性地占据更多带宽(RTT 不公平);② 吞吐量反比于丢包率的平方根 → 要达到高吞吐量,需要极低的丢包率,这在高速长距链路上不可实现。

4.

所需 cwnd W = 40×10⁹ × 0.05 / (1500×8) = 2×10⁹ / 12000 ≈ 166,667 个报文段
L ≈ 8 / (3W²) = 8 / (3 × 2.78×10¹⁰) ≈ 9.6 × 10⁻¹¹

一千亿分之一——完全不可能。这正是 Reno 在 40G 链路上必须被替换的原因。

5.cwnd 是距上次丢包的绝对时间的三次函数,而不是「每个 RTT 加一次」,因此消除了 RTT 不公平,并且能在丢包后快速回升到接近 W_max、随后在 W_max 附近平台期谨慎试探。② 减小因子从 0.5 改为 0.7,因为在高速链路上砍掉一半窗口的吞吐量损失过大、恢复过慢。

6. 缓冲区膨胀指路由器/调制解调器配置了过大的缓冲区,导致排队时延达到数百毫秒甚至数秒。基于丢包的算法必然导致它,因为它们的唯一拥塞信号就是丢包——在缓冲区被完全填满之前不会有任何信号,所以 TCP 会持续增长 cwnd 直到把缓冲区填满。而缓冲区中排队的数据不增加任何吞吐量(链路早已饱和),只增加时延。

7. 测量 BtlBw(瓶颈带宽,观测到的最大交付速率)与 RTprop(往返传播时延,观测到的最小 RTT)。最优工作点是在途数据量 = BtlBw × RTprop = BDP——此时恰好填满管道(跑满带宽)但不在缓冲区中留下排队(不增加时延)。

8. 优势是在不丢弃任何分组的前提下传递拥塞信号:避免了重传消耗的带宽(第 15 讲的代价 2)、避免了丢包恢复的时延,并且信号可以在队列开始增长时就发出(更早)。公网部署缓慢的原因是协议僵化:ECN 使用 IP 头部原先保留的比特,部分老旧的中间设备(防火墙、NAT、负载均衡)会丢弃或错误处理带 ECN 标记的分组,导致连接失败,因此各方都不敢默认开启。

9. RED 看队列长度:当平均队列长度超过阈值时按概率丢包。问题是「多长算长」高度依赖链路速率和流量特征,参数极难调对。CoDel 看驻留时间:测量每个分组在队列中实际停留了多久,只有当持续超过目标值(5 ms)时才开始丢弃。这个指标与链路速率无关、无需调参,并且能正确区分「健康的瞬时突发队列」和「有害的持久队列」——前者驻留时间短,后者驻留时间长。