TCP 流量控制与拥塞控制:发送速度的两道缰绳
本文是《计算机网络学习笔记》系列的第四篇。TCP 知道数据怎么可靠地传过去(见第三篇),但还有一个同样重要的问题没解决:应该以多快的速度发?发太快,会压垮接收方的缓冲区,也会让整个网络陷入拥堵。为此,TCP 设计了两套独立的"限速"机制——流量控制和拥塞控制,分别对付这两种不同性质的"堵"。
一眼看清两个概念
这两个概念很容易混淆,先把区别说清楚:
| 流量控制 | 拥塞控制 | |
|---|---|---|
| 保护对象 | 接收方(防止缓冲区溢出) | 网络(防止路由器队列溢出) |
| 发现问题的方式 | 接收方主动通告 | 发送方被动感知(丢包 / 延迟增大) |
| 控制变量 | 接收窗口 rwnd | 拥塞窗口 cwnd |
| 决定权 | 接收方说了算 | 发送方自己调整 |
发送方实际能发出的数据量,受两者中更小的那个约束:
实际发送窗口 = min(rwnd, cwnd)一、流量控制:别把接收方淹了
基本机制
场景:A 向 B 发送一个大文件。B 的应用层可能来不及读,数据就会积压在 B 的接收缓冲区里。如果 A 一直猛发,缓冲区迟早会满,后来的包只能被丢弃——这不仅浪费了带宽,还会触发重传,形成恶性循环。
TCP 的解法:让 B 实时告诉 A 自己还有多少空间。
B 的接收缓冲区(假设总大小 4KB):
[已读走] [已交付应用][还在缓冲区里的数据] [剩余空间 rwnd]
←————————————————4KB————————————————→B 在每一个回复报文的 Window 字段里,填入当前剩余的缓冲区大小(rwnd)。A 看到后,保证在途的未确认数据量不超过 rwnd。
这个字段随着 B 的应用层读取数据而动态变化,A 的发送速率也跟着实时调整。
Window Update:不等数据,专门通知窗口变大
当 B 的应用层读走了一批数据、缓冲区腾出了空间,但 B 此时没有数据要发给 A,怎么告知 A?
B 会主动发出一个纯 ACK 包,仅用于通知 A 窗口大小已经变了,不携带任何数据。这种专门用于更新窗口的包,在 Wireshark 里可以看到被标注为 Window Update。
零窗口与死锁:一个特殊的边界情况
如果 B 的应用层长时间不读数据,缓冲区会被填满,此时 rwnd = 0。A 收到后只能停止发送,等待。
问题来了:B 后来腾出了空间,会发一个 Window Update 通知 A——但如果这个 Window Update 在网络中丢失了,双方就陷入了死锁:A 等 B 通知,B 等 A 来发,两边都傻等。
TCP 的破局方法:A 在 rwnd = 0 期间,会定时发送一个只包含 1 个字节的零窗口探测包(Zero Window Probe)。B 收到后,无论如何都必须回复 ACK,并在 ACK 里带上最新的窗口大小。这样就打破了僵局。
A: 数据 ————> B(rwnd=0,停!)
A: [1 字节探测] ——————> B
B: ——> ACK(rwnd=2048,空出来了!)
A: 继续发送 ———————————> B二、拥塞控制:别把网络堵死
流量控制只保护接收方,但网络中间的路由器也有队列,同样有被打爆的风险。当大量发送方同时往同一条链路狂发数据,路由器的队列溢出,就开始大量丢包,这就是网络拥塞。
拥塞一旦发生,人人都在重传,重传又加剧拥塞,最终整个网络陷入瘫痪——这就是著名的拥塞崩溃(Congestion Collapse),1986 年真实发生过,互联网性能暴跌至正常水平的 1/1000。
TCP 的拥塞控制,就是为了阻止这件事。
两种"堵"的程度
TCP 通过丢包来感知拥塞,但丢包的严重程度不同,反应也不同:
| 类型 | 触发条件 | 含义 |
|---|---|---|
| 小堵 | 收到 3 个重复 ACK | 某个包丢了,但后续包还在到达,网络没完全崩 |
| 大堵 | 超时重传计时器到期 | 连 ACK 都收不到了,网络可能严重拥塞 |
两个控制变量
cwnd(拥塞窗口):发送方自己维护的发送速率上限,单位为 MSS(最大报文段长度);ssthresh(慢启动阈值):记录上一次发生拥塞时的"安全水位线",是速度切换的分界点。
ssthresh 不是"天花板",而是"变速点":cwnd 低于它时大胆加速,高于它时谨慎探路。
三、拥塞控制算法的进化史
Tahoe:第一个版本(1988)
策略:遇到任何拥塞(无论大堵还是小堵),直接把 cwnd 重置为 1,从头慢启动。
这个版本过于激进——即使只是"小堵"(3 个重复 ACK),也要从 1 重来,带宽利用率极低。
Reno:经典四阶段(1990)
Reno 区分了大堵和小堵,是 TCP 拥塞控制的奠基版本,理解它是理解一切变体的基础。

阶段一:慢启动(Slow Start)
名字叫"慢",实际上是指数增长。"慢"是相对于"直接跑满带宽"而言的。
- 初始
cwnd = 1 MSS; - 每收到一个 ACK,
cwnd += 1 MSS; - 每经过一个 RTT,cwnd 翻倍:1 → 2 → 4 → 8 → …
RTT 1: cwnd=1,发 1 个包,收 1 个 ACK → cwnd=2
RTT 2: cwnd=2,发 2 个包,收 2 个 ACK → cwnd=4
RTT 3: cwnd=4,发 4 个包,收 4 个 ACK → cwnd=8
...退出慢启动的条件:cwnd ≥ ssthresh,进入下一阶段。
阶段二:拥塞避免(Congestion Avoidance)
慢启动期间,一旦 cwnd 攀升到 ssthresh,改为线性增长,小心探路:
- 每收到一个 ACK,
cwnd += MSS × (MSS / cwnd)(约等于每个 RTT 增加 1 MSS)
RTT n: cwnd = ssthresh,+1 MSS
RTT n+1: cwnd = ssthresh + 1,+1 MSS
RTT n+2: cwnd = ssthresh + 2,+1 MSS
...直到触发拥塞。
阶段三:快速重传(Fast Retransmit)
收到 3 个重复 ACK,立即重传丢失的包,不等超时计时器。(详见第三篇)
阶段四:快速恢复(Fast Recovery)
Reno 与 Tahoe 的核心区别就在这里:收到 3 个重复 ACK 时(小堵),Reno 不归零,而是:
ssthresh = cwnd / 2 (记住上次的安全水位)
cwnd = ssthresh + 3 (减半,但补偿已收到的 3 个 ACK)然后直接从新的 ssthresh 开始拥塞避免阶段,不需要重新慢启动。
而若是超时(大堵):
ssthresh = cwnd / 2
cwnd = 1 (退回起点,重新慢启动)这就是著名的 AIMD(Additive Increase, Multiplicative Decrease,加性增乘性减) 策略,cwnd 的变化会呈现"锯齿"形态:

加性增(每 RTT +1 MSS)慢慢探路,乘性减(×1/2)快速后退,在公平性和效率之间取得了比较好的平衡。
Reno 完整状态转移图:
┌───────────────────────────────────┐
│ 慢启动 │
│ cwnd 指数增长(每 RTT 翻倍) │
└──────────────┬────────────────────┘
│ cwnd ≥ ssthresh
▼
┌───────────────────────────────────┐
│ 拥塞避免 │
│ cwnd 线性增长(每 RTT +1 MSS) │
└──────────┬────────────┬───────────┘
│ 超时 │ 3 个重复 ACK
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ ssthresh=cwnd│ │ ssthresh=cwnd/2 │
│ cwnd ← 1 │ │ cwnd=ssthresh+3 │
│ 回到慢启动 │ │ → 快速恢复 │
└──────────────┘ └──────────────────┘Cubic:Linux 现代默认(2008)
Reno 的问题在于拥塞后"减半再慢慢线性爬回来"的速度太保守——在高带宽、高延迟的网络(如跨洲际光纤,BDP 很大)上,爬回来的过程会浪费大量带宽。
Cubic 的改进:用三次函数 W(t) = C(t - K)³ + Wmax 来描述窗口的增长过程。

三次函数的特性,让 cwnd 的增长规律如下:
- 刚从拥塞恢复:函数的曲线上升较快,迅速追回丢失的带宽;
- 接近历史峰值 Wmax:曲线趋于平缓,小心探路;
- 超过 Wmax 无丢包:说明网络变好了,继续缓慢向上探索新峰值。
Cubic 完全基于时间(而非 RTT 计数)来控制窗口增长,这使得它在高 RTT 的长肥管道(Long Fat Network)上明显优于 Reno。
BBR:基于模型的新思路(Google, 2016)
Reno 和 Cubic 的核心逻辑都是"以丢包作为拥塞信号"——但丢包代价很高,而且路由器缓冲区(Buffer)越来越大,包可能在被丢之前已经堆积了大量延迟(这就是 Bufferbloat 问题)。
BBR(Bottleneck Bandwidth and Round-trip propagation time) 彻底改变了思路:不等丢包,而是实时测量带宽和最小延迟,主动计算最优发送速率。
BBR 的两个核心测量
测量带宽(BtlBw,瓶颈带宽):
BBR 在发送过程中,周期性地短暂提速(cwnd × 1.25),观察 RTT 有没有变大:
- RTT 没变大 → 带宽有空余,按新速度发;
- RTT 变大了 → 带宽已满,立即降速(cwnd × 0.75),把刚才多推进队列的包"排空"。
通过这种探测,BBR 持续跟踪当前网络的可用带宽。
测量最小 RTT(RTprop,传播延迟):
每隔 10 分钟,BBR 会故意把发送速率降到极低,保持约 200ms。这段时间内,链路几乎是空的,测到的 RTT 就是去掉排队延迟后的纯物理传播时延,即信号在网线上跑一个来回的真实时间。
BBR vs Reno/Cubic
| Reno / Cubic | BBR | |
|---|---|---|
| 拥塞信号 | 丢包(被动) | 带宽 + 延迟(主动测量) |
| 在高丢包网络(如 WiFi) | 频繁降速,性能差 | 能区分拥塞丢包和随机丢包 |
| Bufferbloat | 把 Buffer 填满拉高延迟 | 主动控制在适当队列深度 |
| 使用场景 | 绝大多数场景默认可用 | 长距离、高延迟、卫星链路等效果更显著 |
BBR 目前在 Linux 5.x+ 内核中已内置(需手动开启:sysctl -w net.ipv4.tcp_congestion_control=bbr),Google 在内部的 YouTube、Google.com 等服务上早已全面部署。四个算法的横向对比
| 算法 | 丢包后的反应 | 窗口增长方式 | 适用场景 |
|---|---|---|---|
| Tahoe | 无论大小堵,cwnd 归 1 | 慢启动指数 + 拥塞避免线性 | 历史遗留,几乎不用 |
| Reno | 小堵减半,大堵归 1 | 慢启动指数 + 拥塞避免线性 | 经典,作为理论基准 |
| Cubic | 小堵减半,大堵归 1 | 三次函数快速恢复 | Linux 默认,高带宽首选 |
| BBR | 不以丢包为信号 | 基于实测带宽动态调整 | 长肥管道、高延迟、卫星等 |
总结
TCP 的"发送速度问题",被拆分成了两个独立维度:
- 流量控制(
rwnd):接收方说"我能消化多少",发送方不得超过; - 拥塞控制(
cwnd):发送方自己探测"网络能承受多少",主动收敛。
两者同时生效,最终的发送速度取决于 min(rwnd, cwnd)。
从 Tahoe 到 Reno,再到 Cubic 和 BBR,TCP 拥塞控制的演进历史,本质上是一场网络测量精度的提升:从粗暴地"丢包就减半",到精细地"用三次函数逼近极限",再到 BBR 的"直接测量带宽和延迟、主动规划最优速率"。每一代算法,都是对上一代局限性的定点突破。
本系列前三篇:
· 第一篇:《TCP 协议格式详解》
· 第二篇:《TCP 三次握手与四次挥手》
· 第三篇:《TCP 可靠数据传输》
参考资料:《计算机网络:自顶向下方法》