当“空闲“并非真空闲:Linux内核优化如何演变为QUIC漏洞
摘要:本文深入剖析了CUBIC拥塞控制算法在QUIC实现中的一个隐蔽bug——当拥塞窗口降至最小值时,算法会陷入"死亡螺旋",导致cwnd永久锁定无法恢复。问题根源在于Linux内核的空闲检测优化被错误移植到QUIC环境:
on_packet_sent()函数使用last_sent_time而非last_ack_time计算空闲间隔,导致恢复边界被错误前移。通过系统性排查(症状观察→状态分析→对比测试→代码溯源),最终仅需一行代码修复——将delta计算从`now
- last_sent_time
改为now - last_ack_time`,即可打破循环,恢复100%测试通过率。

阅读时间:10分钟
CUBIC(在RFC 9438中标准化)是Linux的默认拥塞控制器,因此它决定了公共互联网上大多数TCP和QUIC连接如何探测可用带宽、检测丢包时退避以及后续恢复。在Cloudflare,我们的QUIC开源实现quiche使用CUBIC作为默认拥塞控制器,这意味着这段代码位于我们所服务的大量流量的关键路径上。
本文讲述了一个bug的故事:CUBIC的拥塞窗口(cwnd)被永久固定在最小值,无法从拥塞崩溃事件中恢复。
故事始于一项Linux内核变更,旨在使CUBIC符合RFC 9438第4.2-12节中描述的应用受限排除规则——这是对TCP中一个真实问题的修复,但当移植到我们的QUIC实现时,在quiche中显现出意外行为。故事有一个圆满的结局:一个优雅的(近乎)一行代码修复打破了这个循环。
CUBIC算法原理概述
在深入核心问题之前,快速回顾拥塞控制算法(CCA)可能有助于理解背景。
CCA控制的核心是拥塞窗口(cwnd):发送端对任意时刻可处于飞行状态(已发送但未确认)的字节数上限。较大的cwnd允许发送方在每次往返中推送更多数据;较小的cwnd则限制发送速率。包括CUBIC在内的所有基于丢包的CCA,本质上都是一种策略,用于在网络状况良好时增大cwnd,在网络状况不佳时减小cwnd。
本质上,CCA旨在通过推断网络的"可用带宽"来最大化数据传输——毕竟没有人愿意为1 Gbps的订阅付费却只使用其中的一小部分。CUBIC所属的基于丢包算法家族基于一个基本前提运作:(1)如果没有数据包丢失,增加发送速率(即提高带宽利用率);(2)如果发生丢包,基于丢包的算法假设网络容量已被超过,发送方必须退避(即降低带宽利用率)。

该逻辑基于多年来被重新审视的若干假设。不过,我们将把这部分讨论留到下次。
症状:测试失败率达61%
我们的调查始于入口代理集成测试管道中报告的意外失败。这种不稳定行为出现在CUBIC在连接初期经历严重丢包的测试场景中。
拥塞崩溃后的恢复是一种不常见的状态,但这正是拥塞控制器存在的目的。大多数拥塞控制测试只测试算法的稳态和增长阶段;很少有测试会探索最小cwnd情况下,连接被压垮后会发生什么。状态空间这个角落的bug在吞吐量仪表板上不可见,通过静态审查也无法检测,只有当你故意将CCA驱动到这个状态并观察它是否能恢复时才会显现——这正是本测试所做的。
模拟测试设置包含以下细节:

- 在本地运行的Quiche HTTP/3客户端和服务器(localhost)
- RTT = 10ms(配置中设置)
- 通过HTTP/3下载10 MB文件
- 使用CUBIC拥塞控制
- 前两秒注入30%的随机数据包丢失
- 两秒后,丢包完全停止
- 测试有宽松的10秒超时来完成下载,预期在四到五秒内完成
预期行为很简单:CUBIC应该在丢包阶段受到一些冲击,减小其拥塞窗口,一旦丢包停止,稳定地增加窗口并在超时时间内完成下载。相反,我们在多次100次运行中观察到,大约60%的测试无法在宽松的10秒超时内完成下载。
异常现象:999次状态转换,零丢包
我们在quiche的qlog输出中加入了数据包丢失事件的检测,并构建了可视化来理解拥塞控制器内部发生的情况:

失败测试的连接概览。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运行了相同的测试,Reno是基于丢包的算法家族中的另一个成员,但增长率不同。结果是决定性的:100%通过率,显示Reno在丢包阶段后干净地恢复,揭示了这是一个与CUBIC相关的bug。

Reno在T=2s丢包阶段结束后干净地恢复,并在约5s内完成下载
追踪根本原因
基于丢包的算法有两个控制因素:加速和减速,只是加速方式不同。CUBIC有一些额外特性。这里我们重点关注bytes_in_flight == 0的情况。
TCP CUBIC空闲后处理(Linux,2017年)
要理解这个bug,我们首先需要理解它所源自的优化。2017年,Linux内核的CUBIC实现中发现了一个问题。提交信息解释道:
epoch仅在初始时和经历丢包时更新/重置。应用程序空闲后,now - epoch_start的增量"t"以及bic_target都可能变得任意大。因此斜率(ca->cnt的倒数)会变得非常大,最终ca->cnt会被下限限制为2,以实现延迟ACK慢启动行为。
当slow_start_after_idle被禁用时,这尤其表现为空闲几秒后危险的cwnd膨胀(1.5倍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膨胀到不合理的值。

Jana Iyengar最初的修复是在应用程序恢复发送时重置epoch_start。但Neal Cardwell指出了这种方法的缺陷:
…它会要求CUBIC算法重新计算曲线,以便我们再次从当前cwnd的位置陡峭地向上增长(就像CUBIC在丢包后所做的那样)。理想情况下,我们希望cwnd增长曲线保持相同的形状,只是在时间上向后移动空闲时间的量。
由Eric Dumazet、Yuchung Cheng和Neal Cardwell提出的优雅解决方案是将epoch向前移动空闲持续时间,而不是重置它。这保留了CUBIC增长曲线的形状——只是在时间上滑动,使算法从它离开的地方继续。
移植到quiche(2020年)
当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的修复包含了原始内核变更中的一个bug,该bug在大约一周后通过对内核cubic模块的后续变更得到修复。第二次修复的提交信息解释道:
tcp_cubic:不要将epoch_start设置为未来时间
在bictcp_cwnd_event()中跟踪空闲时间是不精确的,因为epoch_start通常在ACK处理时设置,而不是在发送时设置。
进行适当的修复需要添加一个额外的状态变量,鉴于Jana注意到之前CUBIC bug已经存在了很长时间,这似乎不值得麻烦。
我们简单地不要将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崩溃到两包下限。

自我延续的恢复陷阱。在最小cwnd时,每个ACK周期都会触发空闲期调整,并带有膨胀的delta。
在最小cwnd(两个数据包)时,连接的动态转变为"死亡螺旋",其中空闲期优化成为一个自我实现的预言。这个陷阱在一个连续循环中运作:
-
发送和确认数据包:发送方传输整个两包窗口。经过一个RTT(约14ms)后,两个数据包都被确认,导致bytes_in_flight降至零。
-
错误的空闲检测:当下一个突发发送时,on_packet_sent()看到bytes_in_flight == 0并假设连接是空闲的,但实际上它是拥塞受限的。
-
膨胀的delta:计算使用now - last_sent_time来确定空闲持续时间。当拥塞窗口(
cwnd)处于最小值时,last_sent_time是上一个RTT周期开始的时间戳。因此,产生的delta大约是14ms(连接的RTT + 额外的舍入误差)。这个RTT大小的delta被错误地应用为"空闲"时间。连接真正空闲的时间(最后一个ACK到达和下一个数据包发送之间的处理间隔)实际上是0。通过测量完整的RTT而不是真实的间隔,delta被显著膨胀,积极地将恢复开始时间向前移动,可能进入未来。 -
感知恢复:因为恢复开始时间现在在未来,
in_congestion_recovery()检查对每个传入的ACK都返回true。下一个ACK的处理退出恢复状态并将恢复开始时间设置为ACK时间,该时间大于last_sent_time,确保下一次发送将再次看到bytes_in_flight == 0并触发另一次空闲调整。 -
停滞:由于CUBIC在恢复期间跳过cwnd增长,窗口保持固定在两个数据包。这保证管道在下一个ACK时完全排空,重新启动循环。
循环持续数千次迭代,直到来自调度器抖动和ACK处理方差的累积允许in_congestion_recovery()边界向后滑动,打破循环。
排查逻辑链条
下图展示了从发现异常到最终修复的完整排查流程:
关键排查步骤说明:
- 症状观察:测试失败率异常(61%),cwnd被锁定在最小值,尽管丢包已停止但吞吐量无法恢复。
- 状态分析:通过qlog可视化发现状态在恢复和拥塞避免之间快速振荡(999次转换),周期与RTT匹配。
- 对比测试:使用Reno算法进行相同测试,100%通过率确认问题为CUBIC特有。
- 代码溯源:深入分析CUBIC空闲处理逻辑,发现Linux内核移植时的delta计算错误,导致恢复边界被错误地向前推移。
- 修复验证:将delta计算从
now - last_sent_time改为now - last_ack_time,修复后测试全部通过,cwnd正常恢复增长。
这个排查链条展示了从现象到本质的系统性分析方法:从宏观测试失败开始,逐步深入微观状态分析,通过对比实验缩小范围,最终定位到具体代码逻辑问题并实施修复。
修复:从正确的时刻测量空闲时间
打破死亡螺旋需要从bytes_in_flight实际变为零的时刻(最后一个ACK处理时)而不是最后发送数据包的时刻来测量空闲持续时间。
代码变更
- 在CUBIC状态中添加
last_ack_time时间戳 - 在ACK到达时更新
last_ack_time - 使用
last_ack_time计算空闲delta:
// 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_ack_time;
self.congestion_recovery_start_time += delta;
}
// 记录此发送事件的时间。
self.last_sent_time = now;
}
现在delta反映了自最后一个ACK以来的实际间隔,恢复边界不再追逐发送时间:

修复前:边界每个周期前进一个RTT,始终落在下一次发送时或之前。

修复后:边界几乎不动;下一次发送落在边界之前,cwnd增长。
对于真正空闲的连接,last_ack_time是过去很久的时间,相同的表达式捕获完整的空闲持续时间,保留了原始的epoch移位行为。
验证
应用修复后,我们的quiche测试套件恢复了100%的通过率。

修复后,cwnd沿着预期的CUBIC曲线增长,下载在约4-5秒内完成。
连接后期的丢包是预期的——它们发生是因为我们充分利用了路由器分配的缓冲区,这意味着我们正在消耗所有可用带宽。
要点
- "空闲"的定义比听起来更难。小窗口下的正常管道延迟在简单检查中可能看起来像空闲状态。
- 最小cwnd动态是一个独特的角落情况。该bug在高速时不可见,仅在严重丢包后触发。
- 修复方案出乎意料地小,与行为的复杂性形成对比。经过数周的qlog检测和可视化分析以找到根本原因后,解决方案只需更改两行代码。
本文描述的修复已贡献给cloudflare/quiche,Cloudflare的QUIC和HTTP/3开源实现。我们的CCA工作超越了基于丢包的算法:我们还使用quiche开发和测试新的拥塞控制方案,如BBRv3。
如果您对拥塞控制、传输协议或为开源网络代码做出贡献感兴趣,请查看quiche仓库。
参考资料
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)