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

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

什么是 ThreadSanitizer?

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

如果没有 ThreadSanitizer 这样的工具,即使是最有经验的开发人员也可能花费数小时才能找到这样的错误。使用 ThreadSanitizer,您可以获得一份全面的数据竞争报告,该报告通常包含解决问题所需的所有信息。

ThreadSanitizer 报告示例,显示每个线程在何处读取/写入、它们共同访问的位置以及线程创建的位置。 ThreadSanitizer 输出用于 此示例程序(为文章缩短)

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

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

部署中的挑战

良性错误与影响性错误

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

虽然确实存在良性数据竞争,但我们发现(与之前关于此主题的研究一致 [1] [2])数据竞争很容易被误认为是良性的。原因很清楚:很难判断编译器可以和将要优化什么,并且需要查看编译器最终生成的汇编代码才能确认某些“良性”数据竞争。

不用说,此过程通常比修复实际数据竞争花费的时间要长得多,而且也不具有未来可扩展性。因此,我们决定最终的目标应该是“无数据竞争”策略,该策略将良性数据竞争也视为不可取,因为它们存在被误分类的风险、调查所需的时间以及未来编译器(具有更好的优化)或未来平台(例如 ARM)的潜在风险。

但是,很明显,建立这样的策略需要做很多工作,既包括技术方面,也包括说服开发人员和管理层。特别是,我们不能指望大量资源专门用于修复没有明确产品影响的数据竞争。这就是 TSan 的 抑制列表 派上用场的地方。

我们知道必须阻止新的数据竞争涌入,但同时也要让该工具在不修复所有遗留问题的情况下变得可用。抑制列表(特别是 编译到 Firefox 中的版本)使我们能够暂时忽略数据竞争,一旦我们有了这些信息,最终就能 在 CI 中构建 Firefox 的 TSan 版本,该版本将自动避免进一步的回归。当然,安全漏洞需要特殊处理,但通常很容易识别(例如,对非线程安全指针进行竞争)并且可以快速修复,而无需抑制。

为了帮助我们了解工作的效果,我们维护了一个内部列表,其中包含 TSan 检测到的所有最严重竞争(具有副作用或可能导致崩溃的竞争)。这些数据帮助开发人员相信该工具使他们的生活更轻松,同时也清楚地证明了说服管理层的理由。

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

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

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

我们得出结论,良性问题成为主要资源消耗的风险并不构成问题,而且在项目提供的总体收益中是可以接受的。

误报?

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

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

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

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

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

有趣的错误

目前,TSan 错误库中大约有 20 个错误。我们仍在努力修复其中一些错误,并想指出一些特别有趣/有影响力的错误。

小心位域

位域是一个方便的小技巧,可以节省存储大量不同小值的空间。例如,与其使用 30 个布尔值占用 240 字节,不如将它们全部打包到 4 个字节中。在大多数情况下,这很好用,但它有一个令人讨厌的后果:不同的数据现在会产生别名。这意味着访问“相邻”位域实际上是在访问相同的内存,因此存在潜在的数据竞争。

从实际角度来看,这意味着如果两个线程写入两个相邻的位域,其中一个写入可能会丢失,因为这两个写入实际上是对所有位域的读-修改-写操作。

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

我们已经遇到过许多此类问题的实例,但让我们看看 bug 1601940 及其(修剪后的)竞态报告。

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

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

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

哎呀,这本来不应该多线程

我们还发现了几个实例,其中明确设计为单线程的组件意外地被多个线程使用,例如 bug 1681950

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

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

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

延迟验证的竞争

在一些情况下,我们遇到了一个处于良性边界上的模式,我们认为它值得额外关注:有意地竞态读取一个值,但随后进行适当验证的检查。例如,类似的代码

例如,参见 我们在 SQLite 中遇到的这个实例

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

Rust 怎么样?

我们在部署 TSan 期间必须解决的另一个困难是,我们的一部分代码库现在是用 Rust 编写的,而 Rust 对清理程序的支持还不成熟。这意味着我们花了相当一部分时间来启动所有 Rust 代码,而这些工具仍在开发中。

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

特别困难的是需要使用 TSan 检测重建 Rust 标准库。在 nightly 上有一个不稳定的功能 -Zbuild-std,它让我们可以完全做到这一点,但它仍然存在很多粗糙的地方。

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

但通过将 build-std 修补为对我们有效,我们能够检测我们的 Rust 代码,并且很高兴地发现问题很少!我们发现的大多数问题都是 C++ 竞争,恰好通过一些 Rust 代码,因此被我们的全局抑制隐藏了。

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

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

第二个是 bug 1686158,它是 WebRender 软件 OpenGL 垫片中的一些代码。他们使用原始原子为实现的一部分维护了一些手工制作的共享可变状态,但忘记将其中一个字段设为原子。这很容易修复。

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

这与我们许多 C++ 竞争形成对比,C++ 竞争通常涉及在不同线程上随机访问事物,语义不明确,需要对代码进行非平凡的重构。

结论

数据竞争是一个被低估的问题。由于其复杂性和间歇性,我们经常难以识别它们,定位其原因并正确判断其影响。在许多情况下,这也是一个耗时的过程,浪费宝贵的资源。ThreadSanitizer 已经被证明不仅在定位数据竞争方面有效并且提供足够的调试信息,而且对像 Firefox 这样的大型项目来说也是实用的

鸣谢

我们要感谢 ThreadSanitizer 的作者提供此工具,特别是 Dmitry Vyukov(Google),他在部署期间帮助我们处理了一些复杂的 Firefox 特定边缘情况。

关于 Christian Holler

Christian 是 Mozilla 的 Firefox 技术主管和首席工程师。

Christian Holler 的更多文章……

关于 Aria Beingessner

Aria Beingessner 的更多文章……

关于 Kris Wright

Kris Wright 的更多文章……


5 条评论

  1. David

    为什么 30 个布尔值要占用 240 字节?每个 8 字节。我通常希望它们每个占用 1 字节,总共 30 字节。使用基本 32 位整数(如旧的 Visual C++ 使用的)将是每个 4 字节。它是否为每个布尔值使用完整的本机指针字段?这听起来很疯狂。

    2021 年 4 月 6 日 19:50

  2. Juraj M.

    是否有时间表或计划将所有内容重构为 Rust?
    你们现在进展如何?

    2021 年 4 月 7 日 00:13

  3. Louis

    我不确定我是否理解为什么需要 MOZ_ATOMIC_BITFIELDS 修复。根据链接的 C++ 内存模型[0],内存位置是“[...] 最大的非零长度位域连续序列”。

    因此,重新定义位域使其通过虚拟零长度成员分隔将足以确保每个位域都是一个单独的位置。它将避免数据竞争。

    我能想到的唯一问题是,编译器可能会决定实现此方法的方法是将每个位域分配到不同的字节,从而抵消了节省的大小。

    我错过了什么 :) ?

    [0] https://cppreference.cn/w/cpp/language/memory_model

    2021 年 4 月 7 日 03:59

  4. Meder Bakirov

    Linux 文档

    https://github.com/torvalds/linux/blob/3a22981230f997846d1cfeb8eadcda8bcc0f7ea8/Documentation/memory-barriers.txt#L314

    (*) 这些保证不适用于位域,因为编译器通常
    生成代码以使用非原子读-修改-写修改这些
    序列。不要尝试使用位域来同步并行
    算法。

    2021 年 4 月 7 日 15:35

  5. Steve Fink

    由于 gcc 和 clang 似乎都支持语句表达式,因此 racy-init.cpp 可以简单地执行 `static bool unused = ({ /* 初始化代码 * });`,并且使用 c++11 或更高版本,它将是线程安全的。(它将执行获取/释放以确保初始化只发生一次。)或者你可以在其构造函数中声明一个进行初始化的对象。

    因此,如今,编写正确版本实际上会更容易。(如果你碰巧知道 sekrit c++11 技巧!)

    2021 年 4 月 8 日 11:42

本文的评论已关闭。