一、拥塞窗口 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/RTT → 这就是第 15 讲说的 RTT 不公平
- 吞吐量 ∝ 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 ┄┄┄┄┄┄┄┄╭─────╮┄┄┄┄┄┄┄┄
│ ╱ ╲
│ ╱ ⭐平台期 ╲
│ ╱ ╲___ 快速探测新带宽
│ ╱ 快速回升
│ ╱
└──────────────────────────────→ 时间
↑丢包
三个阶段:
- 快速回升:丢包后迅速涨回接近 W_max(不像 Reno 那样慢慢爬)
- 平台期:在 W_max 附近缓慢增长,谨慎试探
- 快速探测:确认 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):队列满了就丢新来的。
⚠️ 尾丢弃的两个问题:
- 全局同步:队列满的瞬间,多条 TCP 连接同时丢包、同时减半、同时重新增长 → 链路利用率周期性坍塌
- 偏袒突发流,且缓冲区长期处于满状态(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 → 拥塞避免
九、随堂自测
- 慢启动为什么叫「慢」?它实际上是什么增长?
- 超时和 3 个重复 ACK 的响应为什么不同?分别是什么?
- 写出 TCP 吞吐量与丢包率的关系式。它揭示了哪两个问题?
- 计算:40 Gbps、RTT 50 ms、MSS 1500 字节,Reno 要跑满需要多低的丢包率?
- CUBIC 相对 Reno 的两个关键改进是什么?
- 什么是缓冲区膨胀?为什么基于丢包的算法必然导致它?
- BBR 测量哪两个量?它的最优工作点是什么?
- ECN 相比丢包信号的优势是什么?为什么公网部署缓慢?
- 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)时才开始丢弃。这个指标与链路速率无关、无需调参,并且能正确区分「健康的瞬时突发队列」和「有害的持久队列」——前者驻留时间短,后者驻留时间长。