追捕海森堡错误

标题:Hunting Heisenbugs

日期:2023/11/14

作者:Paul McKenney

链接:https://lpc.events/event/17/contributions/1504/

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。


我们今天来谈谈如何捕猎海森堡 bug (Heisenbugs)。这有点像是软件工程领域的印象派画派,因为无论是印象派画作还是海森堡 bug,你靠得越近,反而看得越不清楚。对于印象派画作来说,这或许是其创作意图;但对于海森堡 bug 而言,这通常被认为是一个问题。所以,今天我们将来探讨解决这个问题的方法。现在,这仍然更像是一门艺术而非科学,但我们至少可以掌握一些可能对你有帮助的技巧。

好了,我们将讨论几个主题。当然,最后有一个非常重要的话题:捕猎海森堡 bug 固然很棒,但能避免它们则更胜一筹。在我人生的某个阶段,大约 30 年前,海森堡 bug 曾是那种可怕的、足以改变人生的经历。但过了一段时间,你也就习惯了,心态变成了:“好吧,今天是星期二,我们又遇上什么海森堡 bug 了?”

大规模集群的优势

大规模集群实际上非常擅长检测海森堡 bug。这纯粹是数学问题,但有时结果可能出人意料。如果我们有一个“万年一遇”的 bug,在一个拥有百万台系统的集群上,它相当于每 100 小时就会出现一次。是的,一个万年一遇的 bug,在百万系统集群上,每 100 小时发生一次。我不能透露我的雇主运营着多少系统,但我可以说,至少有一百万台。所以,如果我们遇到一个万年一遇的 bug,并且它的表现是持续一致的,比如一个 WARN_ON 警告——如果它只是内存损坏,那每次的表现都会不同,我们可能会把它当作硬件故障,无法区分——但如果它能触发一个 WARN_ON,我们就能看到它,并决定如何处理。我们可能会评估后认为,在这个版本中修复它风险更大,所以我们只在上游修复,让补丁在之后的版本中自然地合并下来。或者我们决定立即修复这个 bug。但无论如何,我们都能看到它,并做出决策。

如何检测?

那么,我们如何检测这些 bug 呢?控制台输出 (console output) 是一种常见的方法,就像在任何地方一样。有很多自动化工具会收集这些输出。当然,你也可以使用 BPF,部署一个内核补丁来检测问题,但这非常痛苦,因为像 Chris Mason 这样的人会理所当然地问你许多尖锐的问题,关于为何要向这么多机器部署一个调试补丁。

你也可以考虑设置一个内核调试器。接下来,我将讨论一个几周前 Chris 让我修复的 bug。这其实是个很受欢迎的任务,因为我当时正在处理一个极其令人沮丧的问题,具体是什么我已经记不清了。所以当时的感觉是:“太好了,Chris,我来看看这个问题,正好换换脑子。” 这个 bug 的情况是,控制台会出现一些输出,当时已经是下午晚些时候了。我看了一眼,然后就去睡觉了,第二天早上醒来时,我已经知道问题所在了。整个过程很有趣。当然,如果修复的不是我自己的 bug,那会更有趣,但人生不如意事十之八九。

这个 bug 的发生频率是每周每 4000 台系统出现一次故障,这足以让我们决定:“好吧,这个版本不能再继续推广了。”

这相当于单台系统的平均无故障时间 (MTBF) 是 70 年。这是一个很容易修复的问题,既然是修复,我们就可以立即推送。如果它是一个功能增强,那就必须先提交到上游,然后再合并回来。但对于修复,我们有道德上的责任。我把补丁发出去,有人看了一下说:“哦,这确实是一个回归 (regression),但考虑到 MTBF 长达 70 年,我们可以等到下一个合并窗口 (merge window)。” 这完全没问题。

你当然可以设置内核调试器,但想象一下一百万个 kgdb 实例……我实在想不出有什么办法能让这件事变得有任何乐趣。当然,如果你足够有受虐倾向,那另当别论。管理起来绝对是一场噩梦。而且,如果你已经有了修复方案,等待这个补丁部署到足够多的机器上来验证问题是否解决,同样也不是一件愉快的事。所以,你真的需要比这更好的方法。当然,更好的方法早已存在,我在这里只是做了很多总结。

你可能会问:“等等,我没有大规模集群,你在说什么?别跟我扯这些。我只有 15 台系统,或者 1000 台。你说的这些有什么用?” 嗯,在我拥有大规模集群之前,我已经捕猎了很长时间的海森堡 bug。也许你们现在的情况和我 90 年代初一样。当时,Sequent 公司在巅峰时期最多也只有 6000 台,也许是 10000 台机器在运行,但我们仍然需要捕猎海森堡 bug。

另一方面,我雇主的集群在整个 Linux 内核安装基数中只占很小一部分。一位名叫 Dave Rusling 的人在 2017 年告诉我,当时全世界有 200 亿个 Linux 内核实例——是 200 亿(20 x 10^9)。所以,如果能减少那些随机发生的故障,对整个世界都是一件好事。修复这些问题会非常有意义,因为如果你觉得它们在百万级系统上发生得很频繁,想象一下在百亿级系统上会是怎样。

当然,很多这类故障被硬件故障掩盖了。还有很多设备、嵌入式系统在遇到任何问题时会自动重启,所以你可能根本不会注意到。但另一个事实是,无论我们是否喜欢,是否认可,Linux 内核正越来越多地被用于安全关键 (safety-critical) 应用中,这种情况已经持续了十多年。

据我所知,我希望它还没有被用于航空电子设备 (avionics)。对于航空电子设备,他们有极其严格的编码规范。事实上,我上一次接触相关领域时,如果你想在代码里增加一个 if 语句,你需要在无数个会议上花上好几个小时来论证为什么你需要这个 if 语句,以及为什么不能用其他方式实现。因为这个 if 语句会增加程序的复杂性,使其更难验证。至于内存分配?想都别想。一旦系统开始运行,绝对不能进行内存分配。在初始化阶段,当飞机还在地面上时,或许可以,但之后绝无可能。

然而,在许多司法管辖区——我不知道这里的规定,但在世界上其他一些地方——如果你有一个设备,它可能包含一台计算机,并且可能运行着 Linux 内核,只要它在长达数年(我所知道的案例是五年)的测试中都表现正常,那么它就被认证为安全关键设备。Linux 内核可以轻而易举地做到这一点。所以,我们将继续看到 Linux 内核被用于低级别的安全关键应用中。我认为,为了这些安全关键的场景,消除一些 bug 是件好事,即使它们不常发生。

核心技术:增加 bug 发生率

好了,那么关键技术是什么?首先,为什么我们称之为海森堡 bug?因为当你添加调试代码时,它就消失了,对吧?这差不多就是它的定义。原因在于,这类 bug 通常是时序依赖 (timing-dependent) 的。当你添加调试代码时,你改变了时序,可能会掩盖这个 bug。当然,有时添加调试代码反而会使 bug 更容易出现,但我们对这种情况的记忆远不如前者深刻。

这是一个很有意思的现象。时序上的微小变化可以减少 bug 的发生率。应对这种情况的一个方法是降低 MTBF,换句话说,增加 bug 发生的频率。这在调试时听起来总是有点反直觉,你通常不希望 bug 出现。但在这种情况下,如果你能让 bug 发生得更频繁,你就可以在添加调试代码后,bug 依然存在。所以,找出它发生的原因,然后让那个原因更频繁地出现。

这样做还有另一个动机。就我目前的工作而言,你希望能在一小部分机器上运行一个小型测试,比如在 50 台机器上进行为期一周的测试,并有信心你已经消除了大部分会在百万台机器上出现的 bug。这会非常方便。如果你算一下,这意味着测试机器上的 MTBF 必须比生产集群上的 MTBF 小六个数量级。六个数量级!有趣的是,在很多情况下,这很容易做到。当然,并非总是如此,生活有时就是很艰难,毕竟这更像一门艺术。但在某些情况下,你可以通过一些相当简单的方法,将 MTBF 降低好几个数量级,从而把海森堡 bug 变成一个普通的 bug。我们将讨论几种方法。

好了,我前面一直在做“销售宣传”,现在让我们来点实在的。我们将借鉴我高中时田径和越野跑教练的理念。那时候,我们的训练非常艰苦。越野赛跑的距离是 2.5 英里,后来变成了 5000 米。比赛日反而是我们最轻松的日子,因为我们的日常训练比 2.5 英里或 5 公里的比赛要艰苦得多。

我们可以把同样的理念应用到计算机上。对于我们这些专注于 Linux 内核的人来说,如果你去看看系统上 Linux 内核的利用率——我们指的是内核非空闲 (non-idle) 的利用率——它通常不会超过 10%。当然,我相信有些工作负载会更高,但很多生产环境中,内核实际的非空闲执行时间只占百分之几,甚至不到 1%。如果你问应用开发者,他们会说这才是世界应有的样子,是上帝的旨意。因为他们只想运行自己的应用程序,在他们看来,内核就是一种税,他们想尽办法逃避。所以他们会努力让内核尽可能少地运行。

这对我们内核开发者来说其实是件好事,因为这意味着我们只需编写一个让内核一直运行的测试,就能将 MTBF 降低一到两个数量级。他们的应用以某种方式使用内核,如果我们能模拟这种方式,但只让内核运行,就能获得一到两个数量级的提升。RCU torture 测试就是这么做的,它是一个内核模块,其用户空间只有一个目录和一个小小的可执行文件,其余部分全在内核中运行。

所以,我们的第一个反海森堡 bug (anti-heisenbug) 技巧:如果海森堡 bug 来自物理学(它实际上是观察者效应,与量子物理无关,但这个名字已经深入人心,也很有趣),那么我们就从物理学中再借一个反海森堡 bug 的方法,那就是增加工作负载强度 (increase the workload intensity)。我们增加工作负载的强度,具体含义因负载而异,但只要增加强度,我们就能降低 MTBF,并可能达到一个可以加入调试代码而 bug 不会消失的程度。在内核 bug 的场景下,就是让机器除了内核什么都不运行,狠狠地压榨它。这样你应该能获得一到两个数量级的提升。

这其实是“隔离测试可疑子系统”的一个特例,只不过这里的可疑子系统是整个内核。但你还可以做得更好。你可以专注于内核的某些部分。有时,你也可以通过配置应用程序来简化它所做的工作。比如,它可能在处理一个带有海量磁盘的大型数据库,那么我们可以让它使用内存文件系统和一个非常小的数据库,也许这样就行。当然,这样你就失去了磁盘 I/O,但你可以根据工作负载,看看可疑的部分在哪里,并集中精力测试它。

一种方法是从应用程序中提取踪迹 (trace),然后用一个工具来针对内核重放这些踪迹,这样消耗的 CPU 时间可能比原始应用程序少得多。这也是一种增加强度的方式。另一种方式是运行比生产环境更多的 CPU、使用更多的内存、进行更多的 I/O。具体取决于故障发生在哪里。如果你的故障是和 SMP 有关的,也许可以增加 CPU 数量,或者增加插槽 (sockets)。增加插槽会增加延迟。

寻找并制造麻烦

关键是要问自己:“过去是什么导致了麻烦?” 这是回顾性的,我们稍后会谈前瞻性的方法。但让我们从简单的事情开始。你的第二个反海森堡 bug 技巧是:寻找并放大麻烦 (look for and promote trouble)。这又是一个反直觉的思路。调试时,你希望一切正常,对吧?但要度过这个阶段,你必须反其道而行之,让情况变得更糟。你要让它变得足够糟,以至于你的调试代码能真正发挥作用。

这么做的另一个好处是,如果你能让它变得更密集、更频繁,那么当你找到一个修复方案时,你就不需要运行那么长时间就能有信心这个修复确实改变了行为,甚至可能真的解决了问题。当然,从统计学上讲,你无法证明你修复了系统,但你可以获得一定程度的信心,相信你已将 bug 的发生率降低了若干个数量级。毕竟,这是现实世界。

不过,你需要小心一点。当你增加强度时,通常也会增加延迟。调度队列会变长,任务可能会增多。如果你增加了 CPU 数量,可能会有更多的锁竞争和内存竞争。你可能会遇到软锁死 (soft lockups)、RCU CPU 停滞警告等问题。当你正在追查一个 bug 时,这些警告只是在告诉你“你把强度调得太高了”,这并不是你想要的。你可以禁用它们。

让我们先看看这背后的原理,因为它能给我们带来另一个反海森堡 bug 技巧。这里是一个理论模型,它与现实严重脱节。我们假设这是一个 M/M/1 排队模型,假设到达是指数分布的,无论队列中有多少人,新请求到达的概率都是随机的;服务也是随机的,遵循指数分布;并且只有一个队列。队列可以是无限长的。除了这些,它完全符合现实。

理论家之所以如此喜欢这个模型,是因为它有一个非常优美的封闭解。你可以在三页纸内从零推导出来,如果你数学比我好,可能用的纸更少。

图1:无限队列下延迟与利用率的关系

图的横轴是利用率。1 表示系统被推到了极限,0 表示空闲。纵轴是延迟,可以理解为你前面排队的人数。在利用率为 0 时,没人排队,延迟为 0。在另一端,当系统被完全占满时,所有人都排在你前面。但由于队列是无限的,这个曲线看起来像指数增长,但实际上它在利用率达到 1 时就冲向了无穷大,是 利用率 / (1 - 利用率) 的形式。

延迟在利用率达到 100% 时会爆炸。这有点不切实际。这是理论。我们可以做得更好一点。如果我们限制队列的最大长度为 k,当队列满了再有请求来时,我们就把它丢掉。这有点像医院,床位是有限的,我很抱歉,但世界就是这样。

图2:有限队列下延迟与利用率的关系

这个模型更复杂一些,但它有一个近似解。现在我们看到,虽然那条无限增长的曲线还在,但由于队列长度有限,如果你足够幸运能进入队列,你最多等待队列长度的时间。当然,在这种情况下,你实际能进入队列的概率会很低。所以曲线最终会趋于平坦。重点是,你可能需要调整你的超时设置,因为 10 倍于正常延迟可能会引发问题。但这是可以做的,你可以调整超时,可以禁用 RCU CPU 停滞警告(有一个 sysfs 接口可以做到),等等。只要意识到你可能需要这样做就行。

策略性地注入延迟

另一个方法是策略性地注入延迟 (strategically inject delays)。之前我们讨论的是增强某一部分工作负载的强度,但这可能行不通,也许它已经达到了最高强度。在这种情况下,有时你可以通过减弱其他部分的强度来达到目的。如果这是一个竞争条件 (race condition),你或许可以通过延长竞争条件中脆弱部分的执行时间,来让它更容易发生。

延迟是个好东西。举些例子,我之前提到过多插槽系统。如果你通常在生产环境中使用单插槽系统,那么在多插槽系统上进行测试会注入延迟,你会发现更多的 bug,并有更好的机会让它在生产环境中运行得更好。

RCU torture 测试实际上会在某些场景中注入延迟,以促使那些过去曾 troublesome 的竞争条件发生。在 90 年代的旧时光里,我们会在同一个总线上插入不同速度的 CPU。对于某些类型的竞争条件,时钟频率的比率直接决定了 bug 发生概率的增加倍数。我们当时可以做到 3:1 的时钟比率。如今,理论上这应该更容易做到,但现在的系统倾向于从你手中夺取控制权,让时钟频率保持在它们想要的状态。有时你需要费点劲才能让它工作。此外,这很难自动化,所以我倾向于避免它。而且,在客户机操作系统 (guest OS) 中,我还没找到方法来实现这一点。

RCU 的关键在于,它必须等待所有 CPU 到达某个特定状态点。在某些情况下,也要等待线程。但如果一个 CPU 处于离线 (offline) 状态,RCU 就不必等待它。如果一个 CPU 在宽限期 (grace period) 开始后才上线 (online),RCU 也不必等待它。RCU 内部有一个合并树 (combining tree) 数据结构,它允许 RCU 在减少锁竞争的同时,观察到全局状态的变化。对于 RCU 来说,在整个树结构中,对于是否在等待某个 CPU,保持一致性至关重要。如果上层节点认为它在等待一个 CPU,而下层节点却不这么认为,这是非常糟糕的。这种情况下,下层节点永远不会报告该 CPU 的状态,导致宽限期永远无法结束。相信我,这会很令人沮丧。

正常情况下,CPU 离线热插拔操作的两个初始化阶段——smp_call_function 和初始化等待位掩码——发生得非常快,可能在 10 微秒内完成。而一个完整的 CPU 热插拔操作通常需要数百毫秒甚至数秒。所以,通常情况下,它们之间的竞争是不会发生的。因此,我们在测试中有意在这两个初始化阶段之间插入一个巨大的延迟,迫使 CPU 热插拔在这段时间内完成,从而暴露那些由竞争引发的 bug。

所以,插入策略性延迟是一种方法。你可能会反对说:“我必须知道把延迟放在哪里,这意味着我已经知道 bug 在哪里了。” 这没错。但很多时候,当你编写并发代码时,你心里是有数的。你可能不愿意承认,甚至处于否认状态,但你大概知道哪部分代码是你写得最累、最不想继续下去的地方,或者哪部分你只是觉得“嗯,我觉得这样能行”。就把延迟加在那些地方。或者,当你试图向别人解释某段代码时,你不得不说得非常复杂,而他们听得一头雾水,就把延迟加在那儿。这很直接。虽然不总是奏效,但这是一个很好的启发式方法。

计算“准失误” (Near Misses)

让我们再次回到航空业。美国联邦航空管理局 (FAA) 要求报告“准失误” (near misses)。如果在飞行中两架飞机彼此靠得太近,双方的飞行员都必须提交正式的事件报告,并接受调查。原因是,这次“准失误”是无意的,背后可能存在某个导致它发生的问题。这次它没有造成碰撞,但在它造成碰撞之前,最好把问题修复。

同样的道理也适用于软件。如果你能找到某种迹象表明发生了一次“侥幸”,那就意味着你可以更快地评估提交 (commits)、配置等等。如果你在做二分查找 (bisecting),这意味着你可以用更短的运行时间来更快地完成查找。特别是当你有一个所谓的修复方案时,如果你依赖“准失误”的计数而不是实际问题的计数,你可能会花少得多的时间来验证它,同时仍然有信心你已经将 bug 的 MTBF 降低了。

在 RCU 中,我们有一个“准失误”计数器。通常 RCU torture 的工作方式是,它不调用 RCU,然后在回调被调用时,它会检查状态是否改变。它会设置并递增一个计数器,然后读者 (reader) 线程如果看到这个计数器前进得太远,就会报告说有问题。问题在于,读者线程要检测到错误,需要跨越整个操作周期。

现在,我们在宽限期开始时保留一个计数器,记录宽限期的开始和结束。如果我们也对这个计数器进行采样,我们就可以检测到“准失误”。一个正确的 RCU 读者应该看不到任何“准失误”。问题是,宽限期结束的计数器更新不是同步的,没有内存屏障,所以我们可能会看到一些误报 (false positive)。RCU torture 的处理方式是,如果在一次运行中只看到一个“准失误”,它会忽略它。但如果看到一大堆,它就会说:“嗯,这里可能有问题。”

这件事最酷的地方在于,当问题存在时,“准失-误”的发生频率比实际 bug 的发生频率高出大约两个数量级。这意味着运行时间可以大大缩短。这还是在我们将内核持续运行所带来的一到两个数量级提升之上的。所以我们还没到六个数量级,但已经有三到四个了,这已经很不错了。所以,当你的软件有某种迹象能表明“情况很接近”时,计算“准失误”是一个很好的反海森堡 bug 技巧。

让罕见事件频繁发生

我们已经讨论了让罕见事件频繁发生的方法,现在让我们通过利用率再看一些其他方式,最后再看看“核武器”选项。

图3:有限队列状态转移图

这还是我们之前那个有限队列。图上显示了从一个状态到另一个状态的转移,表示队列长度在增长。最右边的状态队列长度为 0,最左边是 k(我们之前设为 10)。λ 是新元素到达的速率,μ 是元素被移除的速率。

我们再次选择 k=10。如果利用率是 0.1(10%),这对于生产环境中的内核来说已经是很高的利用率了。在这种情况下,队列中有 10 个元素的概率是 10 的负 10 次方,一个非常小的数字。但如果我们能将利用率提高到 90%(0.9),我们就能让处于该状态的概率增加九个数量级。九个数量级!这虽然是理论上的,但效果是真实存在的。内核里有多少队列?网络子系统有很多,我自己的回调也有一些。所以,另一个反海森堡 bug 技巧是:塞满你的队列 (swamp your queues)

这不仅适用于队列,它适用于任何具有类似我展示的那种概率分布的东西。让我们回到 1993 年。当时我们正在重写一个内存分配器。这是一个简单的 32 位系统。你难以想象在 32 位系统上,一个内存分配器能做出多么“出格”的事情,这些在今天完全行不通。但当时我们充分利用了这一点。

它的结构是相似的:我们有每 CPU 缓存 (per-CPU caches),并且我们努力让正常路径只访问每 CPU 缓存。当时和现在一样,我们有一个全局缓存。在 90 年代中期我们加入了 NUMA 后,我们还有了每节点缓存。最终,内存块会被放回页 (pages) 中,然后页可以被合并成一个 2MB 的区域,然后释放物理内存,最终你还可以释放那个 2MB 的虚拟地址范围。我们非常努力地确保系统绝大多数时间都停留在最左边的快速路径上。

这实际上与那个队列结构相似,你有很高的概率停留在左边,很低的概率移动到右边。内存分配器的状态空间要复杂得多,但你可以把它投影到这个更简单的图上来理解。

当时,Sequent 公司在 90 年代中期的一个大卖点是用于高可用性的共享磁盘。我们会有两台服务器,通过光纤通道连接,这样它们都能访问 SCSI 磁盘链。我们之所以重构内存分配器,是因为我们正在为一个特定的数据库开发一个分布式锁管理器,以实现分布式数据库。好处是,如果你丢失了一台数据库服务器,你仍然可以访问所有数据。

当然,对于这样的系统,你需要经常测试故障模式,否则在需要它的时候它可能不会工作。但由于某种原因,我们的一个客户反对我们每天晚上在他们的系统上这样做。幸运的是,崩溃总是在备份完成后,而不是之前。

于是我们给他们发送了一个调试内核,他们很高兴,因为这抑制了错误。但我们有点担心,它可能会在其他客户那里出现,并且可能在备份之前发生。所以我们决定必须调查一下。当然,当时和现在一样,你会发送一个崩溃转储 (crash dump)。但这个崩溃转储完全是一场灾难。通常,你期望从一个堆栈跟踪中看到一些函数名,并且希望堆栈上的一个函数调用了另一个。但这些崩溃转储里的堆栈跟踪里甚至找不到任何两个函数有调用关系。

它完全乱了套。我们所做的就是尽力复制他们的工作负载。最终,经过几周的努力,我们可以在运行 5 到 27 小时后重现这个问题。他们是每晚都遇到,而我们需要 5 到 27 小时。这带来的问题是,测试一个所谓的修复方案需要非常长的时间,可能是几十倍,需要好几个星期。

最终,我们得到了一个堆栈跟踪,其中有两个函数,一个确实调用了另一个。不幸的是,它指向了我的代码。人生不如意事十之八九。

代码是这样的(根据记忆重构):在 32 位系统上内存很宝贵,所以你不会将 2MB 的内存段在 2MB 的边界上对齐。你可能会遇到一个小指针指向 6MB 和 8MB 之间的情况。如果你用 2MB 去除它,你会得到数组中错误的元素索引。这没什么大不了,你检查一下指针,如果它比你当前区域的起始地址大,你就去前一个区域,就像这边的代码一样。不幸的是,当时的编译器远没有现在这么激进,但 80386 寄存器不多,编译器说:“我的寄存器满了,我不想缓存这个值,我要重新获取两次。”

所以,它会获取一次指针,判断它非零,然后去检查 if 语句的另一部分。这时,指针的值可能已经被并发地修改为 NULL 了。然后程序就急转直下,整个内存都被搞得一团糟,这就是为什么我们的崩溃转储毫无用处。在 Linux 内核中,对于这种情况的修复是使用 read_once(),告诉编译器:“别碰这个,你不懂。”

问题在于,这是一个罕见的事件。诀窍是让复现程序一直触发这个罕见事件,也就是干掉快速路径 (kill the fast path)。这并不难。我写了一个专门的测试,可能是当时我写过的最复杂的并行代码,但它把复现时间缩短到了 12 分钟。

所以我们有三种情况:

  1. 现有测试:找不到这个 bug,MTBF 是无限的。但它能找到其他各种 bug。

  2. 压力测试:基于用户工作负载开发,MTBF 是 5-27 小时。需要大型系统,耗费数人周。

  3. 专门测试:MTBF 低于 12 分钟。需要知道确切的 bug 来编写。在小型系统上即可运行,一两天就能写完。但它的覆盖面可能很窄,而且在运行时系统基本不可用。

你必须在这里做出明智的权衡。

你的快速路径是否在隐藏 bug?

之前我们讨论的是回顾过去,那么未来呢?一个要问自己的问题是:“你的快速路径是否在隐藏 bug?”

答案是:是的。所以,测试中的一个方法就是让那些快速路径不发生。这就是另一个反海森堡 bug 技巧:弱化你的快速路径 (de-emphasize your fast path)。你可能会反对说:“那性能和可扩展性怎么办?” 答案是:会变得很糟糕。但这只是一个测试。此外,你有一些变通方法,比如在更小的系统上运行。有些我为 RCU 测试做的事情,Jan Horn 还称赞过,我把它限制在 4 个 CPU 上。你可以接受大规模的竞争,有时你不在乎,让系统运行,最后把它杀掉就行。

另一个令人惊讶的方法是,在新的高度集成的系统上运行为旧系统开发的代码。原因在于,在 2008 年,我用的系统有 4 个插槽,16 个 CPU,一个比较和交换 (compare-exchange) 操作的延迟大约是 100 纳秒。到 2017 年,我在一个插槽里就有 56 个 CPU,延迟差不多,但 CPU 数量是三倍多。如果你使用同一个主板上的四个插槽,延迟大约是 400 纳秒。而今天,有一台我能用的系统,同样多的 CPU 只有两个插槽,延迟只有 150 纳秒。

所以,我们 10 年或 20 年前写的快速路径,今天可能并不真的需要,或者需求没那么大了。2007 年会杀死你的竞争,今天可能在测试系统上是可以容忍的。当然,另一方面,系统也变得越来越大,更多的 CPU 和插槽可能会让我们再次陷入困境。所以,更新的系统能更好地处理竞争,你或许可以禁用快速路径而不会有太糟糕的事情发生。

组合罕见事件

还有很多其他的罕见事件可能发生。关键点在于,如果你让一个罕见事件更频繁地发生,你会得到一定程度的 MTBF 降低。如果你让两个罕见事件更频繁地发生,效果是乘法的。所以,如果你做了两个都能降低三个数量级的改变,理论上你可能得到六个数量级的降低。当然,在现实生活中,bug 的行为是不可预测的,但从统计学上看,它们有时是表现良好的。

所以,另一个反海森堡 bug 技巧是:组合罕见事件 (combine the rare events),让它们一直发生。如果你必须选择,那就选择那些过去造成最多麻烦的,或者最近开发最活跃的。

先检测,后检测

一个问题是,你加入的检测代码改变了时序。一个技巧是,如果你有像 RCU CPU 停滞警告这样的东西可以检测到问题,就把检测代码放在停滞警告的开头。理论上,这不影响 bug 的 MTBF。

几年前,我们遇到一个 RCU 宽限期卡住的问题。任务都在等待,但唤醒它的定时器就是没触发。这个 bug 的 MTBF 是几百个小时。如果我添加调试代码,情况变得更糟,bug 完全消失了。我花了一年的时间调整配置、启动参数等等,最终把 MTBF 降到了 300 小时,但这仍然不够,加调试代码 bug 还是会消失。

最终发现,是 RCU CPU 停滞警告本身的代码“踢”了 RCU 一脚,让它继续运行了。所以,诀窍是把调试代码放在 RCU 停滞警告的开头,这样我们就可以在问题发生时转储状态并找到 bug。我们设置了一个启发式规则:如果一个 3 毫秒的定时器花了超过 8 毫秒才触发,我们就转储状态。这个 bug 是定时器、CPU 热插拔和 RCU 之间的交互引起的。

所以,另一个反海森堡 bug 技巧是:在检测到错误之后再加入调试代码。崩溃转储是这种方法的一个非常重要的特例。

如何避免海森堡 bug?

什么比精通捕猎海森堡 bug 更好?

没错,从一开始就没有海森堡 bug。有一些方法可以做到这一点。它们有点像“吃蔬菜”之类的建议,但吃了对你有好处。你永远无法做到完美,但如果能减少一半,也是值得的。

所以,最后的反海森堡 bug 技巧是:不要随意地写并发代码 (don’t randomly hack concurrent code)。如果你真的想,也行,但要接受后果。

我们讨论了一些事情,没有银弹,这不是一门科学,但有很多有用的技术。希望其中一些能对你有所帮助。


问答环节

提问者1: 你提到了“并发优先设计”,有没有什么好的资源可以学习这个,特别是针对操作系统的?

回答者: Linux 内核邮件列表里有很多措辞相当……尖锐的讨论。TLA+,Leslie Lamport 的 TLA+,被用来解决 Glibc Pthread 条件变量的并发 bug。是的,如果形式化验证 (formal verification) 适用于你的问题,你可以用它。它效果很好。还有一些其他模型,比如 Lynch 的模型,功能没那么强但更容易使用。实际上,内核中的 qspinlock 和一些日志代码就是用 TLA+ 验证过的,并因此发现和修复了一些 bug。但另一件事就是,要真正理解并发原语,遵循好的设计模式等等。

提问者2: 当你在捕猎一个非常罕见的海森堡 bug 时,你怎么知道当你开始添加检测代码或增加负载时,你追查的仍然是同一个 bug?

回答者: 你可能不知道。是的,要小心。这就是现实世界。我们最初提到的那个 bug 是每周每 4000 台机器发生一次。你怎么知道那不是宇宙射线(alpha 粒子)造成的?因为宇宙射线不会总是在同一行代码上触发 WARN_ON。但你说得对,如果一个海森堡 bug 只是表现为内存损坏,那将很难确定它是不是同一个。再次强调,没有银弹。

提问者3: 在我参与内核开发之前,我们在 Rust 世界里开发了一个很好的库。我们有并发代码,然后用这个库来运行一个测试,它会遍历所有可能的排列组合来寻找 bug。我们在内核里有类似的东西吗?

回答者: 我在内核里没有类似的东西。但有一个用于跟踪 RCU 回调的复杂数据结构,我曾把那部分代码抽到用户空间,为所有内核函数写了桩 (stub),然后基于队列的状态做了穷尽测试,验证函数是否返回了预期的结果。你说得对,我们应该更频繁地使用这种方法,即所谓的白盒测试 (white-box testing)。我在 Kernel Recipes 会议上和一个人聊过,他们正是这么做的:他们有一个合法的但很罕见的状态,bug 会在这个状态之后发生。所以他们“作弊”,直接把数据结构初始化到那个状态,然后让 bug 发生。是的,白盒测试是一种极好的技术,特别是当你可以把它集成到语言本身时。