使用线程安全分析器过渡到多核
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 的多核,我们可以利用以下工作流程
- 安装 TSan
- 编写并行测试运行器
- 在 TSan 下运行测试
- 如果 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 | ()
^^^^^^^^^
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 ()
Write of size 8 at 0x7f5b0c0fd6d8 by thread T4 ():
#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
调用包装 transfer
和 iter_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 | ()
^^^^^^^^^
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.lock
和 Mutex.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_data
的 Hashtbl
。
最后要提醒的是,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,您现在应该能够找到数据竞争。