Firefox中消除数据竞争的技术实践与ThreadSanitizer应用

Mozilla团队在Firefox项目中成功部署ThreadSanitizer工具,检测并修复C/C++组件中的数据竞争问题。文章详细介绍了工具原理、部署挑战、实际案例及Rust代码的集成经验,强调了数据竞争对程序正确性的潜在危害。

消除Firefox中的数据竞争——技术报告

Christian Holler, Aria Beingessner 和 Kris Wright
2021年6月9日

我们成功在Firefox项目中部署了ThreadSanitizer(TSan),以消除剩余C/C++组件中的数据竞争。在此过程中,我们发现了多个有影响力的bug,并可以安全地说,数据竞争对程序正确性的影响常常被低估。我们建议所有多线程C/C++项目采用ThreadSanitizer工具来提升代码质量。

什么是ThreadSanitizer?

ThreadSanitizer(TSan)是一种编译时插桩工具,用于在Linux上根据C/C++内存模型检测数据竞争。需要注意的是,这些数据竞争在C/C++规范中被视为未定义行为。因此,编译器可以自由假设数据竞争不会发生,并在此假设下进行优化。由于线程调度的原因,检测由此类优化导致的bug可能很困难,且数据竞争通常具有间歇性。

如果没有像ThreadSanitizer这样的工具,即使是最有经验的开发人员也可能花费数小时来定位此类bug。使用ThreadSanitizer,您可以获得一份全面的数据竞争报告,其中通常包含修复问题所需的所有信息。

TSan的一个重要特性是,在正确部署时,数据竞争检测不会产生误报。这对于工具采用至关重要,因为开发人员会很快对产生不确定结果的工具失去信心。

与其他消毒剂一样,TSan内置在Clang中,可与任何最近的Clang/LLVM工具链一起使用。如果您的C/C++项目已经使用了例如AddressSanitizer(我们也强烈推荐),那么从工具链的角度来看,部署ThreadSanitizer将非常直接。

部署中的挑战

良性bug与有影响的bug

尽管ThreadSanitizer是一个非常精心设计的工具,但我们在Mozilla的部署阶段必须克服各种挑战。我们面临的最重要问题是,很难证明数据竞争实际上是有害的,并且它们影响了Firefox的日常使用。特别是,“良性”这个词经常出现。良性数据竞争承认特定数据竞争实际上是一种竞争,但假设它没有任何负面副作用。

虽然良性数据竞争确实存在,但我们发现(与之前关于该主题的工作[1][2]一致)数据竞争很容易被错误分类为良性。原因很明确:很难推理编译器可以且将会优化什么,并且确认某些“良性”数据竞争需要查看编译器最终生成的汇编代码。

不用说,这个过程通常比修复实际的数据竞争更耗时,而且也不具有未来证明性。因此,我们决定最终目标应该是“无数据竞争”政策,该政策宣布即使是良性数据竞争也不可取,因为它们存在错误分类的风险、调查所需的时间以及未来编译器(具有更好的优化)或未来平台(例如ARM)的潜在风险。

然而,很明显,建立这样的政策需要大量工作,无论是在技术方面还是在说服开发人员和管理层方面。特别是,我们不能期望投入大量资源来修复没有明确产品影响的数据竞争。这就是TSan的抑制列表派上用场的地方:

我们知道我们必须阻止新数据竞争的涌入,但同时要让工具可用,而无需修复所有遗留问题。抑制列表(特别是编译到Firefox中的版本)允许我们在将数据竞争归档后暂时忽略它们,并最终在CI中启动一个TSan构建的Firefox,该构建会自动避免进一步的回归。当然,安全bug需要特殊处理,但通常很容易识别(例如,在非线程安全指针上竞争)并在没有抑制的情况下快速修复。

为了帮助我们理解工作的影响,我们维护了一个内部列表,列出了TSan检测到的所有最严重的竞争(具有副作用或可能导致崩溃的竞争)。这些数据有助于说服开发人员该工具使他们的工作更轻松,同时也向管理层明确证明了工作的合理性。

除了这些定性数据外,我们还决定采用更定量的方法:我们查看了全年发现的所有bug及其分类方式。在我们查看的64个bug中,34%被分类为“良性”,22%是“有影响的”(其余尚未分类)。

我们知道预计会有一定数量的错误分类的良性问题,但我们真正想知道的是:良性问题是否对项目构成风险?假设所有这些问题确实对产品没有影响,我们是否在浪费大量资源来修复它们?幸运的是,我们发现这些修复大多数是微不足道的和/或提高了代码质量。

微不足道的修复主要是将非原子变量转换为原子变量(20%),为我们无法立即解决的上游问题添加永久抑制(15%),或删除过于复杂的代码(20%)。只有45%的良性修复实际上需要某种更复杂的补丁(即,差异不仅仅是几行代码,并且不仅仅是删除代码)。

我们得出结论,良性问题成为主要资源消耗的风险不是问题,并且对于项目提供的整体收益来说是可以接受的。

误报?

如开头所述,当正确部署时,TSan不会产生误报数据竞争报告,这包括对所有加载到进程中的代码进行插桩,并避免使用TSan不理解的基元(例如原子栅栏)。对于大多数项目来说,这些条件是微不足道的,但像Firefox这样的大型项目需要更多的工作。幸运的是,这项工作主要归结为在TSan的强大抑制系统中添加几行代码。

目前无法对Firefox中的所有代码进行插桩,因为它需要使用共享系统库,如GTK和X11。幸运的是,TSan提供了“called_from_lib”功能,可以在抑制列表中使用,以忽略源自这些共享库的任何调用。我们的另一个未插桩代码的主要来源是构建标志没有正确传递,这对于Rust代码尤其成问题(参见下面的Rust部分)。

至于不支持的基元,我们遇到的唯一问题是缺乏对栅栏的支持。大多数栅栏是标准原子引用计数习惯用法的结果,在TSan构建中可以轻松地用原子加载替换。不幸的是,栅栏是crossbeam crate(Rust中的一个基础并发库)设计的核心,对此的唯一解决方案是抑制。

我们还发现死锁检测中存在一个(众所周知的)误报,但很容易发现,并且完全不影响数据竞争检测/报告。简而言之,任何只涉及单个线程的死锁报告都可能是这种误报。

到目前为止,我们发现的唯一真正误报结果是TSan中的一个罕见bug,并在工具本身中修复。然而,开发人员多次声称特定报告一定是误报。在所有这些情况下,结果证明TSan确实是正确的,问题只是非常微妙且难以理解。这再次证实我们需要像TSan这样的工具来帮助我们消除此类bug。

有趣的bug

目前,TSan bug-o-rama包含大约20个bug。我们仍在努力修复其中一些bug,并想指出几个特别有趣/有影响的bug。

当心位域

位域是一种方便的小工具,可以节省存储许多不同小值的空间。例如,与其让30个bool占用240字节,不如将它们全部打包到4字节中。在大多数情况下,这工作正常,但它有一个讨厌的后果:不同的数据现在别名。这意味着访问“相邻”位域实际上是访问相同的内存,因此是潜在的数据竞争。

实际上,这意味着如果两个线程正在写入两个相邻的位域,其中一个写入可能会丢失,因为这两个写入实际上都是所有位域的读-修改-写操作:

如果您熟悉位域并积极思考它们,这可能很明显,但当您只是说myVal.isInitialized = true时,您可能不会想到甚至意识到您正在访问位域。

我们有许多此问题的实例,但让我们看看bug 1601940及其(修剪过的)竞争报告:

当我们第一次看到此报告时,它令人困惑,因为所讨论的两个线程触及不同的字段(mAsyncTransformAppliedToContent vs. mTestAttributeAppliers)。然而,事实证明,这两个字段都是类中的相邻位域。

这导致我们的CI中间歇性故障,并花费了此代码维护者的宝贵时间。我们发现这个bug特别有趣,因为它证明了在没有适当工具的情况下诊断数据竞争是多么困难,并且我们在代码库中发现了更多此类bug(竞争位域写/写)的实例。另一个实例甚至有可能导致网络负载提供无效的缓存内容,这是另一个难以调试的情况,特别是当它是间歇性的因此不易复现时。

我们遇到这种情况足够多,以至于最终引入了一个MOZ_ATOMIC_BITFIELDS宏,该宏生成具有原子加载/存储方法的位域。这使我们能够为每个组件的维护者快速修复有问题的位域,而无需重新设计它们的类型。

糟糕,那本不应该是多线程的

我们还发现几个组件实例,这些组件明确设计为单线程,但意外被多个线程使用,例如bug 1681950:

这里的竞争本身相当简单,我们通过stat64在同一个文件上竞争,这次理解报告不是问题。然而,从帧10可以看出,此调用源自PreferencesWriter,它负责将更改写入prefs.js文件,这是Firefox首选项的中心存储。

从未打算同时在多个线程上调用此功能,我们认为这有可能损坏prefs.js文件。结果,在下次启动期间,文件将无法加载并被丢弃(重置为默认首选项)。多年来,我们收到了相当多关于此文件神奇地丢失其自定义首选项的错误报告,但我们始终无法找到根本原因。我们现在认为此bug至少部分负责这些丢失。

我们认为这是一个特别好的失败示例,原因有二:它是一个比仅仅崩溃更有害影响的竞争,并且它捕获了更大的逻辑错误,即某些东西在其原始设计参数之外被使用。

延迟验证的竞争

例如,参见我们在SQLite中遇到的此实例。

请不要这样做。这些模式非常脆弱,并且它们最终是未定义行为,即使它们通常工作正常。只需编写适当的原子代码——您通常会发现性能完全没问题。

Rust怎么样?

我们在TSan部署期间必须解决的另一个困难是由于我们的部分代码库现在是用Rust编写的,而Rust对消毒剂的支持远不成熟。这意味着我们在启动过程中花费了相当大的一部分时间,所有Rust代码都被抑制,而该工具仍在开发中。

我们并不特别担心我们的Rust代码有很多竞争,而是担心C++代码中的竞争通过Rust传递而被混淆。事实上,我们强烈建议完全用Rust编写新项目以避免数据竞争。

最困难的部分特别是需要重建具有TSan插桩的Rust标准库。在nightly版本上,有一个不稳定功能-Zbuild-std,让我们可以做到这一点,但它仍然有很多粗糙的边缘。

我们使用build-std的最大障碍是它目前与Firefox使用的供应商构建环境不兼容。修复这个问题并不简单,因为cargo的用于修补依赖项的工具不是设计为仅影响子图(即只是std而不是您自己的代码)。到目前为止,我们通过在rustc/cargo之上维护一小套补丁来缓解这个问题,这些补丁为Firefox实现了足够好的功能,但需要进一步的工作才能上游。

但是,通过hack build-std为我们工作,我们能够对我们的Rust代码进行插桩,并且很高兴发现几乎没有问题!我们发现的大多数东西都是C++竞争,碰巧通过一些Rust代码传递,因此被我们的全面抑制所隐藏。

然而,我们确实发现了两个纯Rust竞争:

第一个是bug 1674770,这是parking_lot库中的一个bug。这个Rust库提供同步原语和其他并发工具,由专家编写和维护。我们没有调查影响,但问题是几个原子排序太弱,并由作者快速修复。这再次证明了编写无bug的并发代码是多么困难。

第二个是bug 1686158,这是WebRender的软件OpenGL shim中的一些代码。他们使用原始原子为部分实现维护一些手动滚动的共享可变状态,但忘记使其中一个字段原子化。这很容易修复。

总体而言,Rust似乎正在实现其最初的设计目标之一:允许我们安全地编写更多并发代码。WebRender和Stylo都非常大且普遍多线程,但线程问题最少。我们确实发现的问题是在低级和明确不安全的

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计