跳转至

20. 丢包即信号:Reno 与 AIMD 的经典控制

第 19 章建立了拥塞控制的坐标系——RTT 测量管道拥挤程度,cwnd 和 bytes_in_flight 构成发送闸门,ACK 同时充当可靠性确认和速率反馈。但那只是测量和表示。发送方到底该怎么决定"现在放多少、丢了怎么办"?

这个问题在 1980 年代末被第一次系统性地回答。答案出奇地简单:没丢包就慢慢加,丢了就砍一半。这条规则叫做 AIMD(Additive Increase / Multiplicative Decrease),Reno 是它最完整的工程化身。本章只讲 Reno 的四个核心机制——慢启动、拥塞避免、快重传、快恢复——以及它在什么条件下开始力不从心。不涉及 Cubic,不涉及 BBR,不涉及 QUIC 实现细节。读完这一章,手里应该有一把可靠的基线标尺。


20.1 加性增、乘性减:AIMD 为什么是稳定的

拥塞控制面对的根本困境是:多个发送方共享一条管道,没有中央调度器告诉每个人该发多少。每个发送方只能靠自己观察到的信号——丢包或时延变化——来猜测管道的剩余容量。

直觉上,最朴素的策略是"对称增减"——没丢就加 1,丢了就减 1。但这种策略有一个致命问题:它不会收敛到公平。假设两个流分别占用 70% 和 30% 的带宽,对称地加减之后,差距不会缩小——两者始终保持同样的绝对差距。

1989 年 Chiu 和 Jain 的分析给出了一个关键洞察:加性增 + 乘性减(AIMD)能让多个竞争流趋向公平分配。直觉可以用一张"公平性向量图"来理解:

  流 B 的 cwnd
    │         ╱ 公平线(45°)
    │       ╱
    │     ╱    ②
    │   ╱  ↗ ↗  乘性减后的新位置
    │ ╱↗ ↗
    │╱        ① 加性增:沿 45° 平行方向增长
    └─────────────────────→ 流 A 的 cwnd

  ① 加性增(+1, +1):两个流各加相同量,轨迹平行于公平线移动
  ② 乘性减(×0.5, ×0.5):丢包时各自减半,轨迹向原点收缩
     → 连续的"增→减→增→减"使轨迹螺旋逼近公平线

关键在于不对称性。加法操作让两个流以相同的绝对量增长,差距不变;但乘法操作让占用多的流减得也多——原来占 70% 的流减半后只剩 35%,占 30% 的流减半后剩 15%。减完之后差距缩小了。反复经过"加性增 → 乘性减"的循环,所有流最终收敛到公平线附近振荡。

这就是 Reno 整套机制的数学基石。慢启动、拥塞避免、快恢复都建立在这个基本规则之上。RFC 5681 将其标准化为 TCP 拥塞控制的核心框架。

AIMD 有一个隐含假设:丢包 = 拥塞。在 1990 年代的有线互联网上,这个等号基本成立——链路错误率极低,丢包几乎总是路由器队列溢出造成的。但在无线网络中,信号衰减和干扰也会导致丢包,这些丢包和拥塞无关。把它们当作拥塞信号会导致不必要的减速。这个假设的适用边界,将在本章末尾成为 Reno 局限性的一部分。


20.2 慢启动:从零开始的指数探索

连接刚建立时,发送方对网络容量一无所知。cwnd 的初始值只有大约 10 MSS(第 19 章已提到 RFC 9002 §7.2 的规定),而管道的真实容量可能是几百甚至几千个 MSS。如何从这个起点尽快找到管道容量?

线性增长太慢。假设管道容量是 1000 MSS,每个 RTT 加 1 MSS,需要 990 个 RTT 才能填满管道。如果 RTT 是 100ms,那就是 99 秒——近两分钟白白浪费在"试探"上。直接满速又太鲁莽,可能瞬间灌爆管道。

慢启动(Slow Start)的解决方案是指数增长:每收到一个 ACK,cwnd 增加一个 MSS。由于一个 cwnd 大小的数据在一个 RTT 内全部被确认,cwnd 实际上每个 RTT 翻倍。

RTT 1:  cwnd = 10 MSS  → 发 10 个包 → 收到 10 个 ACK → cwnd += 10
RTT 2:  cwnd = 20 MSS  → 发 20 个包 → 收到 20 个 ACK → cwnd += 20
RTT 3:  cwnd = 40 MSS  → 发 40 个包 → ...
RTT 4:  cwnd = 80 MSS
RTT 5:  cwnd = 160 MSS
RTT 6:  cwnd = 320 MSS
RTT 7:  cwnd = 640 MSS  ← 7 个 RTT 就逼近 1000 MSS 的管道容量

"慢启动"这个名字容易误导——它的"慢"是相对于"直接满速发送"说的,实际上它是指数级增长,非常激进。

慢启动什么时候结束?有两种触发条件。第一,cwnd 增长到 ssthresh(慢启动阈值)时,切换到拥塞避免阶段的线性增长。ssthresh 的初始值通常设为无穷大(意味着第一次慢启动不受限制,直到丢包才停下来)。第二,检测到丢包时,ssthresh 被设为当前 cwnd 的一半,然后进入恢复流程。

慢启动天然有一个过冲风险。指数增长的最后一步可能让 cwnd 从管道容量的一半直接翻倍到两倍——瞬间超出管道容量,导致大面积丢包。在高带宽链路上,这个过冲尤其严重:如果管道容量是 5000 MSS,最后一步从 2500 翻到 5000,多出来的 2500 个包全部涌入队列。这个问题将在第 21 章引出 Cubic 的 HyStart 机制。


20.3 拥塞避免:线性增长的保守哲学

慢启动越过 ssthresh 后,发送方进入拥塞避免(Congestion Avoidance)阶段。此时发送方已经大致知道管道在哪个量级,继续指数增长太冒险,策略切换为线性试探:每个 RTT 窗口增加约 1 个 MSS。

RFC 5681 §3.1 规定的拥塞避免增长公式是:每收到一个 ACK,cwnd 增加 MSS × MSS / cwnd 字节。这个公式的效果是——一个 RTT 内所有 ACK 累计起来,cwnd 大约增加 1 个 MSS。

cwnd = 100 MSS 时,每个 ACK 增加约 1460²/146000 ≈ 14.6 字节
一个 RTT 内约 100 个 ACK,累计增加 ≈ 1460 字节 ≈ 1 MSS

为什么不是 2 个 MSS、不是按比例增长?这是一种刻意的保守。在不确定管道是否还有余量时,谨慎比激进更安全。每个 RTT 只多试探一个包的量,即使猜错了(管道其实已满),造成的冲击也很小——最多只多出一个包进入队列。

这种保守性在低带宽短时延的网络里工作得很好。一条 10 Mbps、10ms RTT 的链路,BDP(带宽时延积)约 12.5 KB ≈ 8.5 MSS。丢包后 cwnd 从 8 减到 4,4 个 RTT(40ms)就恢复满速。用户几乎感知不到波动。

但在高带宽长时延的网络里,情况截然不同。这个问题留到 20.5 节用具体数字展开。


20.4 快重传与快恢复:不等超时就行动

Reno 之前的 TCP Tahoe 只有一种应对丢包的手段:等待重传超时(RTO)。超时后把 cwnd 归零,ssthresh 设为当前 cwnd 的一半,从慢启动重新来过。

超时等待的代价是巨大的。RTO 通常远大于 RTT——RFC 5681 建议 RTO 至少 1 秒。在 RTT 只有 50ms 的链路上,一次超时意味着浪费了 20 个 RTT 的发送机会。更糟的是,超时后 cwnd 归零,相当于连接"失忆",要从头开始慢启动。

Reno 引入了两个关键改进:快重传(Fast Retransmit)和快恢复(Fast Recovery)。

快重传的逻辑很直观。当接收端收到一个乱序的包时,它会立即发送一个重复 ACK(Duplicate ACK),指向它期望的下一个序号。如果发送端连续收到 3 个重复 ACK,说明某个包大概率已经丢失——后面的包都到了,只有那个包没到。不等超时,立即重传。

发送端                                  接收端
  │                                        │
  ├─ PKT 1 ─────────────────────────→      │  收到 PKT 1, ACK 2
  ├─ PKT 2 ────────── ✕ (丢失)            │
  ├─ PKT 3 ─────────────────────────→      │  收到 PKT 3, 乱序 → 重复 ACK 2
  ├─ PKT 4 ─────────────────────────→      │  收到 PKT 4, 乱序 → 重复 ACK 2
  ├─ PKT 5 ─────────────────────────→      │  收到 PKT 5, 乱序 → 重复 ACK 2
  │                                        │
  │  ← 3 个重复 ACK ──────────────────┤
  │                                        │
  ├─ 重传 PKT 2 ────────────────────→      │  收到 PKT 2, 缺口填上 → ACK 6

快恢复则在快重传的基础上更进一步:丢包后不把 cwnd 归零,而是减半。具体来说,ssthresh 设为当前 cwnd 的一半,cwnd 暂时设为 ssthresh + 3 MSS(3 个重复 ACK 说明有 3 个包已经离开网络),然后继续发送。收到新数据的 ACK 后,cwnd 回落到 ssthresh,进入拥塞避免阶段。

这是一次巨大的工程进步。Tahoe 的"超时 → 归零 → 慢启动"相当于连接每次丢包都从零重来;Reno 的"快重传 → 减半 → 继续"则保留了连接对管道容量的记忆——它知道管道大约能装多少,只是退让一半再慢慢试探回来。恢复时间从秒级降到了毫秒级。

但 Reno 的快恢复有一个已知局限:它一次只能处理一个包丢失的场景。如果同一个窗口内丢了多个包,第一个丢包触发快恢复、cwnd 减半后,第二个丢包的重复 ACK 会再次触发减半——连续多次减半让 cwnd 迅速萎缩,性能急剧下降。

NewReno(RFC 6582)通过区分"部分确认"(Partial ACK)改进了这个问题:如果恢复期间收到的 ACK 只确认了部分数据(说明还有更多丢包),不退出恢复状态,而是继续重传。这让 Reno 在一个窗口内多包丢失时也能正常工作,但基本的 AIMD 框架没有改变。


20.5 Reno 的边界:高 BDP 网络下的恢复困境

前面几节展示了 Reno 在低 BDP 网络上的优雅——慢启动快速找到容量,拥塞避免谨慎守住,快恢复迅速应对丢包。但当管道变得又长又粗时,Reno 的核心机制开始暴露结构性问题。

BDP(Bandwidth-Delay Product,带宽时延积)是管道容量的度量:带宽 × RTT = 管道一个来回能容纳的数据量。来看一个具体场景。

一条 1 Gbps 链路,RTT 100ms——这在跨大洲传输中很常见。管道容量:

BDP = 1 Gbps × 100ms = 100 Mbit = 12.5 MB ≈ 8562 个 MSS(以 1460 字节计)

发送方的 cwnd 需要达到 8562 MSS 才能填满管道。现在假设发生了一次丢包,Reno 进入快恢复:

丢包前:cwnd = 8562 MSS
快恢复:cwnd 减半 → 4281 MSS,ssthresh = 4281 MSS
拥塞避免:每个 RTT 增加 1 MSS
恢复到满窗口:4281 个 RTT × 100ms/RTT = 428 秒 ≈ 7.1 分钟

七分钟。在这七分钟里,链路的利用率最多只有一半——大量带宽被白白浪费。

更极端的场景:10 Gbps 链路、200ms RTT(如中美之间的长距离传输):

BDP = 10 Gbps × 200ms = 2 Gbit = 250 MB ≈ 171233 MSS
恢复时间:85616 RTT × 200ms = 17123 秒 ≈ 4.75 小时

近五个小时才能恢复满速。在一条花了大价钱租来的 10G 专线上,这意味着绝大部分时间都在"爬坡"而不是"跑满"。

从吞吐量角度看这个问题更直接。在给定丢包率 p 下,Reno 的稳态吞吐大约正比于 1/√p。具体来说(Mathis 等人 1997 年的经典公式):

吞吐量 ≈ (MSS / RTT) × (C / √p)

其中 C ≈ 1.22

要在 1 Gbps 链路上跑满带宽,需要的丢包率不能超过大约 2 × 10⁻¹⁰——也就是每发送 50 亿个包才允许丢 1 个。这在任何真实网络中都是不可能的。

这个困境不是实现 bug,不是参数没调好,而是 AIMD 线性增长的结构性限制。"每 RTT 加 1 MSS"这个规则在管道容量只有几十个 MSS 时绰绰有余,但当管道容量达到几千甚至几万个 MSS 时,线性增长就像用汤匙往游泳池里加水。

问题的根源可以归结为一句话:Reno 的恢复速度与管道容量无关——不管管道多大,每 RTT 都只恢复 1 MSS。管道越大,恢复时间就越长,两者成正比。


20.6 一个时代的基线

Reno 不只是"第一代拥塞控制算法"。它定义了拥塞控制的基本词汇——慢启动、拥塞避免、快重传、快恢复——后续所有算法都在这套词汇框架下对话。它验证了 AIMD 的收敛性和公平性,证明了去中心化的速率控制在全球尺度的网络上是可行的。它在 1990 年代到 2000 年代初的互联网上运行了十几年,支撑了从拨号到宽带的整个过渡期。

它的力量来自简洁。四个机制、一套增减规则、一个假设(丢包 = 拥塞),就构成了完整的控制闭环。在低 BDP 的局域网和城域网中,这套机制至今仍然工作得足够好。

它的局限同样清晰。"丢包 = 拥塞"的假设在两种场景下失效:无线网络中信号衰减导致的随机丢包会被误判为拥塞,造成不必要的减速;深缓冲区链路上,Reno 要把缓冲区填满直到丢包才知道减速,而此时排队时延已经很高——这就是后来被称为"bufferbloat"的问题。线性恢复在高 BDP 网络下的结构性缓慢,更是让它在宽带时代逐渐力不从心。

这些局限不是批评,而是坐标。理解 Reno 在什么条件下足够好、在什么条件下撑不住,是理解后续所有拥塞控制演进的前提。Cubic 的三次函数增长直接对准了线性恢复的死穴;BBR 则更激进——它连"丢包是信号"这个前提都不接受了,转而去建模管道本身。每一步演进,都是在回应 Reno 的某个具体局限。

如果线性恢复是 Reno 在高 BDP 网络的结构性瓶颈,那能不能用一条更聪明的增长曲线来替代?这条曲线应该在窗口远低于上次丢包点时快速攀升,接近丢包点时谨慎放缓,超过后再次加速探索。这个想法,正是下一章的起点。