Hotdry.

Article

when idle isnt idle quic kernel timer coalescing bug

2026-05-13general

title: "当" 空闲 "并非真空闲:Linux 内核优化如何演变为 QUIC 死亡螺旋" date: "2026-05-13T22:01:58+08:00" excerpt: "解析 Linux 内核 timer coalescing 机制如何导致 QUIC idle 超时误判,以及最小拥塞窗口场景下的连接稳定性陷阱。" category: "systems"

在拥塞控制算法的实现中,"空闲"(idle)的判断往往比表面看起来复杂得多。Cloudflare 在其开源 QUIC 实现 quiche 中最近修复的一个 bug,恰好揭示了当 Linux 内核的 timer coalescing 优化遭遇最小拥塞窗口场景时,如何引发一种被称为 "死亡螺旋" 的连接停滞现象。

现象:61% 的测试失败率

问题的发现源于 Cloudflare 的 ingress proxy 集成测试流水线。测试场景设计为:客户端通过 HTTP/3 下载 10MB 文件,使用 CUBIC 拥塞控制,在前两秒注入 30% 的随机丢包,之后丢包停止。理论上,CUBIC 应在丢包结束后逐步恢复窗口并完成传输 —— 预期耗时约 4-5 秒,测试设置了 10 秒的宽松超时。

然而,测试结果令人困惑:约 60% 的测试用例无法在 10 秒内完成。深入分析 qlog 可视化数据后,工程师发现了一个异常模式:在 T=2s 丢包停止后,拥塞窗口(cwnd)被永久锁定在最小值(2700 字节,即两个满尺寸包),且 CUBIC 状态机在 "恢复" 与 "拥塞避免" 之间以约 14ms 的周期快速振荡 —— 这恰好等于连接的 RTT。在约 6.7 秒内,状态转换发生了 999 次,但 cwnd 始终无法增长。

根因:从内核优化到用户空间陷阱

这一 bug 的源头可追溯至 2017 年 Linux 内核的一项 CUBIC 优化。当时,开发者发现当应用空闲一段时间后恢复发送,CUBIC 的 epoch_start 时间戳未更新,导致 delta_t = now - epoch_start 异常增大,进而使 CUBIC 计算出的目标窗口过大,引发危险的 cwnd 膨胀。

内核的修复方案颇为优雅:不重置 epoch_start,而是将其向前平移 idle 时长。这样,CUBIC 的增长曲线形状得以保留,只是整体向后滑动,算法从断点处继续执行。

当 Cloudflare 于 2020 年将 CUBIC 移植到 quiche 时,这一 idle 调整逻辑也被同步引入。然而,QUIC 运行在用户空间,缺乏内核 TCP 栈的 CA_EVENT_TX_START 回调机制。quiche 选择在 on_packet_sent() 中检测 idle 条件:

if bytes_in_flight == 0 {
    let delta = now - self.last_sent_time;
    self.congestion_recovery_start_time += delta;
}

问题正出在这里。内核在引入该优化约一周后,又提交了一个后续修复(commit c2e7204d),指出在 bictcp_cwnd_event() 中跟踪 idle 时间是不精确的,因为 epoch_start 通常在 ACK 处理时设置,而非发送时。将 recovery start time 基于发送时间计算,可能将其推入未来,导致 bictcp_update() 发生溢出。遗憾的是,quiche 的实现未能同步跟进这一后续修复。

死亡螺旋的形成机制

当 cwnd 坍缩到最小值(两包)时,连接的动态发生微妙变化:

  1. 发送 - 确认周期:发送方发出两个包,经过一个 RTT 后收到 ACK,bytes_in_flight 降为零。
  2. 误判为空闲:当发送下一个 burst 时,on_packet_sent() 看到 bytes_in_flight == 0,判定连接处于 idle 状态。但实际上,连接只是受限于拥塞窗口,并非真正空闲。
  3. delta 膨胀:计算 now - last_sent_time 时,last_sent_time 实际上是上一个 RTT 周期的开始时间(即发送上两个包的时刻)。因此 delta 约为 14ms(RTT),而非真正的空闲间隔(接近 0)。这个被严重夸大的 delta 将 congestion_recovery_start_time 大幅向前推进,甚至可能进入未来。
  4. 持续误判恢复状态:由于 recovery start time 在未来,in_congestion_recovery() 检查始终返回 true。每个 ACK 的处理都会退出恢复状态并将 recovery start 设为 ACK 时间,但下一次发送又会将其再次推入未来。
  5. 窗口锁定:CUBIC 在感知到的恢复期间跳过 cwnd 增长,窗口保持两包不变,确保下一轮 ACK 后 bytes_in_flight 再次归零,循环往复。

这一陷阱需要三个条件同时满足:经历丢包进入恢复状态、处于拥塞避免模式(而非慢启动)、cwnd 坍缩到最小值。这也解释了为何该 bug 在高带宽场景下难以察觉 —— 只有当连接被严重压制后,问题才会显现。

修复:从正确的时刻测量空闲

修复方案的核心是重新定义 idle 的起始时刻。不是简单地使用 last_sent_time,而是取 max(last_ack_time, last_sent_time) 作为 idle 计算的基准:

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);
    }
}

last_ack_time 记录了最近一次 ACK 到达的时刻,更接近 bytes_in_flight 实际降为零的时间点。对于真正空闲的连接,last_ack_time 远在过去,计算结果与之前一致;对于最小窗口下的正常传输,idle delta 接近零,recovery boundary 几乎不移动,允许 cwnd 正常增长。

修复后,测试通过率恢复至 100%,cwnd 沿预期的 CUBIC 曲线增长,下载在 4-5 秒内完成。

工程启示

这一案例提供了几点值得关注的工程经验:

最小 cwnd 的动态特殊性:在高带宽场景下表现正常的代码,在窗口坍缩到最小值时可能暴露边界条件问题。测试覆盖应包含极端拥塞场景下的恢复行为。

内核修复的完整跟进:移植内核优化时,不仅要关注初始实现,还需跟踪后续修复补丁。内核代码的演进往往是迭代式的,忽略后续调整可能埋下隐患。

Idle 定义的语境依赖:"空闲" 在不同上下文中有不同含义。对于拥塞控制,idle 应指 "无数据可发" 而非 "无数据在途"。当窗口受限导致管道排空时,简单的 bytes_in_flight == 0 检查会产生误判。

用户空间实现的额外复杂性:QUIC 运行在用户空间意味着需要自行管理时间戳和状态转换,缺乏内核 TCP 栈的成熟事件机制。这要求实现者对时序细节有更精细的把控。

该修复已贡献至 cloudflare/quiche 仓库。对于运行 QUIC 服务的工程团队,建议审查拥塞控制实现中的 idle 检测逻辑,确保在最小窗口场景下不会触发类似的死亡螺旋。


参考来源

  • Cloudflare Blog: "When 'idle' isn't idle: how a Linux kernel optimization became a QUIC bug" (2026-05-12)
  • Hacker News Discussion: https://news.ycombinator.com/item?id=48116064
  • Linux Kernel Commit 30927520dbae: tcp_cubic: add bictcp_cwnd_event to track and handle app idle
  • Linux Kernel Commit c2e7204d180f: tcp_cubic: do not set epoch_start in the future

general

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

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