从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将在执行期间检测到遇到的数据竞争。
例如,考虑以下程序
此程序存在数据竞争。内存位置a和b由多个域d1和d2并发读取和写入。a和b根据内存模型是“非原子”位置(参见第 10章),并且它们之间没有同步访问。因此,这里有两个数据竞争,分别对应于两个内存位置a和b。
当您使用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会报告两个数据竞争,有时报告一个,有时不报告任何一个。这是由于两个因素的组合导致的
此示例说明了数据竞争有时可能被无关的同步操作隐藏的事实。
TSan检测会给运行时带来不可忽略的成本。根据经验,此成本会导致减速,范围从2倍到7倍。高减速的主要因素之一是频繁访问可变数据。相反,对不可变内存位置的初始化写入和读取不会被检测。TSan还会分配大量虚拟内存,尽管它只使用其中的一小部分。内存消耗增加了4到7倍。
如前面的示例所示,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可能会将其报告为数据竞争。
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的历史记录事件(如函数进入和退出),并用于重建堆栈跟踪。有时可能需要增加历史大小来获取第二个堆栈跟踪,但它也会增加内存消耗。此设置不会更改每个内存位置记住的内存访问次数。
一般来说,使用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代码中禁用检测。
TSan 拦截所有信号并将其传递给已插装的程序。这种来自 TSan 的覆盖对于程序并不总是透明的。同步信号,例如 SIGSEV、SIGILL、SIGBUS 等,将立即传递下去,而异步信号,例如 SIGINT,则会被延迟到下一次调用 TSan 运行时(例如,直到下一次访问可变数据)。TSan 的这一限制可能会产生意想不到的影响:例如,不分配内存的纯递归函数在终止之前无法被中断。