第10章 TCP 拥塞控制
叙事密度:中高 | 主语言:Python | Java/C++ 差异窗 | Vol 4·计算机网络
元数据卡
| 属性 | 内容 |
|---|---|
| 卷号 | Vol 4 — 计算机网络 |
| 章节 | 第10章:TCP 拥塞控制 |
| 前置 | 第3章(TCP 深度剖析:滑动窗口、流量控制) |
| 后置 | 第6章(现代协议:WebSocket 与 gRPC) |
| 理论深度 | (4/5) |
| Python 相关度 | ≈80%;拥塞窗口模拟、算法演进逐步实现 |
| 核心模型 | AIMD、慢启动、拥塞避免、快速恢复、CUBIC、BBR |
| 代码量 | ~180 行 |
你在哪
"驿道最精细的调控机制——TCP 拥塞控制。当驿道上法术信函太多堵住了,所有 TCP 传送咒要协调减速,而不是一起加大魔力输出。不然整条驿道都会瘫痪。"
第3章你学会了 TCP 的核心机制:三次法力握手建立连接,滑动卷轴做魔力流量控制(接收端信标塔说"我只能收这么多"),四次告别咒优雅结束。
但有个问题被故意略过了:如果驿道本身太挤了怎么办?
魔力流量控制管的是"接收方信标塔吃不下"——但是发送方和接收方之间的驿道上,可能有几十座驿道信标塔共享同一条魔导缆线。其他 TCP 传送咒也在发送魔法数据,一座信标塔的魔力缓存区只有几 MB,当所有传送咒的法术信函一起涌入:
[发送方A] →→→→→ 信标塔(缓存区爆了!) →→→→→ [接收方B]
[发送方C] →→→→→↗ ↑
[发送方D] →→→→→↗ 丢弃法术信函这就是驿道淤塞(拥塞/congestion)。信标塔魔力缓存区满了 → 丢信函 → TCP 超时重发 → 发更多信函 → 更淤塞 → 缓存区一直满 → 传送吞吐量趋近于零(驿道崩溃,congestion collapse)。
TCP 拥塞控制,就是驿道的限速法阵——当驿道变挤时,所有 TCP 传送咒要协调减速,而不是一起加大传送魔力。
本章分层
- 必读:拥塞控制解决的是网络过载问题(不是接收方缓冲问题),AIMD 直觉(加性增/乘性减)、慢启动和拥塞避免的概念
- 选读:快速重传 / 快速恢复、Tahoe vs Reno 对比、Bufferbloat
- 深水区:CUBIC 三次函数增长、BBR 带宽探测、CoDel 主动队列管理、公式推导
本章是可选深水章:拥塞控制属于 TCP 最深入的细节。如果你时间有限,理解「拥塞控制让所有 TCP 连接在网络拥堵时协调减速」就够了,公式推导不应作为常规验收标准。
本章不会要求你掌握
- AIMD 吞吐量公式的数学推导
- CUBIC 三次函数的精确计算
- BBR 四个阶段的详细状态迁移
你的任务
学完本章,你应当:
- 理解核心前提:拥塞控制解决网络过载,不是接收方缓冲问题——流量控制 vs 拥塞控制是两回事
- 手绘 AIMD 的「锯齿」曲线直觉,解释为什么加性增/乘性减是最优策略
- 写出慢启动从 1 MSS 到窗口=64 的过程
- 用 Python 模拟一个 TCP 连接的 cwnd 随时间变化
- 区分 Tahoe、Reno、Cubic 的策略差异
- 解释 BBR 和基于丢包的拥塞控制的本质区别
- 说清楚 Bufferbloat 是什么、为什么 CoDel 能缓解
遭遇战 → 获得技能
第1战:AIMD——拥塞控制的基石
问题: 驿道上的魔力管道只有一个法术水龙头,你、隔壁法师塔、山下驿站三座塔想同时用魔力。如果你拼命开大、大家拼命开大——最终谁都出不了法力(魔力压跌光)。你怎么协调分配?
AIMD(Additive Increase Multiplicative Decrease) 是 TCP 的答案:
- 加性增(AI):每个 RTT,拥塞窗口 +1 MSS(如果没有丢包)
- 乘性减(MD):检测到丢包时,拥塞窗口 × ½
用 Python 模拟一次 RTT:
class TCPSender:
def __init__(self):
self.cwnd = 1 # 拥塞窗口,单位:MSS(报文段)
self.ssthresh = 64 # 慢启动阈值
self.rtt = 0.1 # 模拟 RTT = 100ms
self.time = 0.0
def on_ack(self, lost: bool = False):
"""一个 RTT 结束,收到 ACK(或检测到丢包)"""
if lost:
# 乘性减:cwnd 砍半(至少 1)
self.ssthresh = max(1, self.cwnd // 2)
self.cwnd = max(1, self.cwnd // 2)
else:
# 加性增:每个 RTT +1 MSS
self.cwnd += 1
self.time += self.rtt
sender = TCPSender()
for _ in range(20):
lost = (sender.time > 0.7 and sender.time < 0.9) # 0.7s-0.9s 模拟丢包
sender.on_ack(lost)
print(f"t={sender.time:.1f}s cwnd={sender.cwnd:5.1f} {'← LOST!' if lost else ''}")输出会形成一个"锯齿"——线性增长直到丢包,然后切半,再增长,再切半……
为什么是 AIMD 而不是 AIAI(都加)或 MDMD(都减)?
- AIAI 的结果:多个 TCP 流之间不会收敛到公平——先到的流占优势,后到的永远追不上
- MDMD 的结果:拥塞窗口永远起不来,带宽利用率极低
- AIMD 的数学优雅:多个竞争的 TCP 流最终会收敛到公平共享带宽——每条流吞吐量 ≈ 总带宽/流数量(证明需要一点控制论,但直觉上"你减半我也减半"让大家都缩到一个公平点)
一个精心设计的系统往往简单、对抗熵增。AIMD 就是这样的设计。 它不需要中央协调器,不需要全局视图——每个发送端独立做丢包检测 + 本地决策,全局收敛到公平分配。
第2战:慢启动——不要一上来就塞爆路由器
问题: 如果新的 TCP 连接建立后直接开始 AI(每个 RTT +1 MSS),那要几百个 RTT 才能把 cwnd 升到几百——视频加载黄花菜都凉了。
慢启动(Slow Start,实际上一点也不慢):
初始 cwnd = 10 MSS(RFC 6928 之前的旧实现 = 1 或 2)。每收到一个 ACK,cwnd += 1 MSS。这意味着:
初始 cwnd = 10
RTT 1: 发 10 个包 → 收到 10 个 ACK → cwnd = 20
RTT 2: 发 20 个包 → 收到 20 个 ACK → cwnd = 40
RTT 3: 发 40 个包 → 收到 40 个 ACK → cwnd = 80
RTT 4: 发 80 个包 → ...指数增长(2^N),每 RTT 翻倍:
def slow_start_sequence(init_cwnd: float = 10, ssthresh: float = 256):
"""模拟慢启动过程中 cwnd 的 RTT 序列"""
cwnd = init_cwnd
seq = [cwnd]
while cwnd < ssthresh:
cwnd *= 2 # 每 RTT 翻倍(简化模型:每个 RTT 收到的 ACK 数 = 发送数)
if cwnd > ssthresh:
cwnd = ssthresh
seq.append(cwnd)
return seq
print(slow_start_sequence(10, 256))
# → [10, 20, 40, 80, 160, 256]
# 只用 5 个 RTT 就从 10 MSS 飙到了 256 MSS!什么时候结束慢启动?
- cwnd ≥ ssthresh(慢启动阈值)→ 进入拥塞避免(线性增长)
- 检测到丢包 → ssthresh = cwnd/2,cwnd = 1(重新慢启动,Tahoe 行为)
Java 差异窗:Java 没有暴露 cwnd 或 ssthresh 的 API——这些都在操作系统内核的 TCP 栈里。如果你想看实际 cwnd,Linux 上可以
ss -ti或读/proc/net/tcp。C 可以用TCP_INFO的struct tcp_info通过getsockopt()读取tcpi_snd_cwnd。Python 可以用psutil.net_connections()但拿不到 cwnd——得调用 C 扩展(如python-tcp-info)。
第3战:拥塞避免——从指数到线性的战略转弯
问题: 慢启动让你的连接快速到达网络容量的"大致水位"。之后呢?继续指数增长?那你很快就超过网络的上限,大量丢包、拥塞崩溃。怎么办?
拥塞避免(Congestion Avoidance)——从翻倍变为线性:
每收到一个 ACK,cwnd += 1/cwnd(等价于每个 RTT 加 1 MSS)。
def congestion_avoidance(init_cwnd: float, rtts: int = 10):
"""模拟拥塞避免阶段 cwnd 的增长"""
cwnd = init_cwnd
seq = [cwnd]
for _ in range(rtts):
# 每个 RTT 增长 1 MSS。精确实现:每收到一个 ACK,cwnd += MSS * (MSS/cwnd)
# 简化:cwnd 每次循环 +1(对应一个 RTT)
cwnd += 1
seq.append(round(cwnd, 1))
return seq
print(congestion_avoidance(256, 10))
# → [256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266]AIMD 的全周期图示(cwnd vs 时间):
cwnd
^
| 慢启动(2^N) 拥塞避免(+1/RTT) 丢包
| / ______/ ↙
| / ______/ \ /
| / ____/ \____/ ← 乘性减(×½)
| /__/ \
| \__
+------------------------------------------------→ 时间
↑ ↑ ↑
开始连接 ssthresh=64 丢包后→ssthresh=cwnd/2关键直觉:慢启动是"快速探测可用带宽的上限",拥塞避免是"稳妥地逼近上限",丢包后乘性减是"快速退让,给其他人让路"。三个阶段的切换让 TCP 既能快速启动,又能稳定运行,还能从错误中恢复。
第4战:快速重传与快速恢复——不等超时
问题: 一个包丢了。发送端在超时(通常 ≥ 200ms)之前怎么知道它丢了?
TCP 的巧妙机制——重复确认(Duplicate ACK):
接收端如果收到了失序的包(3 号包没到,4、5、6 号包先到了),它会重复发 ACK=3(期望收到的下一字节序号)——一次、两次、三次。
发送端收到 3 个重复的 ACK(3 Dup ACK):连续 4 次收到相同的 ACK(第一次 + 三次重复)= 几乎可以肯定那个包丢了 → 立即重传,不等超时。
class TCPFastRetransmit:
def __init__(self):
self.cwnd = 100
self.ssthresh = 100
self.dup_ack_count = 0
self.last_ack = 0
self.retransmitted = False
def on_ack_received(self, ack_number: int):
"""收到一个 ACK,判断是否是重复的"""
if ack_number == self.last_ack:
self.dup_ack_count += 1
if self.dup_ack_count >= 3 and not self.retransmitted:
print(f"3 Dup ACK 检测到!快速重传 seq={ack_number}")
self.ssthresh = max(1, self.cwnd // 2)
self.cwnd = self.ssthresh + 3 # 快速恢复
self.retransmitted = True
return "FAST_RETRANSMIT"
else:
self.last_ack = ack_number
self.dup_ack_count = 0
self.retransmitted = False
return "NORMAL"快速恢复(Fast Recovery)——Reno 的优雅策略:
在快速重传之后,cwnd 不降到 1(Tahoe 的做法),而是设为 ssthresh + 3:
丢包前 cwnd = 100, ssthresh = 50
Reno:cwnd = 50 + 3 = 53,然后进入拥塞避免
而 Tahoe:cwnd = 1,重新慢启动到 ssthresh=50(浪费好几个 RTT)Tahoe vs Reno 对比:
事件: 3 Dup ACK 在 cwnd=100 时发生
Tahoe: cwnd = 1 → 慢启动到 ssthresh=50 → 拥塞避免
(从 1 到 50 需要 log₂(50/10) ≈ 3 RTT 慢启动)
Reno: cwnd = 53 → 拥塞避免(从 53 开始线性增长)
(省了 3 个 RTT 的慢启动时间)Reno 的致命弱点:如果一个 RTT 内丢了多个包(一个窗口中多个包丢失),快速恢复会多次触发,cwnd 一降再降,最终退化到 Tahoe 行为甚至超时。这种情况在大带宽 × 高延迟链路(长肥网络,long-fat network)上很常见——这就是为什么需要更好的算法。
Java 差异窗:这些算法在内核里,Java 不提供选择器。但你可以通过 JNI 或
jnetpcap抓包分析 WireShark 级别的数据来观察。Linux 可以用ss -i显示当前 TCP 连接的cwnd、rtt、ssthresh。C 最直接——TCP 栈代码就在/net/ipv4/tcp_cong.c(Linux 源码)。
深水区:以下算法演进(CUBIC、BBR、CoDel)属于现代拥塞控制的前沿内容。主线只要求理解「Tahoe 丢包后归零,Reno 丢包后砍半,CUBIC 更平滑,BBR 不靠丢包信号」这个直觉就够了。
第5战:TCP 算法演进——Tahoe → Reno → NewReno → Cubic → BBR
Tahoe(1988)— 第一个拥塞控制算法
- 慢启动(指数)
- 拥塞避免(线性)
- 快速重传(3 Dup ACK)
- 丢包 → cwnd = 1(重新慢启动)
- 问题:丢包代价太大,吞吐量腰斩
Reno(1990)— 加上了快速恢复
- 丢包 → cwnd = cwnd/2 + 3
- 避免了 Tahoe 的"归零重来"
- 问题:多包丢失仍会超时 → Reno 对无线链路(随机丢包而非拥塞丢包)极不友好
NewReno(1999)— 改进了对多包丢失的处理
- 同一个窗口内丢多个包时,用部分 ACK(Partial ACK)来感知——收到一个 ACK 只确认了部分但还未发送完的数据 → 继续快重传剩下的
- 不会在同一个窗口中反复缩减 cwnd
BIC(2004)— 二分法搜索
- 不再 AI/MD 线性,而是二分查找最合适的窗口大小
- 如果之前 cwnd=100 丢包,ssthresh=50 → 试探中间值 75 → 不丢就试 87.5 → ……收敛
- 问题:CUBIC 是它的后继优化版本
CUBIC(2006)— Linux 默认算法(直到今天)
CUBIC 不再依赖 RTT 的直接比例来控制增长,而是用时间作为增长函数的自变量——窗口增长是一个三次函数(cubic function):
W(t) = C*(t - K)³ + W_max其中:
W_max= 上次丢包时的窗口大小t= 距离上次丢包的时间K= 窗口从丢包恢复回W_max所需的时间C= 缩放常数(默认 0.4)
三次函数形状:
cwnd
^
| W_max ─────────────────────●──────────
| \ ↑ 超过 W_max 后快速
| \ 增长(抢占带宽)
| \____W_max/2_______
| \
| \ 达到 W_max 附近时
| \ 平坦(趋近稳定)
+--------------------------------------------→ 时间CUBIC 的关键优势:
- RTT 公平性:RTT 小的传送咒增长不再比 RTT 大的传送咒快——因为增长是"时间的函数",不是"ACK 数量的函数"。驿道上,近的信标塔和远的公平竞争。
- 高速网络友好:三次函数在远离
W_max时增长快(快速恢复带宽),在接近W_max时增长慢(稳定运行)。完美适配 10Gbps+ 链路。
用 Python 模拟 CUBIC 的行为:
import math
class CUBIC:
def __init__(self, C: float = 0.4, beta: float = 0.3):
self.C = C # 缩放常数
self.beta = beta # 乘性减少因子(Reno=0.5, CUBIC=0.3)
self.W_max = 100 # 上次丢包时的窗口
self.cwnd = 100
self.last_loss_time = 0.0
def on_rtt(self, t: float):
"""每个 RTT 调用一次"""
K = (self.W_max * self.beta / self.C) ** (1/3)
elapsed = t - self.last_loss_time
# 三次函数:W = C*(t-K)³ + W_max
target = self.C * ((elapsed - K) ** 3) + self.W_max
self.cwnd = max(self.cwnd, target) # cwnd 只增不减(和标准一样用 max)
return self.cwnd
def on_loss(self, t: float):
self.last_loss_time = t
self.W_max = self.cwnd
self.cwnd = (1 - self.beta) * self.cwnd # ×0.7(比 Reno 的×0.5更积极)BBR(Bottleneck Bandwidth and Round-trip propagation time)(2016)— 谷歌的范式革命
所有之前的算法都有一个隐含假设:丢包 = 拥塞。
但这个假设在 2020 年代的网络上已经不成立了:
- 无线/移动网络:信号波动导致随机丢包(不是拥塞)
- 深缓冲区(Bufferbloat):路由器缓冲区几百 MB,丢包发生前你已经把延迟从 10ms 加到了 500ms
BBR 的颠覆性思想:不再用丢包判断拥塞。而是直接测量两个物理量:
- BtlBw(瓶颈带宽)——整条路径上最窄的那段的带宽
- RTprop(往返传播时间)——没有排队时的最小 RTT
然后在一个 RTT 内发送的量 = BtlBw × RTprop(管道容量 = 带宽 × 延迟)。
class BBR:
"""BBR 核心简化为带宽 × 延迟模型"""
def __init__(self):
self.btlbw = 100 # Mbps,瓶颈带宽估计
self.rtprop = 0.02 # 秒,最小 RTT(20ms)
self.cwnd = int(self.btlbw * 1e6 / 8 * self.rtprop / 1460) # 单位 MSS
# ≈ (100Mbps / 8 × 0.02s) / 1460B ≈ 171 MSS
def pacing_rate(self) -> float:
"""发送 pacing 速率 = BtlBw(刚刚好填满瓶颈,不多发)"""
return self.btlbw * 1e6 / 8 # Bps
def probe_bandwidth(self):
"""周期性探测带宽是否变化(BBR 每隔 ~8 RTT 的"探测"阶段)"""
# 发送速率提升 1.25 倍 × 1 RTT → 如果 BtlBw 变大(队列没膨胀),更新
# 然后发速率降回 0.75 × 1 RTT → 排空队列
...BBR vs CUBIC 的核心差异:
| CUBIC | BBR | |
|---|---|---|
| 拥塞信号 | 丢包 | 带宽 & RTT 测量 |
| 丢包发生时 | 减窗口 | 不降速(除非丢包率 > 某个阈值) |
| 对 Bufferbloat 的响应 | 无——丢包前缓冲区已满 | 主动维持最小 RTT |
| 吞吐量 | 高延迟链路可能偏低 | 好——特别是无线/移动网络 |
| 公平性 | 与 Reno/CUBIC 流友好 | 与 CUBIC 共存时可能占优 |
Java 差异窗:BBR 在内核里(Linux 4.9+),Java 应用可以通过
ss -i或/proc/net/tcp看到当前使用什么拥塞算法。C 开发者可以通过setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, "bbr", 3)切换。Python 没有纯 Python 的 TCP 栈实现——你需要在内核支持 BBR 的 Linux 上运行,Python 只负责通过 socket 创建连接。
Bufferbloat 与 CoDel——延迟爆炸的根源
问题: 你家路由器缓冲区很大(比如 512KB)——看起来是好事?多存点包,减少丢包?错。
Bufferbloat 就是"缓冲区太大"导致的灾难:
没有 Bufferbloat(缓冲区 = BDP 的 1-2 倍):
cwnd 增长 → 缓冲区渐满 → 丢包 → cwnd 减半 → 队列清空 → RTT 正常
有 Bufferbloat(缓冲区 = BDP 的 10 倍):
cwnd 增长 → 缓冲区渐满……继续增长……缓冲区几乎满了……终于丢包!
→ cwnd 减半,但缓冲区还是满的 → RTT 从 20ms 暴涨到 500ms
→ 你的传送阵加载慢不是因为驿道容量不够,是因为传送延迟爆炸!用 Ping 你可以自己测 bufferbloat——给 ping 加个负载(大文件下载同时测 RTT):
无负载时:RTT = 15ms
下载时: RTT = 450ms ← 缓冲区满了,每个包排队等 435ms!CoDel(Controlled Delay,2012)——不是让缓冲区更大,而是主动丢弃那些在缓冲区里待了太久的包:
class CoDel:
"""CoDel 简化版:当包在队列里停留超过 target 就主动丢弃"""
def __init__(self, target_ms: float = 5.0, interval_ms: float = 100.0):
self.target = target_ms / 1000.0 # 5ms
self.interval = interval_ms / 1000.0 # 100ms
self.dropping = False
self.drop_next = float('inf')
def enqueue(self, pkt, queue, now: float):
"""决定是否丢弃新的入队包"""
sojourn_time = now - pkt.arrival_time # 包排队时长
min_sojourn = min(pkt.waiting_time for pkt in queue) if queue else 0
if min_sojourn < self.target:
self.dropping = False
self.drop_next = now + self.interval
return "ENQUEUE"
else:
if not self.dropping:
self.dropping = True
drop_time = now + self.interval / math.sqrt(2)
self.drop_next = drop_time
if now >= self.drop_next:
# 丢队尾包(主动通知发送端减速,比被动慢等要好)
self.drop_next = now + self.interval / math.sqrt(len(queue))
return "DROP"
return "ENQUEUE"CoDel 解决的核心矛盾:传统 AQM(主动队列管理,如 RED)需要精细调参(最小阈值、最大阈值、丢弃概率、权重)。CoDel 的绝妙在于无需任何配置参数——它只跟踪最小排队时延,如果排队时延超过 5ms 就主动丢包,然后缩短丢包间隔直到排队时延降下来。
目前 Linux 的默认 qdisc 已是 fq_codel(Fair Queuing + CoDel)——公平队列 + CoDel 的组合,给每条流独立的队列 + CoDel 管理。
常见陷阱:用 Python 模拟一个完整的 TCP 拥塞控制周期
下面把本章知识拼起来——从连接建立到慢启动、拥塞避免、丢包、快速恢复、再到稳定运行:
import matplotlib.pyplot as plt # 可选,用于可视化
import math
class TCPConnection:
def __init__(self, algo: str = "cubic"):
self.cwnd = 10 # 初始窗口(RFC 6928)
self.ssthresh = 64
self.rtt = 0.1 # 100ms
self.time = 0.0
self.algo = algo
self.W_max = 0
self.last_loss_t = 0.0
self.history = [] # (time, cwnd, phase)
def phase(self) -> str:
if self.cwnd < self.ssthresh:
return "SLOW_START"
elif self.algo == "cubic" and self.cwnd >= self.W_max * 0.9:
return "CUBIC_PROBE"
else:
return "CONG_AVOID"
def advance_rtt(self, lost: bool):
prev = self.cwnd
self.time += self.rtt
if lost:
if self.algo == "tahoe":
self.ssthresh = max(1, self.cwnd // 2)
self.cwnd = 1
elif self.algo == "reno":
self.ssthresh = max(1, self.cwnd // 2)
self.cwnd = self.ssthresh + 3
elif self.algo == "cubic":
self.W_max = self.cwnd
self.ssthresh = max(1, int(self.cwnd * 0.7))
self.cwnd = self.ssthresh
self.last_loss_t = self.time
self.history.append((self.time, prev, f"LOSS→{self.algo}"))
return
# 正常增长
if self.cwnd < self.ssthresh:
self.cwnd *= 2 # 慢启动(指数)
elif self.algo == "cubic":
K = (self.W_max * 0.3 / 0.4) ** (1/3)
elapsed = self.time - self.last_loss_t
target = 0.4 * ((elapsed - K) ** 3) + self.W_max
self.cwnd = max(self.cwnd + 1, target) # 至少线性增长
else:
self.cwnd += 1 # 拥塞避免(线性)
self.history.append((self.time, self.cwnd, self.phase()))
# 模拟 30 RTT,Tahoe vs Reno vs Cubic
tcp_tahoe = TCPConnection("tahoe")
tcp_reno = TCPConnection("reno")
tcp_cubic = TCPConnection("cubic")
for _ in range(30):
t = tcp_tahoe.time
# 在 1.3s 和 2.5s 触发丢包
lost = (0.3 < t < 0.4) or (0.9 < t < 1.0) or (1.6 < t < 1.7)
tcp_tahoe.advance_rtt(lost)
tcp_reno.advance_rtt(lost)
tcp_cubic.advance_rtt(lost)
# 打印最终窗口大小
print(f"Tahoe: cwnd = {tcp_tahoe.cwnd:.1f} (30 RTT after 3 losses)")
print(f"Reno: cwnd = {tcp_reno.cwnd:.1f} (30 RTT after 3 losses)")
print(f"Cubic: cwnd = {tcp_cubic.cwnd:.1f} (30 RTT after 3 losses)")如果装了 matplotlib,你可以把
history画成折线图——你会看到 Tahoe 每次丢包后的"断崖"远大于 Reno 和 CUBIC。CUBIC 的三次函数在接近上一次W_max时会自然"平躺",展现出比 Reno 更平滑的收敛。
通关挑战
深水区:以下公式推导和吞吐量计算属于深入内容。如果你对数学推导不感兴趣,理解「吞吐量与丢包率的平方根成反比」这个结论就够了。
挑战1:AIMD 吞吐量公式推导()
被广泛引用的 TCP 吞吐量公式:
吞吐量 ≈ (MSS × √(3/2)) / (RTT × √(丢包率))给定 MSS=1460, RTT=100ms, 丢包率=0.001,估算吞吐量。提示:这个公式基于 Reno 的 AIMD 行为推导——从数学上想清楚为什么吞吐量和丢包率的平方根成反比。
挑战2:慢启动多线程竞争模拟()
用 Python 模拟 3 个 TCP 流同时通过同一个瓶颈链路(带宽 100 Mbps,RTT 50ms,缓冲区 200 包)。每个流独立运行自己的拥塞控制。观察:
- 慢启动阶段 3 个流如何抢占带宽?
- 拥塞避免后,吞吐量是否收敛到公平(≈33 Mbps 每人)?
提示:你需要一个共享的 "瓶颈链路" 对象,所有流的包经过它时累加排队。
挑战3:Bufferbloat 仿真器()
创建两个场景:
- 缓冲区 = BDP × 1(RTT 20ms,带宽 50Mbps → 约 85 包)
- 缓冲区 = BDP × 10(约 850 包)
在新连接开始后的 10 秒内,每 100ms 采样一次实时 RTT(含排队延迟)。用结果说明 Bufferbloat 如何让 RTT 暴涨但丢包率几乎没有增加。
提示:Bufferbloat 可怕就可怕在——丢包率正常但延迟爆炸。这是 TCP 基于丢包的拥塞控制的阿喀琉斯之踵。
挑战4:用 iperf3 + ss 实测()
在 Linux 上做:
# 检查当前拥塞算法
sysctl net.ipv4.tcp_congestion_control
# 切换到 BBR
modprobe tcp_bbr
sysctl -w net.ipv4.tcp_congestion_control=bbr
# 用 iperf3 测试
iperf3 -c <server> -t 30 # 服务端用 -s
# 另一终端实时看 cwnd
ss -ti | grep -E "cwnd|cubic|bbr"看看不同算法在同一链路上 cwnd 曲线的差异。
验收标准
完成本章后,你应当可以:
- [ ] 画出 AIMD 的锯齿波曲线并解释每个转折的原因
- [ ] 说清慢启动、拥塞避免、快速重传、快速恢复的触发条件和行为
- [ ] 区分 Tahoe、Reno、CUBIC 在丢包处理上的关键差异
- [ ] 向一个 TCP 初学者解释为什么慢启动"实际上不慢"
- [ ] 解释 BBR 为什么不需要丢包信号来控制拥塞
- [ ] 用自己的话解释 Bufferbloat + CoDel 的关系
- [ ] 理解丢包率每降低 10 倍,Reno TCP 吞吐量约提升 3 倍——这就是为什么高速链路这么难(具体公式推导不要求掌握)
常见卡点
| 卡点 | 原因 | 解药 |
|---|---|---|
| cwnd 和 awnd(接收端窗口)是什么关系 | cwnd 是发送端"我怕网络扛不住"的自限,awnd 是接收端"我怕内存扛不住"的限流 | 实际发送窗口 = min(cwnd, awnd) |
| 为什么慢启动每 RTT 翻倍而不是每个 ACK 翻倍 | 每收到一个 ACK +1MSS → 一个窗口发 N 个包收 N 个 ACK → 窗口 += N → 翻倍 | 其实是同一个现象的描述角度不同 |
| BBR 真的不用丢包做信令吗 | 严格来说 BBR 也有丢包阈值(>20% 丢包率会降速),但正常网络下的丢包它不降速 | 这是和 AI 丢包信号算法的根本哲学差异 |
| CoDel 的 5ms target 哪来的 | 作者 Nichols 通过大量互联网实测发现 5ms 是"可容忍的排队延迟"的边界 | 不需要调——这是设计亮点 |
| CUBIC 的公平性有待商榷 | CUBIC 在共享瓶颈时可能比 Reno 占优,但 Linux 实现了尾损探测(Tail Loss Probe)辅助公平 | 没有完美的拥塞控制,只有不断演进的工程 tradeoff |
| 为什么拥塞控制和流量控制是两回事 | 流量控制看接收方的缓冲区(别人不能吃太多),拥塞控制看网络路径的容量(路太挤走慢点) | 记住:接收端 vs 网络→两个完全不同的瓶颈 |
现在不需要理解
- TCP Vegas:基于 RTT 变化的拥塞控制,不靠丢包——开源的学术研究,实际部署很少。如果你有兴趣了解"不基于丢包"的思路,直接看 BBR(Vegas 的更强大后代)。
- PRR(比例速率降低):Linux 3.2 引入的更精确的丢包恢复算法,替代 Reno 快速恢复中的简单 cwnd 切半。是大规模链路中对丢包响应的精细优化。
- 精确的 CUBIC 公式推导:CUBIC 论文中 W(t)=C×(t-³√(W_max×β/C))³+W_max 的完整推导——知道它是个三次函数就够。
- BBR 的四个阶段详细阐述:STARTUP、DRAIN、PROBE_BW、PROBE_RTT——知道 BBR 核心是探测 BtlBw + RTprop 就够了。
- tail-drop vs RED vs CoDel 的队列管理数学分析。知道"丢包前的队列行为各有各的数学模型"就够了——驿道守护法师选择上,fq_codel 是当前的推荐默认值。
旅人笔记
TCP 拥塞控制可能是计算机网络里最"有故事"的议题。它不是一个算法,而是一整条技术演进线——从 1988 年 Jacobson 在 LBL 的一篇论文(Congestion Avoidance and Control)开始,到 2020 年代谷歌主导的 BBR,跨越了三十多年。
这条演进路线的核心追问始终是同一个:如何在不破坏网络的前提下,尽可能多地发送数据?
不同的时代给出了不同的答案:
- 1988:丢包 = 拥塞 → 砍半(简单、有效、粗暴)
- 1990:丢包后不需要归零 → 快速恢复(更快收敛)
- 2006:大带宽 × 长延迟链路需要新函数 → CUBIC(三次函数更公平)
- 2016:无线时代丢包 ≠ 拥塞 → BBR(直接测物理量)
每个答案都是对前一个答案的不满足——因为它遇到了新的网络形态。这就是工程之美:没有完美的方案,只有持续逼近的过程。
一句话消化本章:TCP 用"增→减→增→减"的锯齿找到公平点,从 Tahoe 的「犯了错就打回原形」进化到 CUBIC 的「基于时间的细腻调节」再到 BBR 的「不再依赖丢包信号」。
→ 下一站预告
QUIC 与 HTTP/3——迎接 TCP 的继承人
你刚学会了 TCP 拥塞控制的全部细节。但 TCP 本身有一个根本性问题:队头阻塞。一个丢包卡住整条流。
下一章,我们看 QUIC 和 HTTP/3 怎么把传输层从 TCP 换到 UDP——0-RTT 握手、无队头阻塞、连接迁移。这是互联网传输层 40 年来最大的变革。