第 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 switch

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 並行讀取和寫入。根據記憶體模型(請參閱第 10 章),ab 是「非原子」位置,而且它們的存取之間沒有同步。因此,這裡有兩個資料競爭,對應於兩個記憶體位置 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 原子操作競爭。理論上存在偽陽性的一種可能性:如果從 C 初始化 value 而未使用 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 的抑制檔案,因為它允許更精細的控制,而且當資料競爭發生在 f 的傳遞呼叫者中時,使用 CAMLno_tsan 限定函式 f 會導致 TSan 的堆疊追蹤中缺少項目。

沒有方法可以在 OCaml 程式碼中停用儀器化。

6 訊號傳遞的變更

TSan 會攔截所有訊號並將其向下傳遞至儀器化程式。來自 TSan 的此覆疊對於程式而言並不總是透明的。同步訊號,例如 SIGSEVSIGILLSIGBUS 等,會立即向下傳遞,而異步訊號(例如 SIGINT)將會延遲到下次呼叫 TSan 執行期時(例如,直到下次存取可變資料時)。TSan 的此限制可能會產生令人驚訝的效果:例如,在終止之前,純粹、遞迴且不配置的函式無法中斷。