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

Mozilla团队成功在Firefox项目中部署ThreadSanitizer工具,系统性地检测和修复C/C++组件中的数据竞争问题。本文详细介绍了技术挑战、真实漏洞案例及Rust代码的集成经验,为多线程项目提供实践指南。

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

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

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

什么是ThreadSanitizer?

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

如果没有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%的良性修复需要某种更复杂的补丁(即,差异不仅仅是几行代码,并且不仅仅是删除代码)。

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

误报?

如开头所述,在正确部署的情况下,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时,您可能不会想到甚至没有意识到您正在访问位域。

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

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

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

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

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

我们还发现了几个组件实例,这些组件明确设计为单线程,但意外地被多个线程使用,例如漏洞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实现了足够好的功能,但需要进一步的工作才能上游。

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

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

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

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

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

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