Erlang BEAM 运行时深度工程实战:从归约计数抢占式调度、每进程分代 GC 到软实时与分布式容错的全链路解析

执行摘要

绝大多数人对并发运行时的认知是"线程 + 锁 + 线程池"。Go 用 GMP 把线程调度搬进了用户态,Java 用虚拟线程把栈搬进了堆,但它们共享同一个底层假设:调度的基本决策单位是一段连续执行的指令流,抢占由操作系统或协作点决定。

BEAM(Bogdan/Björn's Erlang Abstract Machine)走的是完全不同的第二条路。它把"抢占"这件事做成了语言语义的一部分:每个 Erlang 进程每执行若干次函数调用(归约,reduction)就被强制换出,这个数字在运行时是可数、可观测、可精确预算的。由此换来的是全球电信级系统里被反复验证过的性质——几百万个并发实体共享少量 OS 线程,而 P99 延迟不随并发数爆炸。

在 AI Agent 平台重新变成热点的 2026 年,BEAM 的价值被重新发现:一个 Agent 会话本质上就是一个有状态、会崩溃、需要隔离、需要超时回收的轻量进程。WhatsApp 用 50 名工程师支撑 9 亿用户、RabbitMQ、Riak、以及大量实时语音与信令系统,靠的都是同一套机制。

本文剥开四层:进程模型 → 归约计数调度器 → 每进程分代 GC → 信号与消息传递 → 监督树容错,每一层都配可运行的代码与生产调参结论。


一、先建立坐标系:为什么"尾延迟"比吞吐更值钱

评价并发运行时至少要看三个维度,缺一个就会被误导:

维度含义常见误解
吞吐单位时间完成任务数批处理的唯一指标
尾延迟P99/P999 响应时间最容易被平均延迟掩盖
故障隔离半径单个任务崩溃影响多少邻居只有在事故复盘时才被想起

一个 4 核 VM 上跑 200 万个空闲 TCP 长连接,Go 与 Java 都能做到;难的是其中某个连接触发一次 2 ms 的 GC 或一次 20 ms 的 JSON 解析后,其他 199 万个连接的 P99 是否抖动。

BEAM 的设计目标从第一天起就被 Joe Armstrong 写成一句话:软实时(soft real-time)——响应时间的上界必须存在且可预测,即使不能保证绝对。这句话直接决定了后面所有 engineering trade-off。


二、进程模型:不是线程,也不是纤程

Erlang 进程(process)与 OS 线程、与 goroutine 都有本质区别:

  1. 无共享内存。进程之间只有消息传递,没有共享堆,因此没有锁、没有数据竞争、没有内存屏障。
  2. 独立小堆。每个进程创建时分配约 2–3 KB 堆(可通过 +h 调整),按需增长。
  3. 由 BEAM 调度,不由内核调度。默认每个 CPU 核一个 scheduler 线程,另有一个 dirty scheduler 线程池。

创建一个进程的成本大约 1–3 μs、初始内存 2–3 KB,因此"一个连接一个进程""一个会话一个进程"是常规写法而非特例:

-module(agent_session).
-export([start/1, loop/1]).

start(SessionId) ->
    spawn_opt(fun() -> loop(SessionId) end,
              [{min_heap_size, 233},      %% 初始堆字数,避免频繁 minor GC
               {min_bin_vheap_size, 46368}, %% binary 虚拟堆
               {fullsweep_after, 100},    %% 多少次 minor GC 后强制 full sweep
               {priority, normal}]).

loop(SessionId) ->
    receive
        {user_utterance, Text} ->
            Result = llm:invoke(Text),
            session_registry ! {reply, SessionId, Result},
            loop(SessionId);
        {timeout, _Ref} ->
            io:format("session ~p idle timeout~n", [SessionId]);
        stop ->
            ok
    end.

注意 spawn_opt 里的 min_heap_size:这是 BEAM 调优里性价比最高的一个参数。默认 233 字(word)的堆对于有状态的 Agent 会话太小,会导致前几秒疯狂触发 minor GC;按实测把稳态堆大小直接设进 min_heap_size,可以让稳态 GC 次数降到接近零——这是用空间换可预测延迟的典型操作。


三、调度器的内核:归约计数(Reduction Counting)

这是 BEAM 最核心、也最容易被忽略的设计。

3.1 什么是归约

BEAM 把一次函数调用(含 BIF 调用)计为一次归约(reduction)。每个进程有一个 reduction counter,调度器每次把进程放上 CPU 前给它一个配额(老版本固定 2000,OTP 24+ 引入可配置的 context reduction 与更细的时间片管理)。进程每完成一次归约就减一,减到 0 就被强制抢占并放回 run queue 尾部。

/* erts 调度循环的高度简化伪代码 */
static void schedule(Process *p) {
    p->reds = CONTEXT_REDS;          /* 配额,默认约 4000-8000 */
    while (p->reds > 0) {
        if (!run_one_instruction(p))  /* 返回 0 表示进程让出/阻塞 */
            return;
        if (is_call_instruction())    /* 只有函数调用才计归约 */
            p->reds--;
    }
    /* 配额耗尽:无条件抢占,无需进程配合 */
    enqueue(run_queue, p);
}

关键在于:抢占点不需要被抢占进程做任何配合。这与两类运行时形成鲜明对比:

  • 协作式调度(Node.js、Python asyncio、早期 goroutine):一个 while(true) 或一次长循环会饿死整个事件循环。
  • OS 时间片抢占:抢占点不可预测,一个线程可能在任意机器指令处被换出,因此需要复杂的锁协议保护临界区。

BEAM 取了中间值:抢占点确定(函数调用边界)、频率可数。这带来两个直接后果:

一个纯 Erlang 写的无限循环永远不会阻塞调度器——它每 4000 次调用就被换出一次,其他进程照常获得 CPU。

但一个用 C 写的、不调用 Erland 函数的 NIF 会阻塞整个调度器线程,因为归约计数对 C 代码不生效。

第二条是 BEAM 生产事故的头号来源,后面会讲怎么治。

3.2 多调度器与迁移

OTP 默认启动 schedulers = max(1, 逻辑核数) 个调度线程,每个有自己的 run queue。负载均衡通过迁移(migration)完成:

机制触发条件作用
Work stealing本队列空从其他 scheduler 队列偷任务
Migration path定期检查按优先级平衡各队列长度
优先级分层max/high/normal/lowmax/low 只在本地队列执行,不迁移
%% 观察调度器实时状态
1> erlang:system_info(schedulers_online).
16
2> erlang:statistics(run_queue).
3
3> erlang:system_info(total_run_queue_lengths).
{16, 3, 0, 0}   %% {在线数, 总队列长度, max队列, high队列}

工程判据:erlang:statistics(run_queue) 持续 > 0 说明 CPU 已饱和;如果它很大但 top 里 CPU 利用率只有 40%,那几乎一定是有进程卡在 NIF 或 dirty scheduler 上,或者 +sbt(busy wait threshold)配置不当导致调度器在自旋与休眠之间反复横跳。

3.3 Dirty Scheduler:把阻塞式 C 代码隔离出去

OTP 17+ 引入 dirty scheduler,专门用于执行预计会阻塞超过 1 ms 的 NIF:

/* 声明为 dirty NIF:在 dirty CPU scheduler 上执行 */
static ErlNifFunc nif_funcs[] = {
    {"inference_run", 1, inference_run, ERL_NIF_DIRTY_JOB_CPU_BOUND},
    {"disk_read",     1, disk_read,     ERL_NIF_DIRTY_JOB_IO_BOUND}
};
%% 查看 dirty scheduler 使用情况
1> erlang:system_info(dirty_cpu_schedulers).
16
2> erlang:system_info(dirty_io_schedulers).
10

生产铁律:

  1. 任何 NIF 如果执行时间可能超过 1 ms,必须声明为 dirty,否则它会独占一个普通 scheduler 线程,其上的所有 Erlang 进程全部停摆。
  2. 若 NIF 执行时间不可预估(比如调用第三方推理库),正确做法不是 dirty NIF,而是用 Port 把它放到独立 OS 进程,靠管道通信——一个 segfault 只杀掉一个 Port,不会带崩整个 VM。

这个决策矩阵值得背下来:

场景选择理由
短、纯计算、可证明 < 1 ms普通 NIF零拷贝,最快
长 CPU 计算、内存安全可控Dirty CPU NIF不阻塞正常调度器
阻塞 I/ODirty IO NIF独立线程池,数量可调
第三方库 / 有崩溃风险Port 或 C Node崩溃隔离到进程边界

四、内存与 GC:每个进程一个独立的小世界

4.1 为什么 per-process GC 是软实时的前提

JVM 的 STW(stop-the-world)暂停时间与堆大小正相关,因此 32 GB 堆上的 GC 暂停天然是百毫秒级。BEAM 反过来做:每个进程有独立堆,GC 只发生在单个进程内部,暂停时间与"这个进程的活数据量"相关,而与其他进程无关。

一个进程堆里只有几 KB 活数据 → 它的 GC 就是微秒级。这条性质不随系统总进程数变化,这正是"几百万进程下 P99 稳定"的数学基础。

4.2 分代与 fullsweep

每个进程内部是分代式(Cheney 复制 GC):

  • minor GC:扫描 young heap,活的复制到 new heap,老的晋升到 old heap。
  • full sweep:扫描整个堆(含 old heap),把 old heap 里已死的对象回收。

触发策略由 fullsweep_after 控制,默认 65535(几乎不触发),意味着old heap 里的垃圾可能长期不回收。

%% 纯流式、无长期状态的进程:频繁 full sweep,堆保持最小
spawn_opt(F, [{fullsweep_after, 0}]).

%% 长生命周期、持有大状态(如缓存表)的进程:基本不做 full sweep
spawn_opt(F, [{fullsweep_after, 1000}]).

更实用的判据:

  • 短生命周期、吞吐型进程(HTTP handler、消息转发):fullsweep_after 设 0–10,让堆尽快回落,内存占用低。
  • 长生命周期、状态型进程(Agent 会话、连接状态机):设一个较大值(100–1000)或 0 关闭自动 fullsweep,改由 erlang:garbage_collect(Pid) 在业务空闲点显式触发。
%% 在业务空档(如一轮对话结束)显式回收,避免在请求路径上 GC
maybe_collect(Counter) when Counter rem 100 =:= 0 ->
    erlang:garbage_collect(self());
maybe_collect(_) -> ok.

4.3 Binary:唯一会跨进程共享的大对象

大于 64 字节的 binary 存在共享的 off-heap 区域,进程堆里只放一个 ProcBin 引用,引用计数归零才释放。这带来 BEAM 上最经典的"内存泄漏":

%% 反模式:一个长生命周期进程持有大 binary 的引用(哪怕只是子串)
loop(State) ->
    receive
        {data, BigBin} ->                   %% 8 MB
            %% binary:copy 会切断与母体的引用,否则整个 8MB 都被钉住
            Small = binary:copy(binary:part(BigBin, 0, 16)),
            loop(State#{last => Small})
    end.

判据:erlang:memory(binary) 持续上涨而进程总堆稳定,几乎一定是 refc binary 被长生命周期进程钉住。诊断用:

1> [{P, erlang:process_info(P, binary)} || P <- erlang:processes()].
%% 找 binary 列表里 size 大的进程

修复手段:对要长期保存的 binary 片段调用 binary:copy/1 切断引用;或者让持有大 binary 的进程短命。


五、消息传递与选择性接收的隐藏代价

5.1 消息队列与信号

进程间发送的是异步消息拷贝(除大 binary 共享外)。消息进入接收进程的外部消息队列,进程执行 receive 时按模式匹配扫描。

%% 危险:选择性接收要扫描整个队列
receive
    {ack, Id} -> handle(Id)
end.
%% 若队列里有 10 万条 {log, ...} 消息,这次 receive 是 O(n)

经典解法是加 Ref 把选择性接收变成"任意消息 + 判断",或直接用 OTP 的 gen_server:call(内部已经做了 Ref 优化):

%% 正确做法:用唯一引用做标签
Ref = make_ref(),
Worker ! {self(), Ref, work},
receive
    {Ref, Result} -> Result
after 5000 ->
    {error, timeout}
end.

after 0 是另一个高频技巧——非阻塞地"扫一眼"队列,用于实现带优先级的后台任务:

flush_priority() ->
    receive
        {priority, M} -> handle(M), flush_priority()
    after 0 ->
        ok
    end.

5.2 背压的缺失

BEAM 的 ! 是无界异步发送,没有内置背压。一个慢消费者会被消息淹没直到 OOM。生产上必须显式做:

手段适用说明
gen_server:call + 超时请求-响应天然有界,但会阻塞调用方
有界队列 + {error, overloaded}网关入口快速失败优于雪崩
erlang:process_info(P, message_queue_len) 监控通用超过阈值触发降级或告警
%% 网关侧显式限流:队列过长直接拒绝
maybe_accept(Pid) ->
    case erlang:process_info(Pid, message_queue_len) of
        {message_queue_len, N} when N > 1000 ->
            {error, overloaded};
        _ ->
            ok
    end.

六、容错:let it crash 与监督树

BEAM 的容错哲学是反向的:不是写防御性代码覆盖所有错误分支,而是让错误进程死掉,由监督者按策略重启。

-module(agent_sup).
-behaviour(supervisor).

init([]) ->
    Flags   = #{strategy => one_for_one,
                intensity => 10,      %% 10 次重启
                period => 60},        %% 60 秒内
    Child   = #{id => session,
                start => {agent_session, start_link, []},
                restart => transient,  %% 只有异常退出才重启
                shutdown => 5000,
                type => worker,
                modules => [agent_session]},
    {ok, {Flags, [Child]}}.

关键工程点:

  1. intensity/period 是系统的"熔断阈值"。10 次/60 秒被突破后,监督者自己崩溃并向上传递——这是一种有意设计的级联失败保护:如果某个依赖彻底挂了,反复重启只会放大故障,不如整体退出让外层(systemd / K8s)处理。
  2. restart => transient vs permanent。Agent 会话这类"有生命周期"的实体应该用 transient,正常结束不重启;基础设施进程用 permanent。用错会导致"进程正常退出后被无限重启"这类诡异循环。
  3. 必须用 link/monitor 建立生死绑定,否则父进程无法感知子进程崩溃。
%% 单向监控:监控方不会因被监控方崩溃而死
Ref = erlang:monitor(process, Pid),
receive
    {'DOWN', Ref, process, Pid, Reason} ->
        handle_crash(Reason)
end.

七、生产落地清单

把上面的机制收敛成一份可以直接照着做的 checklist:

旋钮默认值生产建议影响
+sbt (busy wait)dbCPU 密集 + 低延迟 → nnets 或 nn调度器自旋 vs 休眠,直接决定唤醒延迟与功耗
+h (min heap)233状态型进程按稳态堆调大消除启动期 minor GC 风暴
fullsweep_after65535短命进程设 0–10;长命进程调大并显式 GC内存占用 vs GC 暂停
+SDcpu/+SDio核数/10有 NIF 时显式配 dirty 线程数防止 NIF 拖垮普通调度器
+P (最大进程数)262144百万级进程调至 5000000硬上限,超限 spawn 失败
+sub/+sbwttrue/none容器环境务必设置调度器忙等待与 CPU 配额

一个典型的低延迟容器启动参数:

erl +sbt nnets \
    +sub true \
    +sbwt very_long \
    +SDcpu 8 +SDio 4 \
    +P 2000000 \
    +hms 8192 \
    -kernel inet_dist_listen_min 9100

最后给一条排障路径,比任何参数都管用:

  1. erlang:statistics(run_queue) — CPU 是否饱和;
  2. erlang:system_info(total_run_queue_lengths) — 是否卡在某个优先级;
  3. erlang:memory() 分项对比 — 暴涨的是 processes、binary 还是 ets;
  4. [{P, process_info(P, [message_queue_len, heap_size, reductions])} || P <- processes()] — 定位异常进程;
  5. erlang:system_monitor 设 long_gc / large_heap 告警,把问题在发生那一刻抓下来。
erlang:system_monitor(self(), [{long_gc, 100},        %% GC 超过 100ms 报告
                               {large_heap, 8 bsl 20}, %% 堆超 8MB 报告
                               {busy_port, true}]).

八、结论

BEAM 不是"更快的运行时",它是在延迟可预测性这个维度上做了不同取舍的运行时。把这层链条串起来看:

  1. 无共享进程把并发问题从"锁正确性"降级为"消息协议设计"——这是复杂度的量级下降。
  2. 归约计数调度把抢占从"内核不可控的时间片"变成"语言层面可预算的配额",代价是纯 Erlang 代码的函数调用有额外计数开销(约 3–8% 吞吐损失)。
  3. per-process 分代 GC把暂停时间与系统总内存解耦,代价是内存放大(每个进程独立堆 + 复制 GC 需要双倍空间)。
  4. 监督树把容错从"在每个函数里写 try/catch"变成"在系统边界声明重启策略"。

真正值得从 BEAM 带走、并迁移到任何语言里的工程原则是:把"抢占的粒度"和"故障的半径"当成一等公民来设计,而不是留给操作系统和运气。当你构建一个要同时承载几十万个有状态 AI Agent 会话、且不能让一次慢推理拖垮邻居的系统时,这套 1986 年就确立的设计,依然是今天最经得起推敲的答案之一。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部