第27章 使用ThreadSanitizer运行时检测数据竞争

1 概述和用法

从5.0版本开始,OCaml允许共享内存并行处理,从而可以修改多个线程之间共享的数据。这会产生数据竞争的可能性,即对同一内存位置的无序访问,其中至少一个访问是写操作。在OCaml中,很容易引入数据竞争,并且包含数据竞争的程序的行为可能不直观——观察到的行为无法通过简单地交错来自不同并发线程的操作来解释。有关数据竞争及其后果的更多信息,请参见第 ‍9.4节和第 ‍10章。

为了帮助检测数据竞争,OCaml支持ThreadSantizer (TSan),这是一种动态数据竞争检测器,已成功应用于C/C++、Swift等语言。OCaml的TSan支持自OCaml 5.2版本起可用。

要使用TSan,必须使用--enable-tsan配置编译器。您还可以安装启用了TSan功能的opam切换,方法如下

opam switch create <YOUR-SWITCH-NAME-HERE> ocaml-option-tsan

目前,OCaml的TSan支持适用于FreeBSD、Linux和macOS上的x86_64架构,以及Linux和macOS上的arm64架构。使用TSan支持构建OCaml需要GCC或Clang。最低支持版本为GCC 11和Clang 14。请注意,使用GCC 11的TSan数据竞争报告已知会导致较差的堆栈跟踪报告(没有行号),此问题已在GCC 12中修复。

启用TSan的编译器与常规编译器的区别在于:所有由ocamlopt编译的程序都将插入对TSan运行时的调用,并且TSan将在执行期间检测到遇到的数据竞争。

例如,考虑以下程序

let a = ref 0 and b = ref 0 let d1 () = a := 1; !b let d2 () = b := 1; !a let () = let h = Domain.spawn d2 in let r1 = d1 () in let r2 = Domain.join h in assert (not (r1 = 0 && r2 = 0))

此程序存在数据竞争。内存位置ab由多个域d1d2并发读取和写入。ab根据内存模型是“非原子”位置(参见第 ‍10章),并且它们之间没有同步访问。因此,这里有两个数据竞争,分别对应于两个内存位置ab

当您使用ocamlopt编译并运行此程序时,可能会在标准错误上观察到数据竞争报告,例如

==================
WARNING: ThreadSanitizer: data race (pid=3808831)
  Write of size 8 at 0x8febe0 by thread T1 (mutexes: write M90):
    #0 camlSimple_race.d2_274 simple_race.ml:8 (simple_race.exe+0x420a72)
    #1 camlDomain.body_706 stdlib/domain.ml:211 (simple_race.exe+0x440f2f)
    #2 caml_start_program <null> (simple_race.exe+0x47cf37)
    #3 caml_callback_exn runtime/callback.c:197 (simple_race.exe+0x445f7b)
    #4 domain_thread_func runtime/domain.c:1167 (simple_race.exe+0x44a113)

  Previous read of size 8 at 0x8febe0 by main thread (mutexes: write M86):
    #0 camlSimple_race.d1_271 simple_race.ml:5 (simple_race.exe+0x420a22)
    #1 camlSimple_race.entry simple_race.ml:13 (simple_race.exe+0x420d16)
    #2 caml_program <null> (simple_race.exe+0x41ffb9)
    #3 caml_start_program <null> (simple_race.exe+0x47cf37)
[...]

WARNING: ThreadSanitizer: data race (pid=3808831)
  Read of size 8 at 0x8febf0 by thread T1 (mutexes: write M90):
    #0 camlSimple_race.d2_274 simple_race.ml:9 (simple_race.exe+0x420a92)
    #1 camlDomain.body_706 stdlib/domain.ml:211 (simple_race.exe+0x440f2f)
    #2 caml_start_program <null> (simple_race.exe+0x47cf37)
    #3 caml_callback_exn runtime/callback.c:197 (simple_race.exe+0x445f7b)
    #4 domain_thread_func runtime/domain.c:1167 (simple_race.exe+0x44a113)

  Previous write of size 8 at 0x8febf0 by main thread (mutexes: write M86):
    #0 camlSimple_race.d1_271 simple_race.ml:4 (simple_race.exe+0x420a01)
    #1 camlSimple_race.entry simple_race.ml:13 (simple_race.exe+0x420d16)
    #2 caml_program <null> (simple_race.exe+0x41ffb9)
    #3 caml_start_program <null> (simple_race.exe+0x47cf37)
[...]

==================
ThreadSanitizer: reported 2 warnings

对于每个检测到的数据竞争,TSan都会报告冲突访问的位置、它们的性质(读取、写入、原子读取等)以及相关的堆栈跟踪。

如果多次运行上述程序,输出可能会发生变化:有时TSan会报告两个数据竞争,有时报告一个,有时不报告任何一个。这是由于两个因素的组合导致的

此示例说明了数据竞争有时可能被无关的同步操作隐藏的事实。

2 性能影响

TSan检测会给运行时带来不可忽略的成本。根据经验,此成本会导致减速,范围从2倍到7倍。高减速的主要因素之一是频繁访问可变数据。相反,对不可变内存位置的初始化写入和读取不会被检测。TSan还会分配大量虚拟内存,尽管它只使用其中的一小部分。内存消耗增加了4到7倍。

3 误报和漏报

如前面的示例所示,TSan只会报告执行期间遇到的数据竞争。另一个重要的注意事项是TSan每个内存位置仅记住有限数量的内存访问。在撰写本文时,此数字为4。涉及遗忘访问的数据竞争将不会被检测到。最后,TSan的文档指出,如果两个线程同时访问同一位置,则存在极小的错过竞争的概率。TSan可能只会在这三种特定情况下忽略数据竞争。

对于来自OCaml代码的两次内存访问之间的数据竞争,TSan不会产生误报;也就是说,TSan不会发出虚假报告。

当通过使用C原语混合OCaml和C代码时,误报的概念变得不太清晰,因为它涉及两个内存模型——OCaml和C11。但是,TSan的行为应该与预期基本一致:C中的非原子读写将与OCaml中的非原子读写发生竞争,C原子操作将不会与OCaml原子操作发生竞争。存在一种理论上的误报可能性:如果一个value从C初始化而没有使用caml_initialize(在GC在分配和写入之间不运行的条件下允许,请参见第 ‍22章),并且另一个线程稍后进行了冲突访问。这并不构成数据竞争,但TSan可能会将其报告为数据竞争。

4 运行时选项

TSan支持使用TSAN_OPTIONS环境变量在运行时配置许多选项。TSAN_OPTIONS应包含一个或多个用空格分隔的选项。有关更多信息,请参见TSan标志文档所有消毒程序的通用标志文档。值得注意的是,TSAN_OPTIONS允许从TSan报告中抑制某些数据竞争。抑制数据竞争报告对于有意发生的竞争或无法修复的库很有用。

例如,要抑制源自OCaml模块My_module中函数的报告,可以运行

TSAN_OPTIONS="suppressions=suppr.txt" ./my_instrumented_program

其中suppr.txt是一个包含以下内容的文件

race_top:^camlMy_module

(请注意,这取决于可执行文件中OCaml符号的格式。某些构建器(如Dune)可能会导致不同的格式。您应该根据堆栈跟踪中实际存在的符号调整此示例。)

TSAN_OPTIONS变量还允许增加“历史大小”,例如

TSAN_OPTIONS="history_size=7" ./my_instrumented_program

TSan的历史记录事件(如函数进入和退出),并用于重建堆栈跟踪。有时可能需要增加历史大小来获取第二个堆栈跟踪,但它也会增加内存消耗。此设置不会更改每个内存位置记住的内存访问次数。

5 链接指南

一般来说,使用TSan检测的OCaml程序只能与也使用TSan检测的OCaml或C对象链接。否则可能会导致崩溃。此规则的唯一例外是不以任何方式调用OCaml运行时系统的C库,即不分配、引发异常、回调到OCaml代码等。例如,libc或系统库。不会报告未检测库中的数据竞争。

与OCaml交互的C代码应始终通过ocamlopt命令构建,该命令会将所需的检测标志传递给C编译器。CAMLno_tsan限定符可用于防止对函数进行检测

CAMLno_tsan void f(int arg)
{
  /* This function will not be instrumented. */
  ...
}

不会报告来自未检测函数的竞争。CAMLno_tsan应仅由专家使用。它可用于在某些极端情况下减少性能开销,或抑制一些已知的警报。对于后者,应尽可能优先使用TSAN_OPTIONS中的抑制文件,因为它允许更细粒度的控制,并且使用CAMLno_tsan限定函数f会导致在f的传递调用者中发生数据竞争时TSan的堆栈跟踪中缺少条目。

无法在OCaml代码中禁用检测。

6 信号传递的更改

TSan 拦截所有信号并将其传递给已插装的程序。这种来自 TSan 的覆盖对于程序并不总是透明的。同步信号,例如 SIGSEVSIGILLSIGBUS 等,将立即传递下去,而异步信号,例如 SIGINT,则会被延迟到下一次调用 TSan 运行时(例如,直到下一次访问可变数据)。TSan 的这一限制可能会产生意想不到的影响:例如,不分配内存的纯递归函数在终止之前无法被中断。