TCP 拥塞控制算法详解
一、首先理解几个前置概念
在正式讲解拥塞控制之前,我们需要先搞懂几个基础概念,否则后面的内容会看得一头雾水。
1.1 什么是 TCP?
TCP(Transmission Control Protocol,传输控制协议)是互联网中最核心的通信协议之一。当你打开网页、发送消息、下载文件时,你的设备和服务器之间传输数据,底层很多时候用的就是 TCP。
TCP 有一个非常重要的特点:可靠传输。也就是说,它要保证你发出去的数据,对方一定能完整地、按顺序地收到。如果中间有数据丢失了,TCP 会负责重新发送,直到对方收到为止。
1.2 数据是怎么在网络中传输的?
你可能以为数据是一整块地从 A 发送到 B,但实际上不是。TCP 会把要发送的数据切成一个个小块,每一小块叫做一个数据包(Packet)。这些数据包被一个接一个地发送出去,经过网络中的各种路由器、交换机,最终到达目的地。到达目的地后,TCP 再把这些小块按顺序拼装回原来的完整数据。
1.3 什么是 ACK?
ACK 是 Acknowledgement 的缩写,中文意思是"确认"。
TCP 的可靠传输机制是这样运作的:发送方每发出一个数据包,接收方收到后就会回复一个 ACK,意思是"我收到了"。发送方只有收到了对方的 ACK,才能确认这个数据包确实被对方收到了。
如果发送方等了很久都没收到 ACK,它就会认为这个数据包在传输过程中丢失了,于是会重新发送这个包。这就是所谓的超时重传。
1.4 什么是 RTT?
RTT 是 Round-Trip Time 的缩写,中文叫做"往返时间"。
它指的是:从发送方发出一个数据包开始,到发送方收到对方回复的 ACK 为止,这中间经过的总时间。你可以理解为一个数据包"走一个来回"所需要的时间。
举个例子:你从北京往上海发了一个数据包,这个包到达上海需要 10 毫秒,上海回复的 ACK 返回北京又需要 10 毫秒,那么这一个 RTT 就是 20 毫秒。
RTT 是拥塞控制中一个非常关键的时间尺度,后面你会反复看到"每经过一个 RTT"这样的表述。
1.5 什么是拥塞窗口?
这是理解拥塞控制最核心的概念。
拥塞窗口 (Congestion Window,通常缩写为 cwnd)是一个数字,它决定了发送方在没有收到 ACK 的情况下,最多能同时发出多少个数据包。
为什么需要这个东西?因为如果发送方不加限制地疯狂发送数据包,网络中的路由器和链路就会被塞满,导致大量数据包被丢弃,这就是网络拥塞。
所以拥塞窗口就像一个"阀门",用来控制发送方的发送速率。拥塞窗口越大,发送方一次性能发出的数据包就越多,发送速率就越快;拥塞窗口越小,发送速率就越慢。
1.6 什么是网络拥塞?
想象一下城市的道路。如果路上只有少量车辆,大家都能顺畅通行。但如果同时涌入大量车辆,道路就会堵塞,车辆走不动,甚至有些车被迫掉头(对应网络中就是数据包被丢弃)。
网络也是一样的。网络中的路由器有一个缓冲区(Buffer),用来临时存放正在排队等待转发的数据包。如果同时到达的数据包太多,超出了缓冲区的容量,路由器就会直接丢弃多余的数据包。这种现象就叫做网络拥塞 ,而数据包被丢弃就叫做丢包。
丢包是网络拥塞最直接的表现。TCP 正是通过检测是否发生了丢包,来判断网络是否出现了拥塞。
1.7 什么是慢启动门限?
慢启动门限(Slow Start Threshold,通常缩写为 ssthresh)是一个预设的阈值。它的作用是:当拥塞窗口的大小增长到这个阈值时,发送方就要改变增长策略,从快速增长切换为缓慢增长。
你可以把它理解为一条"警戒线"。拥塞窗口在这条警戒线以下时,可以放心地快速增长;一旦到达或超过这条警戒线,就必须小心翼翼地慢慢增长了,因为再快就可能导致网络拥塞。
二、为什么需要拥塞控制?
理解了上面这些概念后,我们来思考一个问题:TCP 为什么需要拥塞控制?
假设没有拥塞控制,会发生什么?
发送方会尽可能快地发送数据,争取让自己的数据最快到达对方。但问题是,网络是所有人共享的。你发得快,别人也发得快,大家都在抢占网络资源。结果就是:网络中的路由器不堪重负,缓冲区爆满,大量数据包被丢弃。
数据包丢了之后,TCP 的可靠传输机制会要求重传。于是发送方又重新发送那些丢失的数据包,这些重传的数据包进一步加剧了网络的拥塞。拥塞加剧后丢包更多,丢包更多后重传更多,重传更多后拥塞更严重......这就形成了一个恶性循环,最终整个网络可能瘫痪,所有人都传不了数据。
所以 TCP 必须有一种机制,让每个发送方"自觉"地控制自己的发送速率。当网络状况好的时候,可以适当加快速率;当网络出现拥塞的迹象时,必须立刻降低速率。 这就是拥塞控制要解决的问题。
TCP 的拥塞控制算法就是通过动态调整拥塞窗口的大小 来实现的。拥塞窗口大,发送速率就快;拥塞窗口小,发送速率就慢。整个算法的核心思路就是:不断地试探网络的承受能力,找到一个合适的发送速率,既不浪费网络带宽,又不造成网络拥塞。
三、拥塞控制的四个阶段
TCP 拥塞控制算法可以清晰地划分为四个阶段(或者说四种状态)。我们按照时间顺序逐一讲解。
3.1 第一阶段:慢启动(Slow Start)
3.1.1 什么时候处于慢启动阶段?
当一条 TCP 连接刚刚建立的时候,发送方对当前网络的状况一无所知。网络到底能承受多大的流量?发送方完全不知道。所以它必须从一个很小的速率开始,一点一点地试探。
这个"从零开始、逐步增加"的阶段,就叫做慢启动。
3.1.2 慢启动的具体过程
初始状态: 拥塞窗口的大小被设置为 1。也就是说,发送方一开始只能同时发出 1 个数据包,然后等待对方的 ACK。
增长规则: 每经过一个 RTT(也就是每完成一轮数据的发送和确认),拥塞窗口的大小翻倍,即 cwnd × 2。
我们来具体看看这个过程:
- 第 1 个 RTT 开始时: cwnd = 1。发送方发出 1 个数据包,然后等待 ACK。
- 第 1 个 RTT 结束时: 收到了 1 个 ACK,说明网络没问题。于是 cwnd 翻倍变成 2。
- 第 2 个 RTT 开始时: cwnd = 2。发送方可以同时发出 2 个数据包。
- 第 2 个 RTT 结束时: 收到了 2 个 ACK,cwnd 翻倍变成 4。
- 第 3 个 RTT 开始时: cwnd = 4。发送方同时发出 4 个数据包。
- 第 3 个 RTT 结束时: 收到了 4 个 ACK,cwnd 翻倍变成 8。
- 第 4 个 RTT 开始时: cwnd = 8。发送方同时发出 8 个数据包。
- ......
你看到规律了吗?cwnd 的变化是:1 → 2 → 4 → 8 → 16 → 32 → ......
这就是指数增长。每经过一个 RTT,拥塞窗口就变成原来的 2 倍。
3.1.3 为什么叫"慢启动"?
你可能会觉得奇怪:指数增长明明很快啊,为什么叫"慢"启动?
这里的"慢"是相对的。在 TCP 拥塞控制算法被发明之前,TCP 的做法是一开始就把拥塞窗口设置得很大(比如直接等于接收方的接收窗口大小),然后一股脑地发送大量数据。相比之下,从 1 开始逐步增长,起步阶段确实是"慢"的。所以叫做"慢启动"。
虽然起步慢,但由于是指数增长,速率上升得其实非常快。这是一种很聪明的策略:起步保守,但增长迅猛,能够快速逼近网络的承受极限。
3.1.4 慢启动什么时候结束?
慢启动阶段不会永远持续下去。它在以下情况会结束:
- 拥塞窗口达到了慢启动门限(ssthresh): 这时候发送方觉得"速率已经不低了,再指数增长可能太冒进了",于是切换到下一个阶段------拥塞避免。
- 发生了丢包: 这说明网络已经拥塞了,必须立刻调整策略。具体怎么调整,后面第三阶段会详细讲。
3.2 第二阶段:拥塞避免(Congestion Avoidance)
3.2.1 什么时候进入拥塞避免阶段?
当拥塞窗口在慢启动阶段一路指数增长,终于达到了慢启动门限(ssthresh)的时候,发送方就从慢启动阶段切换到拥塞避免阶段。
3.2.2 为什么需要从指数增长切换到线性增长?
我们来想一下:在慢启动阶段,拥塞窗口是指数增长的,也就是 1、2、4、8、16、32、64......增长速度越来越快。
但网络的承受能力是有极限的。如果拥塞窗口已经比较大了(比如已经到了 32),继续指数增长的话,下一步就变成 64,一下子翻了一倍。这种剧烈的增长很可能直接超出网络的承受能力,导致大量丢包。
所以,当拥塞窗口增长到一定程度后,就应该放慢增长速度,小心翼翼地试探网络还能不能再承受更多的数据。这就是拥塞避免阶段要做的事情。
3.2.3 拥塞避免的具体过程
增长规则: 每经过一个 RTT,拥塞窗口只增加 1,即 cwnd + 1。
假设慢启动门限是 16,那么当 cwnd 通过慢启动增长到 16 之后,后续的变化就变成了:
- cwnd = 16 → 经过 1 个 RTT → cwnd = 17
- cwnd = 17 → 经过 1 个 RTT → cwnd = 18
- cwnd = 18 → 经过 1 个 RTT → cwnd = 19
- cwnd = 19 → 经过 1 个 RTT → cwnd = 20
- ......
这就是线性增长。每个 RTT 只增加 1,增长速度非常平稳。
对比一下:
- 慢启动阶段(指数增长):1, 2, 4, 8, 16(每次翻倍)
- 拥塞避免阶段(线性增长):16, 17, 18, 19, 20(每次加1)
显然,拥塞避免阶段的增长速度慢得多,这正是为了避免增长过快而导致网络拥塞。
3.2.4 拥塞避免什么时候结束?
拥塞避免阶段会一直持续,直到发生丢包。
虽然拥塞避免阶段增长得很慢,但它毕竟还是在增长。拥塞窗口会一步一步地变大:20、21、22、23......总有一刻,拥塞窗口会大到超出网络的承受能力,于是丢包发生了。
一旦丢包发生,就进入了第三个阶段------拥塞发生。
3.3 第三阶段:拥塞发生(Congestion Occurred)
3.3.1 丢包意味着什么?
在 TCP 的拥塞控制逻辑中,丢包 = 网络拥塞的信号。
一旦发送方检测到丢包,它就知道:"我发送的数据太多了,网络已经承受不住了。"此时,发送方必须立刻降低自己的发送速率(也就是缩小拥塞窗口),给网络减减压。
但关键问题来了:发送方是怎么知道丢包了的?
发送方检测丢包有两种途径,而这两种途径反映的网络拥塞程度是不同的,因此 TCP 对这两种情况采取不同的应对策略。
3.3.2 情况一:收到三个重复 ACK → 快速重传和快速恢复
什么是三个重复 ACK?
要理解这个,我们先了解一下 TCP 的 ACK 机制的一个细节。
TCP 的 ACK 是"累积确认"的。接收方发送的 ACK 会告诉发送方:"我期望收到的下一个数据包的编号是 X。"这意味着编号 X 之前的所有数据包,接收方都已经收到了。
现在假设发送方发出了编号为 1、2、3、4、5 的五个数据包。其中编号为 2 的数据包在传输过程中丢失了,但 3、4、5 都顺利到达了接收方。
接收方的反应是这样的:
- 收到数据包 1,回复 ACK:"我期望收到 2"。
- 数据包 2 丢了,没收到。
- 收到数据包 3,但因为 2 还没到,所以接收方无法更新确认号,只能再次回复 ACK:"我期望收到 2"。
- 收到数据包 4,2 还是没到,再次回复 ACK:"我期望收到 2"。
- 收到数据包 5,2 还是没到,再次回复 ACK:"我期望收到 2"。
发送方这边会发现:它连续收到了多个 ACK,而且这些 ACK 都在说同一句话------"我期望收到 2"。这就是所谓的重复 ACK。
当发送方连续收到三个重复的 ACK时(注意:是三个重复的,也就是除了第一个正常的 ACK 之外又收到了三个相同的),发送方就可以判定:编号为 2 的数据包大概率是丢了。
为什么是三个?
为什么不是一个或两个就判定丢包?因为网络中数据包到达的顺序有时候会乱。可能编号为 2 的包只是走了一条比较慢的路径,稍后就会到达。收到一两个重复 ACK 可能只是正常的乱序,不一定是丢包。但如果连续收到三个重复 ACK,说明后面的 3、4、5 都到了而 2 还没到,基本可以确认 2 确实是丢了。
三个是一个经验值,在可靠性和效率之间取了一个平衡。
快速重传
既然已经判定数据包 2 丢了,发送方就立刻重传数据包 2 ,不需要等到超时计时器到期。这就叫做快速重传。
这里有一个重要的区别:快速重传是数据驱动 的,而不是时间驱动的。
什么意思呢?
- 时间驱动: 发送方为每个发出的数据包设置一个超时计时器。如果在规定时间内没有收到 ACK,计时器到期,发送方就重传。这种机制需要等待时间到期才会触发重传,如果计时器设得比较长,就要等很久。
- 数据驱动: 发送方不是靠等时间来判断丢包,而是靠收到的重复 ACK 来判断丢包。三个重复 ACK 一到,立刻重传。不需要等任何计时器,响应更快。
所以快速重传相比超时重传,能够更快地恢复丢失的数据包,减少等待时间。
快速恢复
快速重传解决了"丢失的数据包怎么补发"的问题,但还有另一个问题:拥塞窗口怎么调整?
在快速重传的场景下,虽然发生了丢包,但请注意一个事实:发送方还能收到三个重复的 ACK。
这说明什么?说明网络并没有完全瘫痪。接收方还在不断地收到数据包(3、4、5 都收到了),并且还能把 ACK 发回来。网络只是"有点堵",但还没有"彻底堵死"。
基于这个判断,TCP 采取了一种相对温和 的调整策略,叫做快速恢复:
- 拥塞窗口减半: 把当前的拥塞窗口除以 2。比如拥塞窗口原来是 24,减半后变成 12。
- 同时将慢启动门限也更新为这个减半后的值: ssthresh = 12。
- 接下来进入拥塞避免阶段,线性增长: 拥塞窗口从 12 开始,每经过一个 RTT 加 1。即 12、13、14、15......
注意:这里不会回到慢启动阶段。因为网络状况还没那么糟糕,没必要从 1 重新开始。直接从减半后的窗口大小开始线性增长,既降低了发送速率给网络减压,又不至于让速率降得太多浪费带宽。
这就像开车时发现前方有点堵,你不需要把车停下来重新起步,只需要减速到原来速度的一半,然后慢慢加速就行了。
总结情况一
- 触发条件: 连续收到三个重复 ACK
- 丢包检测方式: 数据驱动
- 对网络状况的判断: 网络有点拥塞,但没有很严重
- 应对策略:
- 快速重传丢失的数据包
- 拥塞窗口减半
- 进入拥塞避免阶段(线性增长)
3.3.3 情况二:超时重传
什么时候会触发超时重传?
如果发送方发出一个数据包后,在很长一段时间内既没有收到正常的 ACK,也没有收到三个重复的 ACK,最终超时计时器到期了。这时候就触发了超时重传。
超时重传是时间驱动的:发送方设定了一个等待时间,时间到了还没收到确认,就重传。
超时重传意味着什么?
超时重传比收到三个重复 ACK 要严重得多。
想想看,为什么发送方连三个重复 ACK 都收不到?可能的原因是:
- 不仅仅是某一个数据包丢了,而是大面积丢包。后面的数据包 3、4、5 也全都丢了,接收方什么都没收到,自然也不会发回任何 ACK。
- 或者即使接收方发回了 ACK,这些 ACK 在回来的路上也丢了。
无论哪种情况,都说明网络的拥塞情况非常严重。网络基本上"堵死了"。
超时重传的应对策略
既然网络已经严重拥塞,TCP 就必须采取最激进的减速措施:
- 拥塞窗口重置为 1: 直接回到最初的状态,从头开始。
- 慢启动门限更新为拥塞发生时拥塞窗口的一半: 比如超时发生时 cwnd = 24,那么 ssthresh 被设置为 12。
- 重新进入慢启动阶段: 从 cwnd = 1 开始指数增长。
这就相当于:路完全堵死了,你不得不把车停下来,重新起步,慢慢加速。
总结情况二
- 触发条件: 超时计时器到期,没有收到 ACK
- 丢包检测方式: 时间驱动
- 对网络状况的判断: 网络严重拥塞
- 应对策略:
- 超时重传丢失的数据包
- 拥塞窗口重置为 1
- 慢启动门限设为拥塞发生时窗口的一半
- 重新进入慢启动阶段(指数增长)
3.3.4 两种情况的对比
| 对比项 | 三个重复 ACK(快速重传/快速恢复) | 超时重传 |
|---|---|---|
| 检测方式 | 数据驱动 | 时间驱动 |
| 网络拥塞程度 | 较轻 | 严重 |
| 拥塞窗口调整 | 减半 | 重置为 1 |
| 后续阶段 | 拥塞避免(线性增长) | 慢启动(指数增长) |
| 反应速度 | 快(不需要等超时) | 慢(需要等计时器到期) |
| 恢复速度 | 较快(从一半开始增长) | 较慢(从 1 开始增长) |
四、完整流程的串联
现在让我们把四个阶段串联起来,用一个完整的例子来走一遍整个拥塞控制的过程。
假设初始条件:
- 初始拥塞窗口 cwnd = 1
- 初始慢启动门限 ssthresh = 16
阶段一:慢启动
| RTT 序号 | cwnd 值 | 阶段 |
|---|---|---|
| 0 | 1 | 慢启动 |
| 1 | 2 | 慢启动 |
| 2 | 4 | 慢启动 |
| 3 | 8 | 慢启动 |
| 4 | 16 | 慢启动 → 达到 ssthresh,切换到拥塞避免 |
cwnd 从 1 开始,每个 RTT 翻倍:1 → 2 → 4 → 8 → 16。
当 cwnd = 16 = ssthresh 时,慢启动阶段结束,进入拥塞避免阶段。
阶段二:拥塞避免
| RTT 序号 | cwnd 值 | 阶段 |
|---|---|---|
| 5 | 17 | 拥塞避免 |
| 6 | 18 | 拥塞避免 |
| 7 | 19 | 拥塞避免 |
| 8 | 20 | 拥塞避免 |
| 9 | 21 | 拥塞避免 |
| 10 | 22 | 拥塞避免 |
| 11 | 23 | 拥塞避免 |
| 12 | 24 | 拥塞避免 → 在这里发生了丢包! |
cwnd 每个 RTT 只加 1:16 → 17 → 18 → ... → 24。
假设当 cwnd = 24 的时候,网络终于不堪重负,发生了丢包。
阶段三:拥塞发生
现在分两种情况讨论:
情况 A:收到了三个重复 ACK
- cwnd 减半:24 ÷ 2 = 12
- ssthresh 更新为 12
- 快速重传丢失的数据包
- 进入拥塞避免阶段,从 cwnd = 12 开始线性增长
后续变化:12 → 13 → 14 → 15 → 16 → 17 → ......
情况 B:发生了超时
- cwnd 重置为 1
- ssthresh 更新为 24 ÷ 2 = 12
- 超时重传丢失的数据包
- 重新进入慢启动阶段,从 cwnd = 1 开始指数增长
后续变化:1 → 2 → 4 → 8 → 12(达到新的 ssthresh)→ 切换到拥塞避免 → 13 → 14 → 15 → ......
你看到了吗?在情况 B 中,cwnd 被打回原点(从 1 开始),但这次指数增长到 12 就停了(因为新的 ssthresh = 12),然后又切换到线性增长。
五、更深层的理解
5.1 拥塞控制的核心思想:探测与退让
整个拥塞控制算法可以用四个字概括:探测与退让。
- 探测: 不断增大拥塞窗口,试探网络还能不能承受更多的数据。慢启动是快速探测(指数增长),拥塞避免是谨慎探测(线性增长)。
- 退让: 一旦检测到丢包(网络拥塞的信号),立刻缩小拥塞窗口,降低发送速率,给网络减压。轻度拥塞就退让一半(快速恢复),严重拥塞就退回原点(重新慢启动)。
这种"探测→达到极限→退让→再探测"的过程会反复进行。如果你把拥塞窗口的大小随时间画成一条曲线,你会看到一个锯齿形的图案:cwnd 先上升,然后突然下降,再上升,再下降......永远在这个过程中循环。
5.2 为什么快速恢复比超时重传更高效?
从数字上就能看出来:
- 快速恢复:cwnd 从 24 降到 12,损失了一半的速率。
- 超时重传:cwnd 从 24 降到 1,几乎损失了全部速率。
而且超时重传还需要等待超时计时器到期,这期间发送方什么都不做,白白浪费时间。
所以快速恢复的设计是非常精妙的。它利用"还能收到三个重复 ACK"这个信息,判断出网络拥塞还不算太严重,从而采取了一种更温和的降速策略,让传输效率更高。
5.3 慢启动门限的动态变化
慢启动门限(ssthresh)不是一成不变的。它的初始值可能是一个比较大的数(由操作系统设定,或者根据网络情况估算),但每次发生拥塞时,ssthresh 都会被更新为当时拥塞窗口的一半。
这意味着:随着网络运行,TCP 会不断地"学习"网络的承受能力。每次拥塞发生后,ssthresh 都会调整为一个更合理的值,让下一次的增长过程更加合理------在安全范围内快速增长(慢启动),在接近危险区域时谨慎增长(拥塞避免)。
5.4 这四个阶段的本质关系
让我们从更高的视角来审视这四个阶段:
- 慢启动 和拥塞避免 是拥塞窗口的两种增长模式:一个快(指数),一个慢(线性)。
- 拥塞发生 是一个转折点:从增长切换到缩减。
- 快速恢复 和超时重传 后重新慢启动,是两种不同的恢复模式:一个温和(从一半开始线性增长),一个激进(从 1 开始指数增长)。
这四个阶段循环往复,构成了 TCP 拥塞控制的完整生命周期。
六、总结
让我们最后用一张清晰的流程图(文字版)来总结整个拥塞控制的过程:
[连接建立]
│
▼
[慢启动阶段]
cwnd = 1,每个 RTT 翻倍(指数增长)
│
├── cwnd 达到 ssthresh ──→ [拥塞避免阶段]
│ 每个 RTT 加 1(线性增长)
│ │
│ ├── 收到 3 个重复 ACK ──→ [快速重传 + 快速恢复]
│ │ cwnd 减半,线性增长
│ │ (回到拥塞避免阶段)
│ │
│ └── 超时 ──→ [超时重传]
│ cwnd = 1,重新慢启动
│ (回到慢启动阶段)
│
├── 收到 3 个重复 ACK ──→ [快速重传 + 快速恢复]
│
└── 超时 ──→ [超时重传]
核心要点回顾:
- 拥塞窗口(cwnd) 是控制发送速率的核心变量。
- 慢启动:从 1 开始指数增长,快速探测网络容量。
- 拥塞避免:到达门限后线性增长,谨慎探测网络极限。
- 三个重复 ACK:网络轻度拥塞,快速重传 + 窗口减半 + 线性恢复。
- 超时:网络严重拥塞,窗口归 1 + 重新慢启动。
TCP 的拥塞控制就像一个谨慎的司机:起步时试探性地加速(慢启动),速度起来后小心地提速(拥塞避免),遇到小堵减速一半继续开(快速恢复),遇到大堵就停下来从头再来(超时重传)。通过这种不断试探、不断调整的机制,TCP 能够在充分利用网络带宽的同时,有效避免网络拥塞,保障整个网络的稳定运行。