乐于分享
好东西不私藏

OpenAI 工程师发现一个隐藏 18年的bug

OpenAI 工程师发现一个隐藏 18年的bug

一个函数返回了 NULL 地址,程序崩溃。另一个函数的栈指针莫名其妙少了 8 字节。OpenAI 的工程师们盯着这些 core dump 看了几天,ChatGPT 也想不出答案。最后他们发现,这不是一个 bug,而是两个——一个来自 Azure 的故障 CPU,另一个来自 GNU libunwind,已经潜伏了 18 年。

从"不可能"到"两个 bug"

几个月前,OpenAI 的 Rockset 服务开始莫名其妙地崩溃。Rockset 是 ChatGPT 数据基础设施的核心组件,负责搜索对话历史和数据插件查询。崩溃的模式很奇怪:函数正常执行完毕,返回时却跳到了一个不存在的地址。有时是返回地址变成了 NULL,有时是栈指针寄存器 %rsp 突然少了 8 字节。

这些都不是正常的程序错误。OpenAI 的工程师们(还有 ChatGPT)把所有能想到的原因都排查了一遍:代码 bug?编译器问题?运行时库?Linux 内核?甚至怀疑是宇宙射线。但每个假设都有强有力的证据反驳。

他们最初以为只有一个 bug,因为崩溃症状看起来相似。但深入分析后,真相令人震惊——这是两个完全不同的 bug,恰好同时出现:一个是 Azure 某台物理机的 CPU 硬件故障,另一个是 GNU libunwind 库里一个 18 年前的竞态条件漏洞。

流行病学思维:从医生到流行病学家

传统的调试方法像医生看病:盯着单个病例,做详细检查,逐个排除病因。但当 OpenAI 的工程师们用这种方法分析了几天之后,他们发现自己陷入了死胡同。

转折点来自于思维方式的转变——从"医生模式"切换到"流行病学家模式"。

他们不再盯着单个 core dump,而是构建了一个自动化流水线,用 ChatGPT 写的脚本分析了过去一整年的所有 core dump。脚本提取寄存器信息,自动分类崩溃类型,然后并行处理整个数据集。

数据清洗之后,模式立刻浮现。原来被混为一谈的"神秘崩溃"实际上是两个完全不同的群体:

第一类(栈错位崩溃):全部来自同一个 Azure 区域,有明确的开始时间,且只发生在新启动的节点上。虽然涉及多个虚拟机,但模式指向一台物理机——无论哪个虚拟机落在上面都会出问题。 第二类(返回 NULL 崩溃):分布在多个集群和地理区域,没有清晰的起始时间,也没有基础设施边界。

这个发现彻底改变了调查方向。当两个 bug 被分开处理,真相开始浮出水面。

Bug #1:那台坏掉的 Azure 主机

第一类崩溃被追踪到一台具体的 Azure 物理主机。工程师们把它下线后,这类崩溃立即消失。尽管他们花了数周时间试图在受控环境中复现这台主机的寄存器损坏问题,但始终没能成功。

但这给 OpenAI 敲响了警钟。他们改进了信号处理程序,加入了寄存器状态记录,这样如果类似问题再次出现,只需要看日志就能发现,不需要再分析 core dump。他们还调整了控制平面,让虚拟机尽量复用而不是频繁回收,这样更容易在基础设施层面识别出问题节点。

硬件故障虽然罕见,但确实存在。更重要的是,它可能伪装成软件 bug,让调试者误入歧途。

Bug #2:一个指令宽的竞态窗口

分离出硬件问题后,剩下的 NULL 返回崩溃变得容易分析。OpenAI 的工程师们重新审视了之前被排除的可能性——异常处理。

Rockset 的 C++ 代码使用异常作为内部数据摄取的背压机制。当一个主机过载时,每秒可能抛出上万次异常。而 C++ 的异常处理依赖于 libunwind 库来展开栈帧、恢复寄存器状态。

OpenAI 使用的 GNU libunwind 版本里,有一个名为 `_Ux86_64_setcontext` 的汇编函数。它的最后几条指令负责恢复栈指针和指令指针:

mov UC_MCONTEXT_GREGS_RSP(%rdi),%rsp
mov UC_MCONTEXT_GREGS_RIP(%rdi),%rcx
push %rcx
...
retq

这里藏着一个极其隐蔽的竞态条件。可以把这两行指令想象成接力赛中的交接棒:第一行指令把栈指针更新到新位置,相当于把接力棒交出去;第二行指令从内存中读取返回地址,相当于接棒准备冲刺。

问题就在于,这两行指令之间有一个极短的时间窗口——只有一条指令的宽度。在这个窗口期间,如果恰好收到一个信号(比如 Rockset 频繁使用的 SIGUSR2),内核会立即在栈顶附近构建信号处理帧。这个信号帧的位置恰好可能覆盖掉还没来得及读取的返回地址。

结果就是,程序恢复执行时拿到的返回地址是 NULL,然后试图跳转到 NULL 地址执行,立即崩溃。

这个竞态窗口有多窄?在现代超标量乱序执行 CPU 上,大约是百亿分之一秒(100 皮秒)。打个比方,如果信号投递的时间是一秒,那么 vulnerable 窗口只有千万分之一秒——比眨眼还快十万倍。

但 OpenAI 做了费米估算:如果脆弱窗口是 10^-10 秒,而 SIGUSR2 每 10^-2 秒 CPU 时间到达一次,那么每次异常清理的竞态失败概率大约是 10^-8。Rockset 每秒可能抛出 10^4 次异常,这意味着单个过载主机每 10^4 秒(约 3 小时)就会崩溃一次。在整个集群规模上,每天出现十几次崩溃完全合理。

为什么这个 bug 藏了 18 年

GNU libunwind 的这个 bug 从 2006 年就存在,直到 2025 年才被 OpenAI 发现和修复。为什么它能潜伏这么久?

首先,触发条件极其苛刻:需要高频信号投递 + 高频异常抛出 + 恰好落在单指令窗口。大多数程序不会同时满足这三个条件。

其次,崩溃症状极具欺骗性。它看起来像是函数返回到了 NULL,让人以为是栈上的返回地址被踩坏。但实际上,返回地址是被 libunwind 临时写在栈上的,而那个值在读取之前就被信号处理覆盖了。

OpenAI 的 Rockset 恰好是完美的"风暴眼":它用高频信号(SIGUSR2)做 CPU 时间统计,用异常做背压控制,而且每秒抛出大量异常。这种组合在普通应用中极为罕见。

核心转储流行病学的启示

OpenAI 把这个方法论称为"核心转储流行病学"(Core Dump Epidemiology)——像流行病学家研究疾病传播一样,从整个人群(所有 core dump)中寻找模式,而不是盯着单个病例。

这种方法的价值在于,它能发现被"医生模式"忽略的真相。当工程师们把两个 bug 混在一起分析时,每个假设都有反例,调查陷入僵局。一旦用数据把两个群体分开,线索立刻清晰。

这个案例暴露了基础设施领域的几个盲区。那台 Azure 主机的 CPU 故障无法通过常规压力测试复现,却在暗中破坏计算结果,这说明硬件故障可以伪装成软件 bug。GNU libunwind 作为广泛使用的基础库,其 18 年的潜伏 bug 直到 OpenAI 的极端场景才暴露,这提醒我们关键依赖的代码审计不能停留在"大家都用"的假设上。而 Rockset 的 coarse_thread_cputime_clock 机制虽然高效,却为竞态条件创造了条件,这让我们看到效率与可靠性之间的权衡并非总是显而易见。

调试的最高境界不是修复单个 bug,而是改变寻找 bug 的思维方式。

一个 bug 的寿命,取决于谁来找它

18 年,足够一个程序员从入门到退休。这个 bug 在无数程序中潜伏,直到 OpenAI 的工程师用流行病学思维把它揪出来。

有些 bug 不是不存在,只是还没遇到能触发它的人。

你的系统里是否也有潜伏多年的"定时炸弹"?在评论区分享你见过最诡异的 bug 故事。

原文链接:https://openai.com/index/core-dump-epidemiology-data-infrastructure-bug

如果你觉得有用,欢迎点赞、在看、转发!

关注我,获取一手信息,了解更多AI知识!