当“空闲”不为空闲:Linux内核优化如何成为QUIC的bug

TL;DR · AI 摘要
Cloudflare发现了一个QUIC实现中的bug,该bug导致CUBIC拥塞控制算法在遇到网络拥塞时无法恢复,最终导致连接失败。
核心要点
- CUBIC拥塞控制算法在Linux内核中的一个优化导致了QUIC实现中的bug。
- 测试表明,在高丢包率情况下,约60%的下载未能在10秒内完成。
- 通过一个简单的修复,解决了这个循环问题。
结构提纲
按章节快速跳转。
CUBIC作为拥塞控制算法的核心是拥塞窗口(cwnd),用于控制发送方的数据传输速率。
在高丢包率情况下,约60%的测试未能在10秒内完成文件下载。
通过分析quiche的qlog输出,发现了999次状态转换但没有丢包的情况。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- QUIC Bug
- CUBIC拥塞控制
- 拥塞窗口(cwnd)
- 测试失败
- 高丢包率
- 60%失败率
- 状态转换
- 999次转换
金句 / Highlights
值得收藏与分享的关键句。
CUBIC拥塞控制算法在Linux内核中的一个优化导致了QUIC实现中的bug。
测试表明,在高丢包率情况下,约60%的下载未能在10秒内完成。
通过一个简单的修复,解决了这个循环问题。
当“空闲”并不空闲:Linux 内核优化如何变成 QUIC 错误
来源 URL: https://blog.cloudflare.com/quic-death-spiral-fix/
发布时间: 2026-05-12T14:00+01:00

CUBIC 是标准化于 RFC 9438 的拥塞控制算法,在 Linux 中默认使用 CUBIC 来管理大多数 TCP 和 QUIC 连接的带宽探测、丢包后退避以及恢复。在 Cloudflare,我们的开源 QUIC 实现 quiche 默认也使用 CUBIC 作为其拥塞控制器,这意味着该代码处于我们处理的大量流量的关键路径中。
本文将讲述一个错误,其中 CUBIC 的拥塞窗口(cwnd)永久固定在其最小值,并且在发生拥塞崩溃事件后无法恢复。
故事始于一个针对 CUBIC 的 Linux 内核更改,旨在使其符合 RFC 9438 §4.2-12 中描述的应用限制排除——这是一个针对 TCP 的真实问题的修复,但在移植到我们的 QUIC 实现时,在 quiche 中出现了意外行为。故事有一个圆满的结局:一个优雅的(近)一行修复解决了这个问题。
CUBIC 的逻辑概述
在深入探讨核心问题之前,快速回顾一下拥塞控制算法(CCA)可能会有所帮助。
CCA 调整的核心旋钮是 拥塞窗口(cwnd):发送方对可以同时飞行(已发送但尚未确认)的字节数量的上限。更大的 cwnd 允许发送方每轮次发送更多数据;较小的 cwnd 则会限制发送速度。每个基于丢包的 CCA,包括 CUBIC 在内,最终都是关于在网络看起来健康时如何增加 cwnd,以及在网络状况不佳时如何减少 cwnd 的策略。
本质上,CCA 的目标是通过推断网络的“可用带宽”来最大化数据传输;因为没有人愿意支付 1 Gbps 的订阅费用却只使用其中的一部分。属于基于丢包算法家族的 CUBIC 基于一个基本前提运行:(1) 如果没有数据包丢失,则增加发送速率(即增加带宽利用率);(2) 如果有丢失,则基于丢包的算法假设网络容量已被超过,发送方必须退避(即降低带宽利用率)。

这种逻辑建立在多年来不断重新审视的几个假设之上。不过,我们将在以后讨论这些假设。
症状:失败率为 61% 的测试
我们的调查始于报告的入口代理集成测试管道中的意外失败。这种异常行为出现在评估 CUBIC 在连接早期阶段严重丢包场景下的测试中。
拥塞崩溃后的恢复是一个不太常见的状态,但正是拥塞控制器存在的意义所在。大多数拥塞控制测试都涉及算法的稳态和增长阶段;很少有人探查在最小 cwnd 下会发生什么,当连接已经被压垮之后。这个状态空间角落中的错误在吞吐量仪表板中不可见,静态审查也无法检测到,只有当你故意驱动 CCA 进入并观察它是否能爬出来时才会显现——而这正是此测试所做的。
模拟测试设置包括以下细节:

- 在本地运行的 quiche HTTP/3 客户端和服务器(localhost)
- RTT = 10毫秒(配置中设定)
- 通过 HTTP/3 下载 10 MB 文件
- 使用 CUBIC 拥塞控制
- 在 前两秒 注入 30% 随机数据包丢失
- 两秒后,丢包完全停止
- 测试具有 10秒超时,预期下载应在四到五秒内完成
预期行为很简单:CUBIC 应在丢包阶段遭受一些损失,减少其拥塞窗口,一旦丢包停止,应稳步增加并及时完成下载。然而,我们在多次 100 次运行中观察到,大约 60% 的测试未能在慷慨的 10 秒超时内完成下载。
异常:999 次状态转换且无丢包
我们通过在 quiche 的 qlog 输出中加入数据包丢失事件并构建可视化工具来理解拥塞控制器内部发生了什么:

_失败测试的连接概览。在 T=2秒后,数据包丢失完全停止——然而 cwnd 仍然固定在最小值,拥塞状态每约 14 毫秒在恢复和拥塞避免之间振荡。_
在两秒(2000 毫秒)标记之后,数据包丢失完全停止。然而,飞行中的字节数量保持平坦,这与 CUBIC 算法的核心逻辑相矛盾:在没有丢包的情况下,应该增加节流(在我们的世界中就是更多的字节)。_这引发了一个问题:如果网络不再丢弃数据包,为什么拥塞窗口无法增长?_
当我们放大该区域时,我们的分析显示 CUBIC 进入了快速振荡,在拥塞避免状态(操作阶段)和恢复状态(丢包恢复状态)之间切换——在大约 6.7 秒内发生了 999 次转换。这意味着每约 14 毫秒发生一次转换——这与连接的 RTT(10 毫秒)非常接近。在整个这段时间里,cwnd 被锁定在最低值:2700 字节,即两个完整大小的数据包。
显然,CUBIC 的逻辑对连接状态产生了误解。关键线索在于振荡周期:约 14 毫秒与 RTT 匹配。触发恢复/避免翻转的因素每往返一次就会发生一次,并且与连接的 ACK 时钟同步;每次往返的 ACK 从客户端触发服务器的下一次发送。因为这是一个下载(从服务器到客户端),相关的 ACK 从客户端传送到服务器,而 CUBIC 的状态机运行在服务器端:每当这些 ACK 到达时,bytes_in_flight 就会降至零,服务器发送下一个两包突发,这正是触发该错误的原因。
为了确认这种行为是否特定于 CUBIC,我们使用了另一个基于丢包的算法成员 Reno,它具有不同的增长速率进行了相同的测试。结果非常明确:100% 通过率,表明 Reno 在丢包阶段结束后能够干净地恢复,揭示了这是与 CUBIC 相关的错误。

_Reno 在丢包阶段结束(T=2s)后干净地恢复,并在约 5s 时完成下载_
追踪根本原因
基于丢包的算法有两个踏板,加速和减速,其加速方式有所不同。然而,CUBIC 带有一些额外的功能。我们将重点关注 bytes_in_flight == 0 的情况。
2017 年 Linux 内核中的 TCP CUBIC 在空闲后
为了理解这个错误,我们需要先了解它来自的优化。2017 年,发现了一个关于 Linux 内核 CUBIC 实现的问题。提交信息解释道:
该时期仅在初始时和遇到丢包时更新/重置。
now - epoch_start的时间差delta t可以在应用程序空闲后变得非常大,以及bic_target。因此,斜率(ca->cnt的倒数)会变得非常大,最终ca->cnt会被下限限制为 2,以实现延迟 ACK 的慢启动行为。当禁用
slow_start_after_idle时,这会在几秒钟的空闲时间后显示出危险的 cwnd 扩张(1.5 倍 RTT)。
时期 是 CUBIC 用来锚定其增长曲线的时间戳:W_cubic(delta_t) 由 delta_t = now - epoch_start 参数化,当 CUBIC 重新开始其增长函数时,例如在丢包事件减少 cwnd 后,时期会被重置。在重置之间,delta_t 随着墙钟时间单调递增。
当应用程序空闲一段时间后重新开始发送时,CUBIC 增长函数 W_cubic(delta_t) 计算 delta_t 为 now - epoch_start,如下图所示。由于时期在空闲期间没有更新,delta_t 会变得非常大,产生一个巨大的目标窗口——CUBIC 会立即尝试将 cwnd 扩张到一个不合理值。

Jana Iyengar 的初步修复方法是在应用程序重新开始发送时重置 epoch_start。但 Neal Cardwell 指出这种方法的缺陷:
…这会让 CUBIC 算法重新计算曲线,使我们再次从当前 cwnd 开始陡峭向上增长(就像丢包后 CUBIC 所做的那样)。理想情况下,我们希望 cwnd 增长曲线形状相同,只是在时间上向后移动了空闲期的长度。
Eric Dumazet、Yuchung Cheng 和 Neal Cardwell 提供了一个优雅的解决方案,即将时期向前移动空闲期的持续时间,而不是重置它。这保留了 CUBIC 增长曲线的形状——只是在时间上滑动,以便算法继续之前的状态。
2020 年移植到 quiche
当 CUBIC 首次实现在 quiche 中时,这个空闲期调整也被移植了。然而,运行在用户空间的 QUIC 没有 TCP 内核级别的 `CA_EVENT_TX_START` 回调。相反,quiche 实现检查 on_packet_sent() 中的空闲条件:
// cubic.rs — on_packet_sent()(简化版)
/// 当发送数据包时更新状态。
fn on_packet_sent(&mut self, bytes_in_flight: usize, now: Instant, ...) {
// 如果发送突发正在重启(即,发送前 bytes_in_flight 为零),
// 调整拥塞恢复开始时间以考虑发送间隔。
if bytes_in_flight == 0 {
let delta = now - self.last_sent_time;
self.congestion_recovery_start_time += delta;
}
// 记录此次发送事件的时间。
self.last_sent_time = now;
}失效之处:QUIC 的差异
移植到 quiche 的修复中包含了一个原始内核更改中的错误,该错误在大约一周后通过 对内核 CUBIC 模块的后续更改得到了修正。第二次修复的提交信息解释了:
tcp_cubic: 不要在未来设置epoch_start在bictcp_cwnd_event()中跟踪空闲时间不够精确,因为epoch_start通常是在 ACK 处理时设置的,而不是在发送时设置的。进行正确的修复需要添加一个额外的状态变量,考虑到 CUBIC 的这个 bug 已经存在很久了,直到 Jana 才注意到它,这样做似乎不值得。
让我们简单地不在未来设置
epoch_start,否则bictcp_update()可能会溢出,CUBIC 会再次过快地增长cwnd。
正如提交消息中提到的,恢复开始时间是在 ACK 处理期间设置的,基于发送时间计算调整可能会将恢复开始时间推到未来。这解释了我们在测试中看到的恢复和拥塞避免之间的振荡现象。只有当每个传入的 ACK 都将 bytes_in_flight 完全归零时,这个陷阱才会持续触发——实际上这意味着 cwnd 已经收缩到了最小值(两个数据包),并且应用程序准备好在 ACK 到达时立即发送另一个完整窗口的数据。在这个模式之外,bytes_in_flight == 0 在每次发送时不太可能成立,因此触发该 bug 的可能性较小。
为什么连接启动时不会发生这种情况?这个 bug 只有在连接退出慢启动并切换到拥塞避免时才会触发。在退出慢启动之前,congestion_recovery_start_time 没有被设置,因此 on_packet_sent 中的错误分支没有恢复边界可以推进。在慢启动期间,CUBIC 的 cwnd 增长遵循所有基于丢失的拥塞控制算法共享的 Reno 式 ACK 规则——立方曲线及其对 congestion_recovery_start_time 的敏感性仅在连接进入拥塞避免阶段后才发挥作用,这意味着陷阱需要同时具备三个条件:真正的丢包事件来设置恢复边界,拥塞避免正在运行,以及 cwnd 收缩到两个数据包的最低点。

_自我延续的恢复陷阱。在最小 cwnd 下,每个 ACK 周期都会触发带有膨胀增量的空闲周期调整。_
在最小 cwnd(两个数据包)下,连接的动力学转变为一种“死亡螺旋”,其中空闲周期优化变成了自证预言。这个陷阱在一个连续循环中运作:
- 发送和确认数据包:发送方传输整个两个数据包的窗口。经过一个 RTT(约 14ms)后,这两个数据包都被确认,导致
bytes_in_flight降为零。
- 误判空闲状态:当下一个突发数据包发送时,
on_packet_sent()发现bytes_in_flight == 0并假设连接处于空闲状态,但实际上它是拥塞受限的。
- 膨胀增量:计算使用 现在时间 - 最后发送时间 来确定空闲持续时间。当拥塞窗口 (
cwnd) 处于最小值时,last_sent_time是前一个 RTT 周期开始的时间戳。因此,结果增量约为 14ms(连接的 RTT 加上额外的舍入误差)。这个 RTT 尺度的增量被错误地应用为空闲时间。实际上,连接空闲的时间(最后一个 ACK 到达与下一个数据包发送之间的处理间隔)几乎为零。通过测量完整的 RTT 而不是实际的间隔,增量被显著夸大,激进地将恢复开始时间向前推进,可能进入未来。
- 感知恢复:由于恢复开始时间现在在未来,
in_congestion_recovery()检查对于每个传入的 ACK 返回真值。处理下一个 ACK 退出恢复并将恢复开始时间设置为 ACK 时间,该时间大于last_sent_time,使得拥塞控制器在下次发送时有可能将恢复时间推入未来。
- 停滞:由于 CUBIC 对任何被认为处于恢复期的数据包跳过了
cwnd增长,窗口保持在两个数据包——确保在下一个 ACK 时管道完全排空并重新启动循环。
这个循环重复数千次,直到小偏差(来自调度器抖动和 ACK 处理差异)累积,让in_congestion_recovery()中的 <= 边界落后于下一个数据包的发送时间,打破循环。
解决方案:从正确的时间点测量空闲时间
解决死亡螺旋涉及从 bytes_in_flight 实际过渡到零(最后一个处理的 ACK)而不是最后一个发送的数据包开始测量空闲时间。
代码更改
- 向 CUBIC 状态添加 `last_ack_time` 时间戳。
- 当 ACK 到达时更新该时间戳。
- 用于空闲增量计算:
// cubic.rs — on_packet_sent()
fn on_packet_sent(&mut self, bytes_in_flight: usize, now: Instant, ...) {
// 检查在发送此数据包之前连接是否处于空闲状态。
if bytes_in_flight == 0 {
if let Some(recovery_start_time) = r.congestion_recovery_start_time {
// 从最近的活动测量空闲时间:要么是最后一个 ACK(近似表示 `bif` 达到 0 的时间),要么是最后一个数据发送时间,取较晚者。单独使用 `last_sent_time` 会在 `cwnd` 很小且 `bif` 在 ACK 和发送之间短暂达到 0 时使增量增加一个完整的 RTT。
let idle_start = cmp::max(cubic.last_ack_time, cubic.last_sent_time);
if let Some(idle_start) = idle_start {
if idle_start < now {
let delta = now - idle_start;
r.congestion_recovery_start_time =
Some(recovery_start_time + delta);
}
}
}
}
}现在增量反映了自最后一个 ACK 以来的实际间隔,恢复边界停止追赶发送时间:

_旧代码:边界每周期前进一个RTT,总是落在或领先于下一个发送时间点。_

_修复:边界几乎不动;下一个发送时间点落在其前方,且拥塞窗口增长。_
对于真正空闲的连接,`last_ack_time` 远在过去,同样的表达式可以捕获完整的空闲时长,原始的时间偏移行为得以保留。
## 验证
应用修复后,我们的quiche测试套件恢复了100%的通过率。

_修复后,拥塞窗口沿预期的CUBIC曲线增长,下载在约4-5秒内完成。_
我们不必担心连接末尾的丢失——这是预料之中的,因为我们完全利用了路由器分配的缓冲区。换句话说,在这个测试用例中,我们充分利用了可用带宽。
## 总结
* **“空闲”比听起来更难定义。** 小窗口下的正常流水线延迟可能看起来像空闲状态。
* **最小拥塞窗口动态是一个独特的边缘情况。** 在高速下该错误不可见,仅在严重丢包后触发。
* **修复相对于复杂的行为来说非常小。** 经过数周的qlog调试和可视化分析以找到根本原因,解决方案只需修改三行代码。正如我们在调查过程中所指出的:找到错误的努力巨大,但修复本身基本上只是一行逻辑。
本文所述的修复已贡献给 `cloudflare/quiche`,这是Cloudflare开源实现的QUIC和HTTP/3。我们的拥塞控制算法不仅限于基于丢包的算法:我们还使用quiche的模块化拥塞控制设计来实验和调整基于模型的[BBRv3](https://blog.cloudflare.com/new-standards/#congestion-control)实现,现在这一实现已启用在越来越多的QUIC部署中。敬请关注关于QUIC拥塞控制实现和性能的进一步更新。
如果你对拥塞控制、传输协议或参与开源网络代码开发感兴趣,请查看**quiche**仓库。我们一直在寻找热爱解决这类问题的优秀工程师,请浏览我们的[职位空缺](https://www.cloudflare.com/careers/)。
[拥塞控制](https://blog.cloudflare.com/tag/congestion-control/)[调试](https://blog.cloudflare.com/tag/debugging/)[QUIC](https://blog.cloudflare.com/tag/quic/)[QUICHE](https://blog.cloudflare.com/tag/quiche/)[网络](https://blog.cloudflare.com/tag/networking/)[HTTP3](https://blog.cloudflare.com/tag/http3/)[Rust](https://blog.cloudflare.com/tag/rust/)