Hotdry.

Article

When "idle" isn't idle: how a Linux kernel optimization became a QUIC bug

深入分析Linux内核CUBIC拥塞控制器的idle优化与QUIC协议idle检测机制冲突导致的cwnd死锁问题,揭示从内核到用户空间QUIC移植中的语义偏差及一行式修复方案。

2026-05-13systems

CUBIC 作为 Linux 内核默认的拥塞控制算法,统治着大多数 TCP 和 QUIC 连接在公共互联网上的带宽探测与恢复逻辑。Cloudflare 的开源 QUIC 实现 quiche 同样以 CUBIC 作为默认拥塞控制器,这意味着该代码位于我们服务流量转发路径的关键位置。然而,在 2026 年 5 月 12 日披露的一个 bug 中,CUBIC 的拥塞窗口(cwnd)在经历拥塞崩溃事件后会被永久锁定在最小值,永远无法恢复。这个问题最初来自 Linux 内核 2017 年的一次优化,当它被移植到用户空间的 QUIC 实现时,意外触发了与 QUIC 流量模式的冲突,形成了一个自激振荡的 "死亡螺旋"。

问题表象:测试失败率 61%

调查始于 quiche 集成测试管道中意外失败的报告。这个问题出现在使用 CUBIC 评估连接早期重度丢包场景的测试中。测试环境包括:本地运行的 quiche HTTP/3 客户端和服务器,RTT 设置为 10 毫秒,通过 HTTP/3 下载 10MB 文件,使用 CUBIC 拥塞控制,在前两秒内注入 30% 随机丢包,两秒后完全停止丢包,测试超时时间设为 10 秒。预期的正常行为是 CUBIC 在丢包阶段承受一些打击后降低拥塞窗口,一旦丢包停止便稳步上升并在超时前完成下载。然而在多次 100 轮测试运行中,约 60% 的测试无法在宽裕的 10 秒超时内完成下载。

更异常的现象出现在连接概览图中:两秒标记后数据包丢失完全停止,但飞行中的字节数保持平直,这违反了 CUBIC 算法的核心逻辑 —— 在没有丢包的情况下应该加大油门增加发送。当放大该区域分析时,发现 CUBIC 进入快速振荡状态,在约 6.7 秒内发生了 999 次状态转换,在恢复状态和拥塞避免状态之间循环往复,每次转换间隔约 14 毫秒 —— 与连接的 RTT(10 毫秒)惊人地接近。值得注意的是,在整个这段时期内,cwnd 被锁定在最小值:2700 字节,即两个完整数据包。这种恢复 / 避免切换的频率与 ACK 时钟锁定同步,表明问题与 ACK 处理逻辑深度耦合。

为了确认这是 CUBIC 特有的问题,团队使用 Reno(另一个基于丢包的拥塞控制算法但增长速率不同)运行了相同测试。结果明确:100% 通过率,Reno 在丢包阶段后干净地恢复,证实这确实是 CUBIC 相关的问题。

根因追踪:内核优化与 QUIC 语义冲突

要理解这个 bug,首先需要理解它源自何处。2017 年,Linux 内核 CUBIC 实现中发现了一个问题:当应用程序进入空闲状态(停止发送)一段时间后恢复发送时,CUBIC 增长函数 W_cubic (delta_t) 计算的 delta_t(即 now - epoch_start)会非常大,因为 epoch 在空闲期间没有被更新。这会产生一个巨大的目标窗口,导致 CUBIC 立即尝试将 cwnd 膨胀到不合理的值。

Jana Iyengar 最初的修复是在应用程序恢复发送时重置 epoch_start。但 Neal Cardwell 指出了这种方法的问题:它要求 CUBIC 算法重新计算曲线,使其再次从当前 cwnd 位置开始陡峭增长 —— 理想情况下应该是保持曲线形状不变,只是沿时间轴向后移动空闲时段的长度。

最终方案由 Eric Dumazet、Yuchung Cheng 和 Neal Cardwell 提出,采用将 epoch 向前移动空闲持续时间而非重置的方法。这保留了 CUBIC 增长曲线的形状 —— 只是沿时间轴滑动,使算法从上次中断处继续。

当 CUBIC 最初在 quiche 中实现时,这个空闲时段调整被移植了过来。然而,QUIC 运行在用户空间,没有 TCP 内核级别的 CA_EVENT_TX_START 回调。相反,quiche 实现在 on_packet_sent () 中检查空闲条件:当 bytes_in_flight == 0 时,用 now - last_sent_time 计算增量,并将 congestion_recovery_start_time 向前移动这个增量。

问题出在这里:恢复开始时间是在 ACK 处理期间设置的,但基于发送时间计算调整会将恢复开始时间推入未来。这解释了测试中观察到的恢复和拥塞避免之间的振荡。这个陷阱只在每个传入的 ACK 都将 bytes_in_flight 一路驱动到零时持续触发 —— 在实践中,这意味着 cwnd 已崩溃到最小值(两个数据包)且应用程序在 ACK 到达的瞬间准备发送下一个完整窗口。

死亡螺旋机制详解

在最小 cwnd(两个数据包)时,连接动力学进入一个 "死亡螺旋",其中空闲期优化变成一个自我实现的预言。这个陷阱以连续循环方式运作:

第一步,发送和确认数据包:发送方传输整个两个数据包的窗口。经过约 14 毫秒(一个 RTT)后,两个数据包都被确认,导致 bytes_in_flight 降到零。第二步,错误的空闲检测:当下一个突发发送时,on_packet_sent () 看到 bytes_in_flight == 0,假设连接处于空闲状态,但实际上它是受拥塞限制的。第三步,膨胀的增量:计算使用 now - last_sent_time 来确定空闲持续时间。当拥塞窗口处于最小值时,last_sent_time 是前一个 RTT 周期开始的时间戳。因此产生的增量约为 14 毫秒(连接的 RTT 加上额外的舍入误差)。这个 RTT 大小的增量被错误地应用为 "空闲" 时间。实际上连接空闲的时间(最后一个 ACK 到达和下一个数据包发送之间的处理间隔)实际上是 0。通过测量整个 RTT 而非真实间隙,增量被显著膨胀,将恢复开始时间向前激进地移动,可能进入未来。第四步,感知恢复:因为恢复开始时间现在在未来,in_congestion_recovery () 检查对每个传入的 ACK 返回 true。下一个 ACK 的处理退出恢复并将恢复开始时间设置为 ACK 时间,这比 last_sent_time 更大,使得拥塞控制器在下一次发送时很可能将恢复时间推入未来。第五步,停滞:由于 CUBIC 跳过任何被认为处于恢复期间的数据包的 cwnd 增长,窗口保持锁定在两个数据包 —— 确保管道在下一个 ACK 上完全排空,重新启动循环。

这个循环重复数千个周期,直到累积的小偏差 —— 来自调度器抖动和 ACK 处理方差 —— 让 in_congestion_recovery () 中的 <= 边界滑到下一个数据包的发送时间之后,打破循环。

修复方案:测量真实最后活动时间

修复死亡螺旋涉及从 bytes_in_flight 实际转换到零的时刻(最后一个 ACK 处理的时间)测量空闲持续时间,而非从最后一个发送的数据包测量。具体修改包括三行代码:首先添加 last_ack_time 时间戳到 CUBIC 状态;其次在 ACK 到达时更新该时间戳;最后使用 max (last_ack_time, last_sent_time) 计算空闲增量。

这个修复的核心理念是:当 cwnd 处于最小值且 bytes_in_flight 在 ACK 和发送之间瞬时达到零时,仅使用 last_sent_time 会将增量膨胀一个完整的 RTT。使用两个时间戳中的最大值,可以正确捕捉到实际最后活动时间。在真正空闲的连接上,last_ack_time 远远处于过去,同样的表达式会捕获完整的空闲持续时间,原始的 epoch 平移行为得以保留。

修复后,测试套件的 100% 通过率得以恢复。在修复后的可视化中,cwnd 沿预期的 CUBIC 曲线增长,下载在约 4-5 秒内完成。曲线显示 cwnd 从最小值稳步增长,连接在丢包停止后干净地恢复,不再陷入死循环。

工程启示

这个案例揭示了几个重要的工程教训。首先,"idle" 比看起来更难定义。在小窗口时的正常管道延迟对于简单检查来说可能看起来像空闲。看似无害的优化在边界条件下可能产生完全不同的行为。

其次,最小 cwnd 动力学是一个独特的角落情况。这个 bug 在高速度下是不可见的,只在严重丢包后触发。当 cwnd 处于最小值时,连接进入一种特殊状态,其中 ACK 时钟的每个周期都可能触发非预期的状态转换。

第三,问题发现与修复的规模完全不成比例。经过数周使用 qlog 进行仪器化和分析可视化来找到根因之后,解决方案只需要修改三行代码。正如调查过程中注意到的:找到 bug 的努力是巨大的,但修复本身基本上就是一行逻辑。

这个修复已经贡献给 cloudflare/quiche——Cloudflare 的开源 QUIC 和 HTTP/3 实现。拥塞控制工作不仅限于基于丢包的算法:quiche 的模块化拥塞控制设计还支持 BBRv3 等基于模型的实现实验和调优,目前已在越来越多的 QUIC 部署中启用。

来源:https://blog.cloudflare.com/quic-death-spiral-fix/

systems

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com