OCaml 5 多核运行时深度工程实战:从 Domain/Fiber 与 Effect Handler、无读屏障内存模型到并发 Major GC
执行摘要:OCaml 5 是近十年函数式语言运行时里改动最激进的一次重构——它没有给旧运行时打补丁,而是从内存模型开始重写:默认弃用裸指针(no naked pointers)、引入松弛内存模型下的 DRF-SC 保证、把堆拆成「每域私有 minor heap + 全局共享 major heap」、用 effect handler 把并发原语从运行时下沉到库层。结果是:OCaml 拿到了真正并行的多线程,同时没有引入读屏障——这在主流托管运行时里几乎是独一份。本文沿「域与纤程 → 内存模型 → minor/major 两级 GC → 值表示 → effect 与 GC 的交互 → 生产调优」逐层拆开,给出可直接落地的参数与测量方法。
一、并发的两层:Domain 管并行,Fiber 管并发
OCaml 5 把「并行」和「并发」明确拆成两个实体:
| 概念 | 载体 | 调度方 | 内存 | 典型用法 |
|---|---|---|---|---|
| Domain | 一个 OS 线程 | 内核 | 私有 minor heap + 共享 major heap | Domain.spawn,数量≈核数 |
| Fiber | 用户态栈块链表 | 运行时/库(effect handler) | 从所属域的 minor heap 分配 | 协程、IO 调度器、生成器 |
let () =
let d = Domain.spawn (fun () ->
(* 每个域有独立的 minor heap,caml_alloc_small 是无锁 bump 分配 *)
let acc = ref 0 in
for _ = 1 to 10_000_000 do acc := !acc + 1 done;
!acc) in
Printf.printf "result=%d\n" (Domain.join d)
关键点:域之间不共享 minor heap。这意味着热路径的分配仍然是「指针碰撞」级别的,没有跨线程分配器争用,也不需要和 malloc arena / TCMalloc 的 per-thread cache 做同样的取舍——运行时直接在语义层保证了分配局部性。
Fiber 则由 effect handler 驱动,它是用户态、可被运行时移动/扩容的栈:
open Effect
open Effect.Deep
type _ t += Yield : unit t (* 自定义 effect *)
let yield () = perform Yield
let run main =
match_with main ()
{ retc = (fun x -> x);
exnc = raise;
effc = fun (type a) (e : a t) ->
match e with
| Yield -> Some (fun (k : (a, _) continuation) ->
continue k ()) (* 续延只能被调用一次:one-shot *)
| _ -> None }
OCaml 的 handler 是深 handler(continue 时 handler 仍在作用域内),但续延是 one-shot(仿射):不调用会泄漏一次续延的栈块,调用第二次直接抛异常。这和 Koka / libmprompt 的多-shot 续延形成对照,也是它能在不复制栈的前提下保持低开销的原因。
二、内存模型:为什么 OCaml 敢没有读屏障
主流分代并发 GC 为了处理「mutator 与 GC 并发读到旧对象」,通常需要读屏障(GHC 的 load_load_barrier / JVM G1 的 SATB 读侧、Shenandoah 的 Brooks/SATB 指针载入屏障)。OCaml 5 明确拒绝了这个代价,代价由三处设计共同承担:
- 默认禁止裸指针。自 4.14 起 "no naked pointers" 成为唯一模式:一个 word 要么是可被 GC 识别的合法 value,要么是立即值。GC 扫描时不再需要「猜这个指针是不是真的指向堆」。
- 写屏障而非读屏障。当 major heap 对象的字段被写入一个 minor heap 指针(old→young),写入方把该对象压入所属域的 remembered set。minor GC 只需扫描根集 + 各域 remembered set,无需扫描整个 major heap。
- 内存模型给出 DRF-SC。无数据竞争的程序表现得像顺序一致;原子访问用
Atomic.t显式标注,非原子访问在竞争下仍保持内存安全(不会段错误、不会撕裂出非法 value),只是不保证读到的值。
(* 反例:非原子读写在多域下是数据竞争,结果未定义 *)
let r = ref 0 in
let d1 = Domain.spawn (fun () -> for _ = 1 to 1_000_000 do r := !r + 1 done) in
for _ = 1 to 1_000_000 do r := !r + 1 done;
Domain.join d1;
Printf.printf "%d\n" !r (* 结果不等于 2_000_000,但程序不会崩 *)
(* 正解一:原子 *)
let a = Atomic.make 0 in
(* ... Atomic.fetch_and_add a 1 ... *)
(* 正解二:锁 *)
let m = Mutex.create () in
Mutex.protect m (fun () -> r := !r + 1)
工程含义很直接:OCaml 5 把「能不能并行」变成了类型/API 层面的显式选择,而不是像 C 那样留给你一个 UB 深渊,也不像 Java 那样把所有字段默认当成潜在共享。共享数据要么用 Atomic.t,要么用 Mutex,要么干脆留在域内。
三、Minor GC:per-domain bump allocator 与「触发即全场」
minor heap 是一块连续内存,caml_alloc_small 只做指针碰撞;收集时把存活对象晋升到 major heap,其余整块清零复用(不是 sweep,是直接重置)。
但有个必须知道的坑:OCaml 5 的 minor 收集是 stop-the-world 的。一个域触发 minor GC 时,所有域都要在 poll point 停下,各自清空自己的 minor heap。于是域数越多、单个 minor heap 越小,STW 次数越频繁——这是多核 OCaml 最常见的「加了域反而更慢」根因。
(* 运行时调参:优先调大 minor heap,而不是加域 *)
let () =
Gc.set { (Gc.get ()) with
Gc.minor_heap_size = 4 * 1024 * 1024; (* 单位 word *)
Gc.space_overhead = 120; (* major 允许的空间放大,默认 80 *)
Gc.major_heap_increment = 32; } (* 每次增量 32% *)
也可在进程启动时给,便于 A/B 而不改代码:
OCAMLRUNPARAM="s=4M,o=120,i=32,b=1,v=0" ./server.native
# s: minor heap size o: space_overhead i: major heap increment
# b: 备份线程开关(0=关) v: GC 调试日志
实践顺序是:先让单域零分配化(热循环不产生 minor 垃圾)→ 再调 minor heap → 最后才考虑加域。跳过第一步直接加域,通常只是把分配压力复制 N 份。
四、Major GC:并发标记、写屏障与备份线程
major heap 全局共享,采用标记—清除 + 按需整理,标记阶段与 mutator 并发:
[mutator 运行] ──► STW: 初始化标记 ──► 并发标记(mutator 用写屏障补记)
──► STW: 终标记(weak/ephemeron/finaliser)──► 并发清除 ──► [空闲]
需要时 ──► STW: compaction(整理碎片)
三个工程细节值得记住:
- 并发标记的写屏障是双面的:mutator 修改 major 对象字段时,既要把 old→young 记入 remembered set,也要在并发标记期间保持三色不变式,避免漏标。
- 备份线程(backup thread):如果所有域都阻塞在系统调用(不在 poll point 上),标记工作无人推进。OCaml 5 另起备份线程推进 GC,代价是上下文切换;纯 CPU 密集场景可以用
b=0关掉换延迟。 - 整理(compaction)是最贵的一次 STW,由
max_overhead阈值触发。碎片化的典型症状是 RSS 远高于 live data 且Gc.stat里live_words稳定但heap_words持续膨胀。
观测手段:
let print_stats () =
let s = Gc.stat () in
Printf.printf "minor=%.1fMB major=%.1fMB live=%.1fMB gc=%d compactions=%d\n%!"
(float s.minor_words /. 0.5e6)
(float s.heap_words /. 0.131072e6)
(float s.live_words /. 0.131072e6)
s.minor_collections
s.compactions
(word = 8 字节;Gc.quick_stat 更便宜,适合打点。)
五、值表示:从 tagged int 到扁平 float 数组
OCaml 的 value 是 64 位 word:整数编码为 2n+1(最低位为 1 表示立即值),因此 int 在 64 位平台是 63 位;指针则低位为 0 且始终指向堆内合法对象头。这带来两条重要优化:
- float array 是扁平的:
float array直接存 double,无 boxing(与其他类型的array表示不同,这也是为什么它会走特殊分配路径)。数值热路径要显式用float array或 Bigarray,而不是float ref array/ 记录数组。 - 不可变 float 记录会展开:所有字段都是 float 的不可变记录,字段以 unboxed 形式存储。
(* 差:每个点一次 boxing + major heap 压力 *)
type point = { x : float; y : float }
let dist a b = sqrt ((a.x -. b.x) ** 2. +. (a.y -. b.y) ** 2.)
(* 好:扁平数组,一次分配,SIMD 友好 *)
let dist xs ys i j =
let dx = xs.(i) -. xs.(j) and dy = ys.(i) -. ys.(j) in
sqrt (dx *. dx +. dy *. dy)
六、Effect Handler 与 GC 的隐式耦合
Effect handler 不是「零成本协程」:每次 perform 都在 minor heap 上分配一个 fiber 栈块,续延是一等 GC 对象。高频 perform(例如把 yield 塞进内层循环)会直接放大 minor GC 次数。
(* 反模式:每迭代一次 effect,等于每迭代一次分配 *)
let bad n = for i = 1 to n do yield () done
(* 正解:批处理,或改用无分配的控制流 *)
let good n = for i = 1 to n do if i land 1023 = 0 then yield () done
另外,域进入阻塞系统调用(外调 C 的 blocking section)时不在 poll point 上,其他域的 minor GC 必须等它——所以 FFI 边界的长阻塞要多加一个域或依赖备份线程兜底。
七、生产落地检查清单
- 先量化再并行:
perf record -g看caml_garbage_collection/caml_minor_collection占比;先消灭热循环分配,再谈加域。 - 域数 ≤ 物理核数,且用
Domain.recommended_domain_count ()起步,不要盲设。 - 共享状态显式化:跨域数据一律
Atomic.t/Mutex/ 消息传递,别依赖「看起来原子的字段写入」。 - 调大 minor heap 是最短平快的优化(
OCAMLRUNPARAM=s=8M起步做 A/B)。 - 关注 compaction 次数:如果
compactions持续增长,说明碎片在累积,考虑降max_overhead或改数据结构。 - 优先用成熟并发库:Eio(capability 安全的 IO + 结构化并发)、domainslib(任务并行)、KCAS(无锁事务)都比裸
Domain更接近生产可用性。 - CI 里防劣化:把
Gc.stat的分配量与 minor GC 次数做成回归阈值,超阈值直接阻断合并。
八、结论
OCaml 5 的设计哲学可以压缩成一句话:把运行时的不确定性尽量前移到语言语义层解决——用「无裸指针 + 值表示统一」换掉读屏障,用「per-domain minor heap」换掉跨线程分配器争用,用「one-shot effect handler」换掉整套调度器内建。它的代价是 minor GC 仍是 STW、域数扩张会放大停顿;它的收益是热路径几乎没有额外读开销,且多核程序的正确性边界清晰可辨。
理解这一点之后,所有 OCaml 多核性能优化都能归约到同一条原则:让分配尽可能留在域内、让存活尽可能短、让共享尽可能显式。类型系统的表达力与运行时的可预测性,在这里不是 traded-off,而是同一套设计的两面。

发表评论 取消回复