本文是《计算机网络学习笔记》系列的第四篇。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 拥塞控制的奠基版本,理解它是理解一切变体的基础。

2026-04-20T02:27:05.png

阶段一:慢启动(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 的变化会呈现"锯齿"形态:

2026-04-20T02:28:13.png

加性增(每 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 来描述窗口的增长过程。

2026-04-20T02:29:16.png

三次函数的特性,让 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 / CubicBBR
拥塞信号丢包(被动)带宽 + 延迟(主动测量)
在高丢包网络(如 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 可靠数据传输》


参考资料:《计算机网络:自顶向下方法》

标签: none

添加新评论