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 都有本质区别:
- 无共享内存。进程之间只有消息传递,没有共享堆,因此没有锁、没有数据竞争、没有内存屏障。
- 独立小堆。每个进程创建时分配约 2–3 KB 堆(可通过
+h调整),按需增长。 - 由 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/low | max/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
生产铁律:
- 任何 NIF 如果执行时间可能超过 1 ms,必须声明为 dirty,否则它会独占一个普通 scheduler 线程,其上的所有 Erlang 进程全部停摆。
- 若 NIF 执行时间不可预估(比如调用第三方推理库),正确做法不是 dirty NIF,而是用 Port 把它放到独立 OS 进程,靠管道通信——一个 segfault 只杀掉一个 Port,不会带崩整个 VM。
这个决策矩阵值得背下来:
| 场景 | 选择 | 理由 |
|---|---|---|
| 短、纯计算、可证明 < 1 ms | 普通 NIF | 零拷贝,最快 |
| 长 CPU 计算、内存安全可控 | Dirty CPU NIF | 不阻塞正常调度器 |
| 阻塞 I/O | Dirty 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]}}.
关键工程点:
intensity/period是系统的"熔断阈值"。10 次/60 秒被突破后,监督者自己崩溃并向上传递——这是一种有意设计的级联失败保护:如果某个依赖彻底挂了,反复重启只会放大故障,不如整体退出让外层(systemd / K8s)处理。restart => transientvspermanent。Agent 会话这类"有生命周期"的实体应该用transient,正常结束不重启;基础设施进程用permanent。用错会导致"进程正常退出后被无限重启"这类诡异循环。- 必须用
link/monitor建立生死绑定,否则父进程无法感知子进程崩溃。
%% 单向监控:监控方不会因被监控方崩溃而死
Ref = erlang:monitor(process, Pid),
receive
{'DOWN', Ref, process, Pid, Reason} ->
handle_crash(Reason)
end.
七、生产落地清单
把上面的机制收敛成一份可以直接照着做的 checklist:
| 旋钮 | 默认值 | 生产建议 | 影响 |
|---|---|---|---|
+sbt (busy wait) | db | CPU 密集 + 低延迟 → nnets 或 nn | 调度器自旋 vs 休眠,直接决定唤醒延迟与功耗 |
+h (min heap) | 233 | 状态型进程按稳态堆调大 | 消除启动期 minor GC 风暴 |
fullsweep_after | 65535 | 短命进程设 0–10;长命进程调大并显式 GC | 内存占用 vs GC 暂停 |
+SDcpu/+SDio | 核数/10 | 有 NIF 时显式配 dirty 线程数 | 防止 NIF 拖垮普通调度器 |
+P (最大进程数) | 262144 | 百万级进程调至 5000000 | 硬上限,超限 spawn 失败 |
+sub/+sbwt | true/none | 容器环境务必设置 | 调度器忙等待与 CPU 配额 |
一个典型的低延迟容器启动参数:
erl +sbt nnets \
+sub true \
+sbwt very_long \
+SDcpu 8 +SDio 4 \
+P 2000000 \
+hms 8192 \
-kernel inet_dist_listen_min 9100
最后给一条排障路径,比任何参数都管用:
erlang:statistics(run_queue)— CPU 是否饱和;erlang:system_info(total_run_queue_lengths)— 是否卡在某个优先级;erlang:memory()分项对比 — 暴涨的是processes、binary还是ets;[{P, process_info(P, [message_queue_len, heap_size, reductions])} || P <- processes()]— 定位异常进程;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 不是"更快的运行时",它是在延迟可预测性这个维度上做了不同取舍的运行时。把这层链条串起来看:
- 无共享进程把并发问题从"锁正确性"降级为"消息协议设计"——这是复杂度的量级下降。
- 归约计数调度把抢占从"内核不可控的时间片"变成"语言层面可预算的配额",代价是纯 Erlang 代码的函数调用有额外计数开销(约 3–8% 吞吐损失)。
- per-process 分代 GC把暂停时间与系统总内存解耦,代价是内存放大(每个进程独立堆 + 复制 GC 需要双倍空间)。
- 监督树把容错从"在每个函数里写 try/catch"变成"在系统边界声明重启策略"。
真正值得从 BEAM 带走、并迁移到任何语言里的工程原则是:把"抢占的粒度"和"故障的半径"当成一等公民来设计,而不是留给操作系统和运气。当你构建一个要同时承载几十万个有状态 AI Agent 会话、且不能让一次慢推理拖垮邻居的系统时,这套 1986 年就确立的设计,依然是今天最经得起推敲的答案之一。

发表评论 取消回复