当“空闲”并不空闲:Linux 内核优化如何引发 QUIC 缺陷

Hacker News Top 工具

摘要

Cloudflare 详细描述了其 QUIC 实现 quiche 中的一个缺陷,该缺陷由 Linux 内核针对 CUBIC 拥塞控制的优化引发,并导致了性能问题,同时介绍了相应的修复方案。

暂无内容
查看原文 导出为 Word 导出为 PDF
查看缓存全文

缓存时间: 2026/05/13 03:10

# 当“空闲”不再空闲:一个 Linux 内核优化如何演变为 QUIC 漏洞 来源:https://blog.cloudflare.com/quic-death-spiral-fix/ 2026-05-12 阅读时间:10 分钟 CUBIC(在 RFC 9438 (https://www.rfc-editor.org/rfc/rfc9438.html) 中标准化)是 Linux 的默认拥塞控制器,因此它决定了互联网上大多数 TCP 和 QUIC 连接如何探测可用带宽、在检测到丢包时退让以及随后的恢复。在 Cloudflare,我们的 QUIC 开源实现 quiche (https://github.com/cloudflare/quiche) 使用 CUBIC 作为其默认拥塞控制器,这意味着这段代码处于我们要服务的很大一部分流量的关键路径中。 在本文中,我们将讲述一个 Bug 的故事:CUBIC 的拥塞窗口(cwnd)被永久锁定在其最小值,无法从拥塞崩溃事件中恢复。 故事的起点是一个 Linux 内核更改 (https://github.com/torvalds/linux/commit/30927520dbae297182990bb21d08762bcc35ce1d),旨在让 CUBIC 符合 RFC 9438 §4.2-12 (https://www.rfc-editor.org/rfc/rfc9438.html#section-4.2-12) 中描述的“应用限制排除”(app-limited exclusion)——这是一个针对 TCP 实际问题的修复,但当移植到我们的 QUIC 实现时,却在 quiche 中暴露出意想不到的行为。结局是圆满的:一个优雅的近单行修复打破了这一循环。 ## CUBIC 逻辑简述 在深入核心问题之前,先快速回顾一下拥塞控制算法(CCA)可能会有助于铺垫背景。 CCA 调整的核心旋钮是**拥塞窗口**(`cwnd`):发送方在任何时刻允许“在途”(已发送但尚未确认)的字节数上限。较大的 `cwnd` 让发送方在每个往返行程(RTT)中推送更多数据;较小的 `cwnd` 则会限制发送速率。每一个基于丢包的 CCA(包括 CUBIC)最终都是一项策略,用于在网络状况良好时如何增长 `cwnd`,以及在状况不佳时如何缩小它。 本质上,CCA 旨在通过推断网络的“可用带宽”来最大化数据传输;因为没人愿意支付 1 Gbps 的订阅费用却只使用其中的一小部分。CUBIC 所属的基于丢包的算法族基于一个基本前提:(1) 如果没有丢包,则增加发送速率(即增加带宽利用率);(2) 如果有丢包,基于丢包的算法假设网络容量已超过,发送方必须退让(即降低带宽利用率)。 BLOG-3273 image5 这种逻辑建立在多年来被多次审视的多个假设之上。然而,我们将把这一讨论留待另一次。 ## 症状:61% 时间失败的测试 我们的调查始于关于入口代理集成测试管道中出现意外失败的报告。这种不稳定的行为出现在评估 CUBIC 在连接早期阶段发生严重丢包的场景的测试中。 拥塞崩溃后的恢复是一种不常见的状态,但恰恰是拥塞控制器旨在处理的状态。大多数拥塞控制测试锻炼算法的稳态和增长阶段;很少有测试探究在连接被压制到最小 cwnd 后会发生什么。状态空间这个角落里的 Bug 在吞吐量仪表板中是不可见的,静态代码审查也无法检测到,只有当你故意将 CCA 驱动进入该状态并观察它是否能爬出时才会显现——而这正是该测试所做的。 模拟测试设置包括以下细节: BLOG-3273 image6 - 在本地(localhost)运行的 Quiche HTTP/3 客户端和服务器 - RTT = 10ms(在配置中设置) - 通过 HTTP/3 下载**10 MB 文件** - 使用**CUBIC**拥塞控制 - 在**前两秒**注入**30% 的随机丢包** - 两秒后,丢包完全停止 - 测试设有宽松的**10 秒超时**以完成下载,预期下载应在四到五秒内完成 预期行为很简单:CUBIC 在丢包阶段受到一些打击,减少其拥塞窗口,一旦丢包停止,就稳步提升并在规定时间内完成下载。相反,我们在多次 100 次运行中观察到,约 60% 的测试无法在宽松的 10 秒超时内完成下载。 ## 异常:999 次状态转换且零丢包 我们用丢包事件工具化了 quiche 的 qlog (https://github.com/cloudflare/quiche) 输出,并构建可视化以了解拥塞控制器内部发生的事情: BLOG-3273 image10 *失败测试的连接概览。在 T=2s 后,丢包完全停止——然而 cwnd 仍被锁定在最小值下限,拥塞状态在恢复和拥塞避免之间每 ~14ms 振荡一次。* 在两秒(2000 毫秒)标记之后,**丢包完全停止**。然而,在途字节数保持平坦,这与 CUBIC 算法的核心逻辑相矛盾:在没有丢包的情况下,应踩油门增加节流(在我们的世界中即更多字节)。*这提出了一个问题:如果网络不再丢包,为什么拥塞窗口无法增长?* 当我们放大该区域时,我们的分析显示 CUBIC 进入快速振荡,在我们的图表中显示为延长的恢复阶段,在拥塞避免状态(操作阶段)和恢复状态(丢包恢复状态)之间——**在大约 6.7 秒内有 999 次转换**。这意味着每 ~14ms 有一次转换——与连接的 RTT(10ms)惊人地接近。在整个这段时间内,cwnd 被锁定在最小下限:2700 字节,或两个满尺寸数据包。 显然,CUBIC 逻辑中的某些内容错误解读了连接的状态。关键线索是振荡周期:~14ms 与 RTT 匹配。触发恢复/避免翻转的事物正在每个往返行程中发生一次,与连接的 ACK 时钟同步;这是一种自时钟节奏,其中每个往返行程来自客户端的 ACK 触发服务器的下一次发送。由于这是一个下载(从服务器到客户端),相关的 ACK 从客户端传向服务器,而 CUBIC 的状态机运行在服务器端:每当这些 ACK 到达时,bytes_in_flight 降至零,服务器发送下一个两包突发,这正是触发 Bug 的原因。 为了确认此行为是 CUBIC 特有的,我们使用 Reno (https://dl.acm.org/doi/10.1145/235160.235162) 运行了相同的测试,Reno 是另一个基于丢包的家族成员,但具有不同的增长速率。结果具有决定性:100% 的通过率,显示 Reno 在丢包阶段后干净地恢复,并表明这是一个与 CUBIC 相关的 Bug。 BLOG-3273 image8 *Reno 在 T=2s 时丢包阶段结束后干净地恢复,并在 ~5s 时完成下载* ## 追踪根本原因 基于丢包的算法有两个踏板,油门和刹车,并在加速方式上有所不同。嗯,CUBIC 带有一些额外功能。在这里,我们将重点关注 bytes_in_flight == 0。 ### 空闲后的 TCP CUBIC(Linux, 2017) 要理解这个 Bug,我们首先需要理解它源自的优化。2017 年,Linux 内核的 CUBIC 实现中发现了一个问题。提交信息 (https://github.com/torvalds/linux/commit/30927520dbae297182990bb21d08762bcc35ce1d) 解释道: > Epoch(纪元)仅在初始化和经历丢包时更新/重置。在应用空闲后,`now - epoch_start` 的 delta “t” 以及 `bic_target` 可能变得任意大。因此,斜率(`ca->cnt` 的倒数)会非常大,最终 `ca->cnt` 会被下限限制为 2,以实现延迟 ACK 慢启动行为。当禁用 `slow_start_after_idle` 时,这特别表现为在几秒钟的空闲时间后危险的 cwnd 膨胀(1.5 x RTT)。 **Epoch** 是 CUBIC 用于锚定其增长曲线的参考时间戳:`W_cubic(delta_t)` 由 `delta_t = now - epoch_start` 参数化,每当 CUBIC 重启其增长函数时(最明显的是在丢包事件减少 `cwnd` 后),epoch 会被重置。在重置之间,`delta_t` 随着挂钟时间单调增长。 当应用程序空闲(停止发送)一段时间然后恢复时,CUBIC 增长函数 `W_cubic(delta_t)` 计算 `delta_t` 为 `now - epoch_start`,如下图所示。由于在空闲期间未更新 epoch,`delta_t` 巨大,产生巨大的目标窗口——CUBIC 将立即尝试将 `cwnd` 膨胀到不合理的值。 BLOG-3273 image7 Jana Iyengar 的初始修复是在应用程序恢复发送时重置 `epoch_start`。但 Neal Cardwell 指出 (https://github.com/torvalds/linux/commit/30927520dbae297182990bb21d08762bcc35ce1d) 该方法的缺陷: > ...这会要求 CUBIC 算法重新计算曲线,以便我们再次从 cwnd 当前所在的位置开始陡峭地向上增长(就像 CUBIC 在丢包后所做的那样)。理想情况下,我们希望 cwnd 增长曲线保持相同的形状,只是在时间上向后平移空闲时期的长度。 由 Eric Dumazet、Yuchung Cheng 和 Neal Cardwell 撰写的优雅解决方案是**将 epoch 向前移动空闲持续时间**,而不是重置它。这保留了 CUBIC 增长曲线的形状——只是将其在时间上滑动,以便算法从离开的地方继续。 ### 移植到 quiche(2020) 当 CUBIC 首次在 quiche 中实现 (https://blog.cloudflare.com/cubic-and-hystart-support-in-quiche/) 时,此空闲时段调整被移植。然而,在用户空间运行的 QUIC 没有 TCP 的内核级 `CA_EVENT_TX_START` (https://github.com/torvalds/linux/commit/30927520dbae297182990bb21d08762bcc35ce1d) 回调。相反,quiche 实现在 `on_packet_sent()` 内部检查空闲条件: ```rust // 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 的修复中包含原始内核更改中的一个 Bug,该 Bug 在一周后通过针对内核 cubic 模块的后续更改 (https://github.com/torvalds/linux/commit/c2e7204d180f8efc80f27959ca9cf16fa17f67db) 得到修复。第二次修复的提交信息解释道: > `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` 按照所有基于丢包的 CCA 共享的基于 Reno 风格的 ACK 规则增长——立方曲线及其对 `congestion_recovery_start_time` 的敏感性仅在连接进入拥塞避免后才会出现,这意味着陷阱需要同时具备三个条件:真实的丢包事件设置恢复边界、拥塞避免正在运行,以及 `cwnd` 崩溃至两包下限。 BLOG-3273 image3 *自我维持的恢复陷阱。在最小 cwnd 时,每个 ACK 周期都会触发带有膨胀 delta 的空闲时段调整。* 在最小 cwnd(两个数据包)时,连接的动态转变为“死亡螺旋”,其中空闲时段优化成为自我实现的预言。此陷阱在连续循环中运行: 1. **发送和 ACK 数据包:**发送方传输整个两包窗口。在一个 RTT(~14ms)后,两个数据包都被 ACK,导致 bytes_in_flight 降至零。 2. **虚假的空闲检测:**当发送下一个突发时,`on_packet_sent()` 看到 bytes_in_flight == 0 并假设连接处于空闲状态,但实际上它是受拥塞限制。 3. **膨胀的 Delta:**计算使用**now - last_sent_time**来确定空闲持续时间。当拥塞窗口(`cwnd`)处于最小值时,`last_sent_time` 是*前一个* RTT 周期*开始*的时间戳。因此,结果 delta 约为**14ms**(连接的 RTT + 额外的舍入误差)。此 RTT 大小的 delta 被错误地应用为“空闲”时间。连接实际空闲的时间(最后一个 ACK 到达与下一个数据包发送之间的处理间隙)实际上为 0。通过测量整个 RTT 而不是真实间隙,delta 被**显著膨胀**,激进地将恢复开始时间向前移动,甚至可能移到未来。 4. **感知恢复:**由于恢复开始时间现在在未来,`in_congestion_recovery()` 检查对每个传入 ACK 返回 true。处理下一个 ACK 退出恢复并将恢复开始设置为 ACK 时间,该时间大于 last_sent_time,使得拥塞控制器在下一次发送时将恢复时间推向未来成为可能。 5. **停滞:**由于 CUBIC 跳过任何被视为处于恢复期的数据包的 `cwnd` 增长,窗口保持在两个数据包——确保管道在下一个 ACK 时完全排空并重新启动循环。 **此循环重复数千次,直到来自调度程序抖动和 ACK 处理方差的微小偏差累积,使 `in_congestion_recovery()` 中的 <= 边界滑到下一个数据包的发送时间之后,打破循环。** ## 修复:从正确的时刻测量空闲 修复死亡螺旋涉及从 bytes_in_flight 实际变为零的时刻(最后处理的 ACK)而不是最后发送的数据包来测量空闲持续时间。 ### 代码更改 1. **添加 last_ack_time 时间戳**到 CUBIC 状态。 2. **更新该时间戳**当 ACK 到达时。 3. **使用它**进行空闲 delta 计算: ```rust // 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 ```

相似文章

QuIDE:通过主动优化掌握量化智能权衡

arXiv cs.LG

本文介绍了 QuIDE 框架,该框架利用智能指数来评估量化神经网络在压缩、准确性和延迟之间的权衡。研究证明,最佳位宽因任务而异:对于大型语言模型(LLM)和简单任务,4-bit 是最理想的;而对于复杂的卷积神经网络(CNN),8-bit 则更为合适。

愚蠢的RCU技巧:边界案例RCU实现

Lobsters Hottest

Paul McKenney 讨论了非常规和边界情况的 RCU(读-拷贝-更新)实现,包括早期 Unix 系统中使用的定时等待 RCU 方法,以及与内存隔离相关的固定缓冲区 RCU 概念,展示了内核开发中具有创造性但潜在危险的同步技术。

qwen3.6 突然中断

Reddit r/LocalLLaMA

用户报告在使用 vLLM 配合特定 Docker 配置及投机解码(speculative decoding)部署 Qwen 3.6 模型时,模型会在任务中途停止生成。