使用线程安全分析器过渡到多核

5.0 版本为 OCaml 语言带来了多核、基于Domain的并行处理。但是,并行Domain在共享可变内存位置上执行不协调的操作可能会导致数据竞争。不幸的是,此类问题不会 ( yet ) 被 OCaml 的强类型系统捕获,这意味着在将并行处理引入现有的 OCaml 代码库时,这些问题可能会被忽略。因此,在本指南中,我们将研究一个分步工作流程,该工作流程利用 线程安全分析器 (TSan) 工具来帮助使您的 OCaml 代码准备好用于 5.x 版本。

注意:从 OCaml 5.2.0 开始,TSan 支持适用于 所有 Tier 1 架构,包括 FreeBSD、Linux 和 macOS。使用 TSan 支持构建 OCaml 需要至少安装 GCC 11 或 Clang 14 作为您的 C 编译器。请注意,GCC 11 的 TSan 数据竞争报告已知会导致较差的堆栈跟踪报告(没有行号),这个问题在 GCC 12 中已修复。

示例应用程序

考虑一个小型银行库,它在 bank.mli 中具有以下签名

type t
(** a collective type representing a bank *)

val init : num_accounts:int -> init_balance:int -> t
(** [init ~num_accounts ~init_balance] creates a bank with [num_accounts] each
    containing [init_balance]. *)

val transfer : t -> src_acc:int -> dst_acc:int -> amount:int -> unit
(** [transfer t ~src_acc ~dst_acc ~amount] moves [amount] from account
    [src_acc] to account [dst_acc].
    @raise Invalid_argument if amount is not positive,
    if [src_acc] and [dst_acc] are the same, or if [src_acc] contains
    insufficient funds. *)

val iter_accounts : t -> (account:int -> balance:int -> unit) -> unit
(** [iter_accounts t f] applies [f] to each account from [t]
    one after another. *)

在幕后,库可能以多种方式实现。考虑 bank.ml 中的以下线程不安全实现

type t = int array

let init ~num_accounts ~init_balance =
  Array.make num_accounts init_balance

let transfer t ~src_acc ~dst_acc ~amount =
  begin
    if amount <= 0 then raise (Invalid_argument "Amount has to be positive");
    if src_acc = dst_acc then raise (Invalid_argument "Cannot transfer to yourself");
    if t.(src_acc) < amount then raise (Invalid_argument "Not enough money on account");
    t.(src_acc) <- t.(src_acc) - amount;
    t.(dst_acc) <- t.(dst_acc) + amount;
  end

let iter_accounts t f = (* inspect the bank accounts *)
  Array.iteri (fun account balance -> f ~account ~balance) t;

建议的工作流程

现在,如果我们要查看此代码是否已准备好用于 OCaml 5.x 的多核,我们可以利用以下工作流程

  1. 安装 TSan
  2. 编写并行测试运行器
  3. 在 TSan 下运行测试
  4. 如果 TSan 抱怨数据竞争,请解决报告的问题并转到步骤 2。

遵循工作流程

现在,我们将针对示例应用程序逐步执行建议的工作流程。

安装检测 TSan 编译器(步骤 0)

从 OCaml 5.2 开始,TSan 包含在 OCaml 中,但必须明确启用。您可以按照以下步骤安装 TSan 开关(这里我们创建一个名为 5.2.0+tsan 的 5.2.0 开关)

opam switch create 5.2.0+tsan ocaml-variants.5.2.0+options ocaml-option-tsan

故障排除

  • 如果上述操作在使用 conf-unwind 安装 No package 'libunwind' found 时失败,请尝试设置环境变量 PKG_CONFIG_PATH 以指向 libunwind.pc 的位置,例如,PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig
  • 如果上述操作出现类似于 FATAL: ThreadSanitizer: unexpected memory mapping 0x61a1a94b2000-0x61a1a94ca000 的错误,这是 TSan 旧版本的已知问题,可以通过运行 sudo sysctl vm.mmap_rnd_bits=28 来降低 ASLR 熵来解决

编写并行测试运行器(步骤 1)

首先,我们可以通过并行运行两个 Domain 来测试库在并行使用下的情况。以下是 bank_test.ml 中一个利用此想法的快速测试运行器

let num_accounts = 7

let money_shuffle t = (* simulate an economy *)
  for i = 1 to 10 do
    Unix.sleepf 0.1 ; (* wait for a network request *)
    let src_acc = i mod num_accounts in
    let dst_acc = (i*3+1) mod num_accounts in
    try Bank.transfer t ~src_acc ~dst_acc ~amount:1 (* transfer $1 *)
    with Invalid_argument _ -> ()
  done

let print_balances t = (* inspect the bank accounts *)
  for _ = 1 to 12 do
    let sum = ref 0 in
    Bank.iter_accounts t
      (fun ~account ~balance -> Format.printf "%i %3i " account balance; sum := !sum + balance);
    Format.printf "  total = %i @." !sum;
    Unix.sleepf 0.1;
  done

let _ =
  let t = Bank.init ~num_accounts ~init_balance:100 in
  (* run the simulation and the debug view in parallel *)
  [| Domain.spawn (fun () -> money_shuffle t);
     Domain.spawn (fun () -> print_balances t);
  |]
  |> Array.iter Domain.join

运行器创建一个包含 7 个账户(每个账户 100 美元)的银行,然后并行运行两个循环,其中

  • 一个使用 money_shuffle 转移资金
  • 另一个使用 print_balances 反复打印账户余额
$ opam switch 5.2.0
$ opam exec -- dune runtest
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1  99 2 100 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 100 5 100 6 101   total = 700
0 101 1  99 2 100 3 100 4 100 5  99 6 101   total = 700
0 101 1  99 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1  99 2 100 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700

从在常规 5.1.0 编译器下运行的结果来看,人们可能认为一切正常,因为余额总计为 700 美元(符合预期),表明没有丢失资金。

在 TSan 下运行并行测试(步骤 2)

现在,让我们在 TSan 下执行相同的测试运行。操作很简单,如下所示,它会立即抱怨存在竞争

$ opam switch 5.2.0+tsan
$ opam exec -- dune runtest
File "test/dune", line 2, characters 7-16:
2 |  (name bank_test)
           ^^^^^^^^^
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1  99 2 100 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 100 5 100 6 101   total = 700
0 101 1  99 2 100 3 100 4 100 5  99 6 101   total = 700
0 101 1  99 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1  99 2 100 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
==================
WARNING: ThreadSanitizer: data race (pid=26148)
  Write of size 8 at 0x7f5b0c0fd6d8 by thread T4 (mutexes: write M85):
    #0 camlBank.transfer_322 lib/bank.ml:11 (bank_test.exe+0x6de4d)
    #1 camlDune__exe__Bank_test.money_shuffle_270 test/bank_test.ml:8 (bank_test.exe+0x6d7c5)
    #2 camlStdlib__Domain.body_703 /home/opam/.opam/5.2.0+tsan/.opam-switch/build/ocaml-variants.5.2.0+tsan/stdlib/domain.ml:202 (bank_test.exe+0xb06b0)
    #3 caml_start_program <null> (bank_test.exe+0x13fdfb)
    #4 caml_callback_exn runtime/callback.c:201 (bank_test.exe+0x106053)
    #5 domain_thread_func runtime/domain.c:1215 (bank_test.exe+0x10a2b1)

  Previous read of size 8 at 0x7f5b0c0fd6d8 by thread T1 (mutexes: write M81):
    #0 camlStdlib__Array.iteri_367 /home/opam/.opam/5.2.0+tsan/.opam-switch/build/ocaml-variants.5.2.0+tsan/stdlib/array.ml:136 (bank_test.exe+0xa0f36)
    #1 camlDune__exe__Bank_test.print_balances_496 test/bank_test.ml:15 (bank_test.exe+0x6d8f4)
    #2 camlStdlib__Domain.body_703 /home/opam/.opam/5.2.0+tsan/.opam-switch/build/ocaml-variants.5.2.0+tsan/stdlib/domain.ml:202 (bank_test.exe+0xb06b0)
    #3 caml_start_program <null> (bank_test.exe+0x13fdfb)
    #4 caml_callback_exn runtime/callback.c:201 (bank_test.exe+0x106053)
    #5 domain_thread_func runtime/domain.c:1205 (bank_test.exe+0x10a2b1)

  [...]

注意,我们获得了两个竞争访问的回溯,其中

  • 一个 Domain 中的写入来自 Bank.transfer 中的数组赋值
  • 另一个 Domain 中的读取来自 Stdlib.Array.iteri 中的调用,以读取和打印 print_balances 中的数组条目。

解决报告的竞争并重新运行测试(步骤 3 和 2)

解决报告的竞争的一种方法是添加一个 Mutex,以确保对底层数组的独占访问。第一个尝试可能是使用 lock-unlock 调用包装 transferiter_accounts,如下所示

let lock = Mutex.create () (* addition *)

let transfer t ~src_acc ~dst_acc ~amount =
  begin
    Mutex.lock lock; (* addition *)
    if amount <= 0 then raise (Invalid_argument "Amount has to be positive");
    if src_acc = dst_acc then raise (Invalid_argument "Cannot transfer to yourself");
    if t.(src_acc) < amount then raise (Invalid_argument "Not enough money on account");
    t.(src_acc) <- t.(src_acc) - amount;
    t.(dst_acc) <- t.(dst_acc) + amount;
    Mutex.unlock lock; (* addition *)
  end

let iter_accounts t f = (* inspect the bank accounts *)
  Mutex.lock lock; (* addition *)
  Array.iteri (fun account balance -> f ~account ~balance) t;
  Mutex.unlock lock (* addition *)

重新运行我们的测试,我们得到

$ opam exec -- dune runtest
File "test/dune", line 2, characters 7-16:
2 |  (name bank_test)
           ^^^^^^^^^
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1  99 2 100 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
Fatal error: exception Sys_error("Mutex.lock: Resource deadlock avoided")

为什么在只添加了两对 Mutex.lockMutex.unlock 调用后,我们可能会遇到资源死锁错误?

解决报告的竞争并重新运行测试,尝试 2(步骤 3 和 2)

哦,等等!在 transfer 中引发异常时,我们忘记再次解锁 Mutex。让我们修改函数以进行处理

let transfer t ~src_acc ~dst_acc ~amount =
  begin
    if amount <= 0 then raise (Invalid_argument "Amount has to be positive");
    if src_acc = dst_acc then raise (Invalid_argument "Cannot transfer to yourself");
    Mutex.lock lock; (* addition *)
    if t.(src_acc) < amount
    then (Mutex.unlock lock; (* addition *)
          raise (Invalid_argument "Not enough money on account"));
    t.(src_acc) <- t.(src_acc) - amount;
    t.(dst_acc) <- t.(dst_acc) + amount;
    Mutex.unlock lock; (* addition *)
  end

现在,我们可以重新运行 TSan 下的测试以确认修复

$ opam exec -- dune runtest
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1  99 2 100 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2 100 3 100 4 100 5  99 6 101   total = 700
0 101 1  99 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1 100 2 100 3 100 4 100 5 100 6 100   total = 700
0 100 1  99 2 100 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700
0 101 1  99 2  99 3 100 4 101 5 100 6 100   total = 700

这工作正常,TSan 不再抱怨,所以我们的小库已准备好用于 OCaml 5.x 并行处理,太棒了!

最后备注和警告

我们遇到的“始终在最后执行某些操作”的编程模式(缺少 Mutex.unlock)是一个反复出现的问题,OCaml 为此提供了一个专用函数

 Fun.protect : finally:(unit -> unit) -> (unit -> 'a) -> 'a

使用 Fun.protect,我们可以将最终修复编写如下

let transfer t ~src_acc ~dst_acc ~amount =
  begin
    if amount <= 0 then raise (Invalid_argument "Amount has to be positive");
    if src_acc = dst_acc then raise (Invalid_argument "Cannot transfer to yourself");
    Mutex.lock lock; (* addition *)
    Fun.protect ~finally:(fun () -> Mutex.unlock lock) (* addition *)
      (fun () ->
         begin
           if t.(src_acc) < amount
           then raise (Invalid_argument "Not enough money on account");
           t.(src_acc) <- t.(src_acc) - amount;
           t.(dst_acc) <- t.(dst_acc) + amount;
         end)
  end

诚然,如果性能是一个问题,那么使用 Mutex 来确保独占访问可能有点繁重。如果是这种情况,一种选择是将底层 array 替换为无锁数据结构,例如来自Kcas_dataHashtbl

最后要提醒的是,Domain 的速度非常快,以至于在一个过于简单的测试运行器中,一个 Domain 可能会在第二个 Domain 启动之前完成!这会导致问题,因为 TSan 将无法观察和检查明显的并行处理。在上面的示例中,对 Unix.sleepf 的调用有助于确保测试运行器确实处于并行状态。一个有用的替代技巧是在一个 Atomic 上进行协调,以确保两个 Domain 都已启动并运行,然后再继续执行并行测试代码。为此,我们可以修改并行测试运行器,如下所示

let _ =
  let wait = Atomic.make 2 in
  let t = Bank.init ~num_accounts ~init_balance:100 in
  (* run the simulation and the debug view in parallel *)
  [| Domain.spawn (fun () ->
         Atomic.decr wait; while Atomic.get wait > 0 do () done; money_shuffle t);
     Domain.spawn (fun () ->
         Atomic.decr wait; while Atomic.get wait > 0 do () done; print_balances t);
  |]
  |> Array.iter Domain.join

牢记这个警告,并拥有 TSan,您现在应该能够找到数据竞争。

仍然需要帮助?

帮助改进我们的文档

所有 OCaml 文档都是开源的。发现错误或不清楚的地方?提交拉取请求。

OCaml

创新。社区。安全。