调试
本教程介绍四种调试 OCaml 程序的技术
- 跟踪函数调用,在交互式顶层工作。
- The OCaml 调试器,它允许分析使用
ocamlc
编译的程序。 - 如何获取未捕获异常的回溯 在 OCaml 程序中
- 使用线程安全分析器检测数据竞争 在 OCaml 5 程序中
在顶层跟踪函数调用
在顶层调试程序的最简单方法是通过“跟踪”有问题的函数来跟踪函数调用。
# let rec fib x = if x <= 1 then 1 else fib (x - 1) + fib (x - 2);;
val fib : int -> int = <fun>
# #trace fib;;
fib is now traced.
# fib 3;;
fib <-- 3
fib <-- 1
fib --> 1
fib <-- 2
fib <-- 0
fib --> 1
fib <-- 1
fib --> 1
fib --> 2
fib --> 3
- : int = 3
# #untrace fib;;
fib is no longer traced.
多态函数
多态函数的一个难点是,如果参数和/或结果是多态的,则跟踪系统的输出信息量很少。考虑一个排序算法(例如冒泡排序)
# let exchange i j v =
let aux = v.(i) in
v.(i) <- v.(j);
v.(j) <- aux;;
val exchange : int -> int -> 'a array -> unit = <fun>
# let one_pass_vect fin v =
for j = 1 to fin do
if v.(j - 1) > v.(j) then exchange (j - 1) j v
done;;
val one_pass_vect : int -> 'a array -> unit = <fun>
# let bubble_sort_vect v =
for i = Array.length v - 1 downto 0 do
one_pass_vect i v
done;;
val bubble_sort_vect : 'a array -> unit = <fun>
# let q = [|18; 3; 1|];;
val q : int array = [|18; 3; 1|]
# #trace one_pass_vect;;
one_pass_vect is now traced.
# bubble_sort_vect q;;
one_pass_vect <-- 2
one_pass_vect --> <fun>
one_pass_vect* <-- [|<poly>; <poly>; <poly>|]
one_pass_vect* --> ()
one_pass_vect <-- 1
one_pass_vect --> <fun>
one_pass_vect* <-- [|<poly>; <poly>; <poly>|]
one_pass_vect* --> ()
one_pass_vect <-- 0
one_pass_vect --> <fun>
one_pass_vect* <-- [|<poly>; <poly>; <poly>|]
one_pass_vect* --> ()
- : unit = ()
函数 one_pass_vect
是多态的,它的向量参数被打印为包含多态值的向量,[|<poly>; <poly>; <poly>|]
,因此我们无法正确跟踪计算。
克服此问题的一种简单方法是定义有问题的函数的单态版本。使用 *类型约束* 可以很容易地做到这一点。一般来说,这允许正确理解多态函数定义中的错误。一旦更正了这一点,您只需抑制类型约束以恢复到函数的多态版本。
对于我们的排序例程,在 exchange
函数的参数上进行一个类型约束可以保证单态类型,这允许正确跟踪函数调用。
# let exchange i j (v : int array) = (* notice the type constraint *)
let aux = v.(i) in
v.(i) <- v.(j);
v.(j) <- aux;;
val exchange : int -> int -> int array -> unit = <fun>
# let one_pass_vect fin v =
for j = 1 to fin do
if v.(j - 1) > v.(j) then exchange (j - 1) j v
done;;
val one_pass_vect : int -> int array -> unit = <fun>
# let bubble_sort_vect v =
for i = Array.length v - 1 downto 0 do
one_pass_vect i v
done;;
val bubble_sort_vect : int array -> unit = <fun>
# let q = [| 18; 3; 1 |];;
val q : int array = [|18; 3; 1|]
# #trace one_pass_vect;;
one_pass_vect is now traced.
# bubble_sort_vect q;;
one_pass_vect <-- 2
one_pass_vect --> <fun>
one_pass_vect* <-- [|18; 3; 1|]
one_pass_vect* --> ()
one_pass_vect <-- 1
one_pass_vect --> <fun>
one_pass_vect* <-- [|3; 1; 18|]
one_pass_vect* --> ()
one_pass_vect <-- 0
one_pass_vect --> <fun>
one_pass_vect* <-- [|1; 3; 18|]
one_pass_vect* --> ()
- : unit = ()
局限性
要跟踪程序中对数据结构和可变变量的赋值,跟踪功能还不够强大。您需要一个额外的机制来在任何地方停止程序并询问内部值:这是一个具有单步执行功能的符号调试器。
单步执行功能程序的意义有点奇怪,难以定义和理解。我们将使用在将参数传递给函数时、进入模式匹配时或在模式匹配中选择子句时发生的 *运行时事件* 的概念。计算进度由这些事件考虑,独立于硬件上执行的指令。
虽然这很难实现,但 Unix 下确实存在一个用于 OCaml 的调试器:ocamldebug
。下一节将说明它的使用。
事实上,对于复杂的程序,程序员可能会使用显式打印来查找错误,因为这种方法允许减少跟踪材料:只打印有用的数据,并且专用格式更适合获取相关信息,而不是跟踪机制可以自动输出的内容。 通用漂亮打印机。
OCaml 调试器
我们现在将快速介绍 OCaml 调试器 (ocamldebug
)。在开始之前,请注意
ocamldebug
在ocamlc
字节码程序上运行(它不适用于本机代码可执行文件),并且- 它不适用于 OCaml 的本机 Windows 端口(但它在 Cygwin 端口上运行)。
启动调试器
考虑以下在文件 uncaught.ml
中编写的明显错误的程序
(* file uncaught.ml *)
let l = ref []
let find_address name = List.assoc name !l
let add_address name address = l := (name, address) :: ! l
let () =
add_address "IRIA" "Rocquencourt";;
print_string (find_address "INRIA"); print_newline ();;
val l : (string * string) list ref = {contents = [("IRIA", "Rocquencourt")]}
val find_address : string -> string = <fun>
val add_address : string -> string -> unit = <fun>
Exception: Not_found.
在运行时,程序引发了一个未捕获的异常 Not_found
。假设我们要找出此异常是在哪里以及为什么引发的,我们可以按照以下步骤进行。首先,我们在调试模式下编译程序
ocamlc -g uncaught.ml
我们启动调试器
ocamldebug a.out
然后调试器会用一个横幅和一个提示符进行响应
OCaml Debugger version 4.14.0
(ocd)
查找虚假异常的原因
键入 r
(表示 *运行*);您将得到
(ocd) r
Loading program... done.
Time : 27
Program end.
Uncaught exception: Not_found
(ocd)
不言自明,不是吗?所以,您想向后单步执行以将程序计数器设置在引发异常之前的时间;因此,键入 b
作为 *回退*,您将得到
(ocd) b
Time: 26 - pc: 0:29628 - module Stdlib__List
191 [] -> raise Not_found<|a|>
调试器告诉您您在 Stdlib
的 List
模块中,在一个对列表的模式匹配中,该模式匹配已经选择了 []
案例,并且刚刚执行了 raise Not_found
,因为程序在该表达式之后停止(如 <|a|>
标记所示)。
但是,正如您所知,您希望调试器告诉您哪个过程调用了来自 List
的过程,以及谁调用了调用来自 List
的过程的过程;因此,您需要执行堆栈的回溯
(ocd) bt
Backtrace:
#0 Stdlib__List list.ml:191:26
#1 Uncaught uncaught.ml:8:38
所以,最后一个被调用的函数来自 List
模块的第 191 行,第 26 个字符,即
let rec assoc x = function
| [] -> raise Not_found
^
| (a,b)::l -> if a = x then b else assoc x l
调用它的函数位于 Uncaught
模块中,文件 uncaught.ml
第 8 行,第 38 个字符
print_string (find_address "INRIA"); print_newline ();;
^
总而言之:如果您正在开发一个程序,您可以使用 -g
选项编译它,以便在必要时准备调试程序。因此,要查找虚假异常,您只需键入 ocamldebug a.out
,然后键入 r
、b
和 bt
,即可获得回溯。
在调试器中获取帮助和信息
要获取有关调试器当前状态的更多信息,您可以直接在调试器的顶层提示符下询问它;例如
(ocd) info breakpoints
No breakpoint.
(ocd) help break
break: Set breakpoint.
Syntax: break
break function-name
break @ [module] linenum
break @ [module] linenum columnnum
break @ [module] # characternum
break frag:pc
break pc
设置断点
让我们设置一个断点并从头开始重新运行整个程序((g)oto 0
然后 (r)un
)
(ocd) break @Uncaught 7
Breakpoint 1 at 0:42856: file uncaught.ml, line 7, characters 3-36
(ocd) g 0
Time : 0
Beginning of program.
(ocd) r
Time: 20 - pc: 0:42856 - module Uncaught
Breakpoint: 1
7 add_address "IRIA" "Rocquencourt"<|a|>;;
然后,我们可以单步执行并找到在 List.assoc
即将在 find_address
中被调用之前(<|b|>
)发生的事情
(ocd) s
Time: 21 - pc: 0:42756 - module Uncaught
3 let find_address name = <|b|>List.assoc name !l
(ocd) p name
name : string = "INRIA"
(ocd) p !l
$1 : (string * string) list = ["IRIA", "Rocquencourt"]
(ocd)
现在我们可以猜测为什么 List.assoc
将无法在列表中找到“INRIA”...
在 Emacs 下使用调试器
在 Emacs 中,您可以使用 ESC-x
ocamldebug a.out
调用调试器。然后,Emacs 会将您直接发送到调试器报告的文件和字符,您可以使用 ESC-b
和 ESC-s
来回单步执行。此外,您可以使用 CTRL-X space
设置断点,等等...
打印未捕获异常的回溯
获取未捕获异常的回溯可能有助于了解问题发生在哪个上下文中。但是,默认情况下,使用 ocamlc
和 ocamlopt
编译的程序不会打印它
ocamlc -g uncaught.ml
./a.out
Fatal error: exception Not_found
ocamlopt -g uncaught.ml
./a.out
Fatal error: exception Not_found
通过将环境变量 OCAMLRUNPARAM
设置为 b
(表示回溯),我们会得到更多信息
OCAMLRUNPARAM=b ./a.out
Fatal error: exception Not_found
Raised at Stdlib__List.assoc in file "list.ml", line 191, characters 10-25
Called from Uncaught.find_address in file "uncaught.ml" (inlined), line 3, characters 24-42
Called from Uncaught in file "uncaught.ml", line 8, characters 15-37
从这个回溯中可以清楚地看出,我们在第 8 行调用 find_address
时,从 Stdlib
的 List.assoc
中接收了一个 Not_found
异常。
当处理使用 dune
生成的程序时,环境变量 OCAMLRUNPARAM
也适用。
;; file dune
(executable
(name uncaught)
(modules uncaught)
)
OCAMLRUNPARAM=b dune exec ./uncaught.exe
Fatal error: exception Not_found
Raised at Stdlib__List.assoc in file "list.ml", line 191, characters 10-25
Called from Dune__exe__Uncaught.find_address in file "uncaught.ml" (inlined), line 3, characters 24-42
Called from Dune__exe__Uncaught in file "uncaught.ml", line 8, characters 15-37
使用线程安全分析器检测数据竞争
随着 OCaml 5 中引入多核并行性,随之而来的是在涉及的 Domain
之间引入数据竞争的风险。幸运的是,OCaml 的线程安全分析器 (TSan) 模式有助于捕获和报告这些数据竞争。
安装 TSan 切换
要安装 TSan 模式,请通过运行以下命令创建一个专用的 TSan 切换(这里我们创建了一个 5.2.0 切换)
opam switch create 5.2.0+tsan ocaml-variants.5.2.0+options ocaml-option-tsan
要确认 TSan 切换是否已正确安装,请运行 opam switch show
并确认它打印了 5.2.0+tsan
。
注意:自 OCaml 5.2.0 起,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 熵来解决。
检测数据竞争
现在考虑以下在 race.ml
文件中编写的 OCaml 程序。
(* file race.ml *)
type t = { mutable x : int }
let v = { x = 0 }
let () =
let t1 = Domain.spawn (fun () -> v.x <- 10; Unix.sleep 1) in
let t2 = Domain.spawn (fun () -> v.x <- 11; Unix.sleep 1) in
Domain.join t1;
Domain.join t2;
Printf.printf "v.x is %i\n" v.x
它构建了一个带有可变字段 x
的记录 v
,该字段初始化为 0
。接下来,它生成两个并行 Domain
,t1
和 t2
,它们都更新了字段 v.x
。
这是一个对应的 dune
文件
namemoduleslibraries
如果我们使用 dune
在一个普通的 5.2.0
切换下编译并运行程序,该程序似乎可以正常工作。
$ opam exec -- dune build ./race.exe
$ opam exec -- dune exec ./race.exe
v.x is 11
但是,如果我们使用新的 5.2.0+tsan
切换下的 Dune 编译并运行程序,TSan 会警告我们存在数据竞争。
$ opam switch 5.2.0+tsan
$ opam exec -- dune build ./race.exe
$ opam exec -- dune exec ./race.exe
==================
WARNING: ThreadSanitizer: data race (pid=19414)
Write of size 8 at 0x7fb9d72fe498 by thread T4 (mutexes: write M87):
#0 camlDune__exe__Race__fun_560 /home/user/race/_build/default/race.ml:6 (race.exe+0x60c65)
#1 camlStdlib__Domain__body_696 /home/user/.opam/5.2.0+tsan/.opam-switch/build/ocaml-variants.5.2.0+tsan/stdlib/domain.ml:202 (race.exe+0x9c38c)
#2 caml_start_program <null> (race.exe+0x110117)
#3 caml_callback_exn runtime/callback.c:201 (race.exe+0xe00fe)
#4 domain_thread_func runtime/domain.c:1215 (race.exe+0xe3e83)
Previous write of size 8 at 0x7fb9d72fe498 by thread T1 (mutexes: write M83):
#0 camlDune__exe__Race__fun_556 /home/user/race/_build/default/race.ml:5 (race.exe+0x60c05)
#1 camlStdlib__Domain__body_696 /home/user/.opam/5.2.0+tsan/.opam-switch/build/ocaml-variants.5.2.0+tsan/stdlib/domain.ml:202 (race.exe+0x9c38c)
#2 caml_start_program <null> (race.exe+0x110117)
#3 caml_callback_exn runtime/callback.c:201 (race.exe+0xe00fe)
#4 domain_thread_func runtime/domain.c:1215 (race.exe+0xe3e83)
Mutex M87 (0x560c0b4fc438) created at:
#0 pthread_mutex_init ../../../../src/libsanitizer/tsan/tsan_interceptors_posix.cpp:1295 (libtsan.so.2+0x50468)
#1 caml_plat_mutex_init runtime/platform.c:57 (race.exe+0x1022f8)
[...]
SUMMARY: ThreadSanitizer: data race (/tmp/race/race.exe+0x4efb15) in camlRace__fun_560
==================
v.x is 11
ThreadSanitizer: reported 1 warnings
请注意,由于 TSan 检测是在一个单独的切换下进行的,因此我们的 dune
文件不需要任何更改。
TSan 报告警告了两个在并行发生的未协调写入之间的数据竞争,并打印了这两个的回溯。
- 第一个回溯报告了在
race.ml
的第 6 行,thread T4
中的写入。 - 第二个回溯报告了之前在
race.ml
的第 5 行,thread T1
中的写入。
再次查看我们的程序,我们意识到这两个写入实际上并没有协调。一个可能的解决方法是用一个 Atomic
替换我们的可变记录字段,该字段保证每个这样的写入都完整地发生,一个接一个。
(* file race.ml *)
let v = Atomic.make 0
let () =
let t1 = Domain.spawn (fun () -> Atomic.set v 10; Unix.sleep 1) in
let t2 = Domain.spawn (fun () -> Atomic.set v 11; Unix.sleep 1) in
Domain.join t1;
Domain.join t2;
Printf.printf "v is %i\n" (Atomic.get v)
如果我们重新编译并运行包含此更改的程序,它现在将在没有 TSan 警告的情况下完成。
$ opam exec -- dune build ./race.exe
$ opam exec -- dune exec ./race.exe
v is 11
TSan 检测从使用调试信息编译程序中获益,调试信息默认情况下在 dune
中生成。因此,要在我们的 5.2.0+tsan
切换下手动调用 ocamlopt
编译器,只需传递 -g
标志即可。
$ ocamlopt -g -o race.exe -I +unix unix.cmxa race.ml