Haskell GHC 运行时深度工程实战:从 STG 机器、惰性求值图重写到 RTS 调度与低延迟 GC

执行摘要:大多数人把 Haskell 的"惰性"理解成延迟计算,这是一个危险的简化。GHC 真正实现的是按需图重写(graph reduction):thunk 一旦被求值就会被原地更新(update-in-place)成值,共享给所有引用者;而支撑这套语义的抽象机既不是栈机也不是寄存器机,而是 STG(Spineless Tagless G-machine)——一台把闭包、续延、求值栈全部统一到堆上的"无栈无标签"机器。本文拆解 GHC 从 Core → STG → Cmm → 机器码的落地路径、thunk 与 Blackhole 的并发语义、RTS 的 Capability/绿色线程/work-stealing 调度,以及分代复制式 GC 的调优边界,并给出空间泄漏的定位手法与生产配置清单。

一、惰性求值的真实模型:图重写,而非延迟调用

看一段最普通的代码:

main :: IO ()
main = do
  let xs = map (*2) [1..1000000]   -- 这里什么都没算
  print (sum xs)
  print (length xs)

在严格语言里 map 会立即物化一百万个元素的列表。在 GHC 里,xs 只是堆上一个 thunk(延迟计算对象)——一段代码指针加它的自由变量环境。真正发生的事情是:

  1. sum xs 进入 WHNF(弱头范式)求值,发现 xs 是 thunk;
  2. 把 xs 的入口地址压入求值栈,跳转到 thunk 的代码;
  3. thunk 计算完毕,原地把自己的头字改写成一个 indirection 或直接覆盖成值;
  4. 回到 sum 继续折叠。

关键在第三步:更新是破坏性的、共享的。length xs 再次遍历时不会重算 map,但副作用是那一百万个 (:) cons cell 仍然驻留——这就是 Haskell 最经典的空间泄漏来源。理解"惰性 = 带记忆化的图重写"这个模型,是理解后面所有调优决策的前提。

二、STG 机器:无栈、无标签的抽象机

GHC 的编译流水线是 Parse → Renamer → Typechecker → Core → STG → Cmm → 汇编。Core 是 System FC 的显式类型化中间表示,而 STG 是决定求值语义的那一层。

STG 只有四种基本构造:

-- STG 伪语法(示意)
let f = \{} \n {} -> case n of
                       0# -> 1#
                       _  -> let t = \{} \u {} -> f (n -# 1#)
                             in  n *# f (n -# 1#)
in  f 10#
  • \{free} \n {args} -> expr:闭包(用花括号包住自由变量、圆括号包住绑定变量是 STG 的标记习惯);
  • let ... in ...:堆分配;
  • case ... of ...:唯一的求值点,语义是"eval 到 WHNF 再分支";
  • 函数应用:跳转,而不是调用。

"Spineless"(无脊) 指 STG 不维护传统图归约机的 spine 栈——共享的子图靠堆上的 indirection 节点自然表达,不需要额外的归约栈。"Tagless"(无标签) 指构造函数在 case 分支时靠跳转表(vector return)分发,而不是在每个堆对象上打 tag 位。这两点合起来带来的工程后果是:

  • 求值栈(stack)和堆(heap)是同一块内存里的两种生长方向,栈溢出检查退化成一次堆界限比较;
  • 函数"调用"在 STG 层是 jump,真正的 call 语义只出现在对未知闭包(函数值)的尾部应用上;
  • 这就是为什么 GHC 对高阶函数既友好又昂贵:未知调用必须走 stg_ap_* 通用应用约定(Generic Apply)。

用 -ddump-stg-final 可以看到实际产物:

ghc -O2 -ddump-stg-final -ddump-to-file Main.hs
# 输出 Main.dump-stg-final,观察 let/case 与 \{} 闭包布局

三、严格性与 unboxing:让 GHC 帮你,而不是对抗它

STG 之后是 Cmm(GHC 的 C-- 方言),再交给 NCG / LLVM / 本机汇编。真正决定性能的是 Strictness Analysis + Worker/Wrapper 变换。

{-# LANGUAGE BangPatterns #-}

-- 坏例子:累加器是惰性的,构建 10^7 层 thunk 链
sumBad :: [Int] -> Int
sumBad = go 0
  where go acc []     = acc
        go acc (x:xs) = go (acc + x) xs

-- 好例子:BangPatterns 强制 acc 立即求值
sumGood :: [Int] -> Int
sumGood = go 0
  where go !acc []     = acc
        go !acc (x:xs) = go (acc + x) xs

GHC 的 demand analysis 会为 sumGood 生成 worker/wrapper:wrapper 做一次 eval,worker 直接操作 unboxed Int#,全程零堆分配。而 sumBad 在 -O2 下未必能被救回来——一旦列表是多态的、或者 acc 的类型携带 Num 字典,+ 就退化成字典分派,严格性分析立刻失效。

工程上三条硬经验:

  1. 在数据类型字段上加严格性注解(data T = T !Int !(Maybe String)),比在函数上加 ! 更有效——它从源头消灭了 thunk 的创建点;
  2. -funbox-strict-fields 或 {-# UNPACK #-} 让小字段内联进父对象,省一次间接寻址;
  3. 长期驻留、会被反复读取的结构(配置、查找表)用 Data.Map.Strict / 严格 foldl',foldr 只在需要惰性短路时使用。

四、RTS 并发:Capability、绿色线程与 Blackhole

GHC RTS 是一个 N:M 调度器:百万级 Haskell 绿色线程(TSO,Thread State Object)映射到少量 OS 线程上,OS 线程又绑定到 Capability——每个 Capability 拥有一份独立的 nursery(新生代分配区)和 run queue。

# 生产服务典型启动参数
my-serv +RTS \
  -N              `# Capability 数 = 物理核数,不要用超线程数` \
  -A32m           `# 每 Capability nursery 大小,默认 4m` \
  -qg1            `# 打开并行 GC` \
  -I0             `# 关闭 idle GC,避免低流量期周期性长停顿` \
  -T              `# 打开运行时统计,供 ekg/ghc-metrics 采集` \
  -RTS

几个容易踩的点:

  • -N 超过物理核在云上尤其糟:超线程兄弟核争夺同一 execution port,-N32 在 16 核机器上常常比 -N16 慢 20% 以上,还会把 GC 的并行度一起拖垮。
  • -A 调大能显著减少 minor GC 次数(nursery 越大,存活率低的对象越不容易被提升),但代价是每次 minor GC 的停顿变长、cache locality 变差。对延迟敏感的服务,-A 从 4m 调到 16–32m 通常是正收益,超过 64m 基本不再改善。
  • Blackhole 是并发惰性求值的死锁检测机制:线程 A 开始求值 thunk 时会把它变成 BLACKHOLE(指向自己的求值栈),线程 B 若也要求值同一 thunk 就被挂起在 blackhole queue 上。自依赖会抛 <<loop>>。代价是:任何共享 thunk 都是潜在的跨 Capability 同步点——这也是 par/pseq 写错时程序反而更慢的原因之一。

五、GC:分代复制式,以及它的延迟边界

GHC 默认是 分代(2 代,可选 3 代)复制式 GC,老年代用 mark-compact / mark-sweep。

  • Minor GC(Gen 0):只扫 nursery 和 remembered set(老→新的写屏障记录),是 stop-the-world 但通常亚毫秒级的。
  • Major GC:扫全部存活对象,停顿与堆大小成正比。
  • Copying 的好处是分配是指针碰撞(bump allocation),无碎片;坏处是堆占用天然接近 2 倍。
  • Compact / Mark-Sweep:-c 能显著降低最大驻留内存(对内存受限容器很关键),但每次 major GC 更慢,且失去复制式的局部性优势。

可观测性靠 -T + GHC.Stats:

import GHC.Stats

reportStats :: IO ()
reportStats = do
  s <- getRTSStats
  putStrLn $ "allocated: " ++ show (allocated_bytes s)
  putStrLn $ "max_live: "  ++ show (max_live_bytes s)   -- 真实常驻下限
  putStrLn $ "gc_cpu_ns: " ++ show (gc_cpu_ns s)
  putStrLn $ "major_gcs: " ++ show (major_gcs s)
  -- 关键比值:copied / allocated,衡量提升率是否过高
  let r = fromIntegral (copied_bytes s) / fromIntegral (allocated_bytes s) :: Double
  putStrLn $ "copy_ratio: " ++ show r

经验阈值:copy_ratio 长期高于 0.3 说明大量短命对象被错误提升到老年代,通常意味着 -A 过小或存在长生命周期的惰性结构;max_live_bytes 远大于业务数据集大小,则是空间泄漏的直接证据。

六、空间泄漏的定位手法

标准三板斧:

# 1) 按产生者(cost centre)归因
ghc -O2 -rtsopts -prof -fprof-auto Main.hs
./Main +RTS -hc -p -RTS && hp2ps -c Main.hp   # 得到 Main.ps

# 2) 按类型归因:找是哪个数据结构在涨
./Main +RTS -hT -RTS && hp2ps -c Main.hp

# 3) 按 retainer 归因:定位谁引用着它
./Main +RTS -hr -RTS && hp2ps -c Main.hp

-hT 告诉你"是 [] 还是 Map 在涨",-hr 告诉你"是哪个顶层结构拽住了它"。绝大多数生产泄漏都长这样:

-- 泄漏:acc 惰性地累积,同时 xs 必须全程驻留
loop :: [Int] -> Int
loop xs = foldl (\acc x -> acc + x) 0 xs

-- 修复:严格左折叠
loop' :: [Int] -> Int
loop' xs = foldl' (+) 0 xs

七、调优清单与结论

  1. 先测再调:+RTS -s 输出的 MUT time / GC time / Productivity 是唯一可信起点。Productivity 低于 90% 才值得动 GC 参数。
  2. 严格性优先于 GC 调参:消灭 thunk 是数量级收益,调 -A/-n 只是常数因子。
  3. profiling 会关闭大量优化:-prof -fprof-auto 只用于定性归因,定量要用 -T 线上统计,或 +RTS -l(eventlog)配合 ghc-events-analyze。
  4. eventlog 是现代首选:./Main +RTS -l -RTS 生成 .eventlog,用 threadscope 看每个 Capability 的 run/GC 时间线,能直接看到 GC 同步阶段的空转。
  5. 容器内存限制:显式 -N 并配合 -M,否则 RTS 会按宿主机核数启动 Capability,在 cgroup 限核下严重超订。

GHC 的复杂度不在于语法,而在于它把求值时机、内存布局、并发调度三件事耦合进同一个运行时。一旦接受了"惰性 = 图重写 + 原地更新"这个心智模型,STG、thunk、Blackhole、nursery 这些概念就会自然归位:它们都是同一套设计约束下的必然产物,而不是零散的 trick。这也是为什么 Haskell 服务在调优到稳态后,往往能用远少于传统 GC 语言的代码量拿到可预测的亚毫秒级尾延迟——代价是你需要理解这台机器本身。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部