TCP 拥塞控制:慢开始、拥塞避免与快速恢复

上一篇我们说,实际发送窗口取接收窗口 rwnd 与拥塞窗口 cwnd 的较小值。rwnd 来自接收端(下一篇流量控制再讲),那 cwnd 又是从哪来、怎么变的?这一篇就专门拆这个 cwnd

拥塞控制和滑动窗口最大的不同在于:滑动窗口里发送端"知道"接收端的窗口(对方明确通告了),但网络能承受多少,没有任何人会告诉你。 路由器不会发一条消息说"我快堵了"。发送端只能像在黑暗中开车——靠"有没有撞到东西"(丢包)来反推路况,再据此调整车速。

所以这一篇的核心,是看清 cwnd 这个数字在一次连接的生命周期里,随着 ACK 不断到来、随着丢包偶尔发生,是怎么一步步涨上去、又怎么被打下来的。把这条 cwnd 变化曲线在脑子里画出来,慢开始、拥塞避免、快速恢复就全串起来了。

一、先说清"拥塞"到底是什么

把网络中间的路由器想成收费站,每个出口有个排队缓冲区。当多条连接同时涌入的流量超过出口链路的转发能力,缓冲区开始排队——延迟先上升(包要排队);缓冲区排满后,新来的包只能被丢弃——丢包出现

注意拥塞是个渐进过程,不是开关。它表现为"还能走,但越来越痛苦":先是延迟悄悄变大,然后才是丢包。而 TCP 主要靠"丢包"这个滞后信号来感知拥塞——等到丢包,其实队列早就排起来了。这个感知的滞后性,是理解后来各种改进算法的关键背景。

拥塞控制的目标,从来不是让一条连接独占最快速度,而是让共享同一条链路的成千上万条连接,在不把网络挤垮的前提下,公平且充分地利用带宽。它带着一点"集体主义"。

二、关键变量:cwnd 和 ssthresh

发送端为拥塞控制维护两个核心变量:

  • cwnd(拥塞窗口):发送端自己估计的"网络当前大概能容纳多少未确认数据"。单位上常用 MSS(最大报文段长度)的倍数来理解。
  • ssthresh(慢开始门限):一个阈值,用来切换"指数增长"和"线性增长"两个阶段。cwnd < ssthresh 用慢开始,cwnd ≥ ssthresh 用拥塞避免。

再强调上一篇的结论:真正能发的数据量是 min(cwnd, rwnd)。所以即便网络很空、cwnd 很大,也不能超过接收端给的 rwnd;反之亦然。这一篇只盯 cwnd

三、慢开始:cwnd 怎么指数级涨起来

连接刚建立时,发送端对网络承载一无所知。激进地一上来猛发,可能瞬间打爆某个队列;过于保守又浪费带宽。TCP 的策略是从小处试探、快速加速

慢开始的规则:

  1. 初始 cwnd 设为一个较小值(现代实现常用 10 个 MSS,早期是 1~4)。
  2. 每收到一个对新数据的 ACK,cwnd 增加 1 个 MSS。

看一下这条规则的实际效果。假设 cwnd=1:发 1 个段,收到 1 个 ACK,cwnd 变 2;发 2 个段,收到 2 个 ACK,各加 1,cwnd 变 4;发 4 个段,收到 4 个 ACK,cwnd 变 8……每经过一个 RTT,cwnd 翻一倍。这就是指数增长。

“慢开始"的"慢”,指的是起点低(从一两个段起步),而不是增速慢。它实际上涨得飞快——每个 RTT 翻倍,几个来回就能冲到很大。名字容易误导,记住"起点慢、爬升快"。

这样指数涨到什么时候为止?涨到 cwnd 达到 ssthresh,就切换到拥塞避免;或者中途发生丢包,立刻进入丢包处理(见第五节)。

四、拥塞避免:从翻倍改成每 RTT 加一

cwnd 爬到 ssthresh,说明已经接近上次出问题的水平了,再翻倍太冒险。于是切换到拥塞避免,把"指数增长"换成"线性增长":

每经过一个 RTT,cwnd 只增加 1 个 MSS(实现上是每收到一个 ACK,cwnd 增加 MSS/cwnd,一个 RTT 内所有 ACK 累加约等于加 1 个 MSS)。

直觉是:前期不知道天花板在哪,所以指数加速快速逼近;逼近危险区后,改成小心翼翼地一次加一格,慢慢试探真正的上限。这就是经典的 AIMD(加法增大) 中的"加法增大"部分。

把慢开始和拥塞避免连起来看 cwnd 曲线:先是一段陡峭的指数上扬,到 ssthresh 后拐弯,变成一条缓缓上升的斜线,持续往上探,直到撞到丢包。

五、丢包了怎么办:两种信号,两种反应

cwnd 不会无限涨,迟早会探到网络极限而丢包。TCP 对丢包的反应,取决于丢包是怎么被发现的——这是最容易记混的地方。

情况一:超时(RTO 触发)。 这是最严重的信号,说明连 3 个重复 ACK 都凑不齐,网络可能严重拥塞甚至中断。反应非常激进:

  • ssthresh 设为当前 cwnd 的一半;
  • cwnd 直接砸回初始值(如 1 个 MSS);
  • 重新进入慢开始

也就是说,超时会让发送端几乎从头来过。

情况二:3 个重复 ACK(快速重传触发)。 这个信号温和得多——既然还能收到重复 ACK,说明后续的包还在陆续到达,网络只是丢了个别段,并没有完全堵死。反应也就温和:

  • ssthresh 设为当前 cwnd 的一半;
  • cwnd 设为 ssthresh减半,而不是砸回 1);
  • 进入快速恢复,之后直接走拥塞避免的线性增长。

同样是丢包,超时砸回起点、重走慢开始;三个重复 ACK 只减半、走快速恢复。区别在于前者认为网络"可能彻底堵死了",后者认为网络"只是偶尔丢一两个,主体还通畅"。信号的严重程度,决定了退让的幅度。 这就是"乘法减小"——出问题时窗口乘以一个因子(这里是 0.5)骤降。

六、快速恢复:为什么不必退回起点

快速恢复是对"3 个重复 ACK"场景的专门优化,回答的是:“既然网络没堵死,凭什么要像超时那样砸回 1、再慢慢爬?那样太浪费了。”

它的逻辑:收到 3 个重复 ACK,触发快速重传补发丢失段后,发送端把 cwnd 减半到 ssthresh,然后直接进入拥塞避免的线性增长,不再经历慢开始那段指数爬升。这样窗口从"减半后的水平"平稳地继续往上探,而不是断崖式归零再重来。

效果体现在 cwnd 曲线上:经典 TCP(Reno)的曲线呈现一个标志性的锯齿形——慢开始的指数上扬、拥塞避免的线性爬升、撞到丢包后减半、再线性爬升、再减半……像锯齿一样在带宽上限附近反复试探。这条锯齿曲线,就是经典拥塞控制最该记住的画面。

stateDiagram-v2 [*] --> 慢开始 慢开始 --> 拥塞避免: cwnd 达到 ssthresh 慢开始 --> 慢开始: 超时(cwnd→初始, ssthresh减半) 拥塞避免 --> 慢开始: 超时(cwnd→初始, ssthresh减半) 拥塞避免 --> 快速恢复: 3个重复ACK(ssthresh=cwnd/2) 快速恢复 --> 拥塞避免: 收到新数据ACK 慢开始 --> 快速恢复: 3个重复ACK

这张状态图把四个阶段的迁移条件画清楚了:慢开始指数涨,达到门限转拥塞避免线性涨;任意阶段遇到超时都退回慢开始(最狠的惩罚);遇到 3 个重复 ACK 则进快速恢复、温和减半后回到拥塞避免。盯住"什么信号触发什么迁移",整个状态机就立住了。

七、看懂机制后,再问:为什么是 AIMD

现在回答设计动机就很自然了。为什么增是"加法"、减是"乘法",而不是反过来或者都用同一种?

核心是公平性与稳定性。设想多条连接共享一条链路:

  • 加法增大让所有连接平等地、缓慢地试探带宽——每个 RTT 都只加一点,谁也不会突然抢跑。
  • 乘法减小在拥塞时让大家都按比例退让——窗口越大的连接,减得越多(减半的绝对量更大)。

数学上可以证明,AIMD 会让多条竞争连接的带宽分配逐渐收敛到公平:占得多的退得多,占得少的退得少,最终趋于均等。如果反过来用"乘法增、加法减",带宽差距会被不断放大,强者恒强,不公平也不稳定。

AIMD 不是拍脑袋定的两个增减规则,而是一组能让大量自私连接在无中心协调下自发收敛到公平、稳定状态的策略。这是拥塞控制最深刻的设计智慧。

至于"把丢包当拥塞信号"这个前提,在有线网络里大体成立,但在无线网络(丢包常来自信号干扰而非拥塞)会误伤——这正是 BBR 等现代算法改用"延迟和带宽估计"而非"丢包"作为信号的出发点。

八、和工程实践 / SRE 的联系

  • ss -ti 直接看 cwnd:能观察到某条连接当前的拥塞窗口大小,配合 rttretrans 判断瓶颈是网络还是接收端。
  • 跨地域访问忽快忽慢:长 RTT 链路下,慢开始爬升要花更多 RTT 才能填满带宽,加上偶发丢包就减半重爬,吞吐自然不稳。这解释了为什么"加机器"解决不了"链路挤了"的问题——瓶颈在共享路径,不在你的服务器。
  • 首屏慢与初始窗口:短连接(如一次小 HTTP 请求)可能在慢开始阶段就结束了,根本没机会涨到高 cwnd。这是把初始 cwnd 调大(如 10)以及复用长连接能显著提速的原因。
  • BBR 的意义:在丢包率较高或缓冲区过大(bufferbloat)的链路上,传统基于丢包的算法表现差。BBR 通过主动测量带宽和最小 RTT 来定速,往往能拿到更高更稳的吞吐,是很多 CDN 和长距离传输的选择。

学习这一部分最容易踩的坑

1. 把"慢开始"理解成增长慢

慢开始是每 RTT 翻倍的指数增长,一点都不慢。"慢"指起点低。记成"增长慢"就会和拥塞避免的线性增长彻底搞反。

2. 以为所有丢包都让窗口砸回 1

只有超时才砸回初始值重走慢开始;3 个重复 ACK 只把窗口减半进快速恢复。两种反应轻重不同,混淆了就理解不了快速恢复的价值。

3. 把拥塞控制和流量控制当成一回事

流量控制(rwnd)防的是"压垮接收端",来自对端通告;拥塞控制(cwnd)防的是"挤垮网络",靠发送端自己估计。实际窗口取两者较小值,但成因和信号来源完全不同。

4. 以为丢包一定意味着网络拥塞

经典 TCP 假设"丢包=拥塞",在无线、高丢包链路上会误判,导致明明带宽够却因偶发丢包反复减窗。理解这个假设的局限,才能理解 BBR 这类算法为何另起炉灶。

总结

这一篇我们盯着 cwnd 这一个数字,看它在 ACK 和丢包驱动下的完整变化曲线:

  • 拥塞控制感知的是"网络能不能扛",信号是滞后的丢包,没人会主动通告;
  • 慢开始:起点低、每 RTT 翻倍指数爬升,快速逼近带宽;
  • 拥塞避免:达到 ssthresh 后改为每 RTT 加一的线性增长,小心试探上限;
  • 丢包反应分两档:超时砸回起点重走慢开始,3 个重复 ACK 只减半进快速恢复;
  • 经典 TCP 的 cwnd 曲线呈锯齿状,在带宽上限附近反复试探;
  • AIMD(加法增大、乘法减小)的深层价值是让大量竞争连接无中心地收敛到公平稳定;
  • 实际发送窗口是 min(cwnd, rwnd)——下一篇就来看 rwnd 这一侧。

当你能默画出那条"指数上扬→线性爬升→减半→再爬升"的锯齿曲线,并说清每次拐弯是被超时还是重复 ACK 触发的,这部分就真正属于你了。

参考资源