← All posts

2026 回看 Erlang

目录 · Table of Contents

这两年写 Go、写 Rust,或者折腾各种有状态服务和 Agent 编排的时候,我脑子里经常会冒出一个念头:我们今天在工程里拼命堆砌的很多东西,是不是正在把四十年前的 Erlang 重新发明一遍?

巧的是,今年(2026 年)正好是 Erlang 诞生四十周年,距离我 2016 年在博客里写那篇 《初识 Erlang 与 Elixir》 也刚好过去了整整十年。

1986 年,Joe Armstrong、Robert Virding 和 Mike Williams 在爱立信计算机科学实验室开始鼓捣 Erlang 的时候,他们眼里根本没有“怎么把单核 CPU 的算力榨干”这种问题。他们当时面对的需求非常纯粹,甚至有点不讲道理:

软件一定会写出 Bug,硬件一定会断电或坏掉。在这个前提下,怎么造出一台永远不停机的电话交换机?

后来他们真的造出来了。包含近两百万行 Erlang 代码的爱立信 AXD301 交换机,跑出了所谓的“9 个 9”可用性(99.9999999%)——算下来一年到头总停机时间不超过 31 毫秒,甚至还能在几万人同时打电话的时候直接在线热更新代码。

但有意思的是,在此后的二十多年里,主流工业界几乎走了一条完全相反的路。我们先是在 C++ 和 Java 里跟共享内存、互斥锁死磕;后来被并发 Bug 折磨怕了,干脆在云计算时代转向了“无状态服务 + 外置数据库 + 消息队列 + 容器编排”。这套逻辑很直接:既然进程自己管不好状态、也扛不住崩溃,那我就把状态全踢到 Redis 和数据库里去,进程崩了就让 Kubernetes 替我重启容器。

这套做法统治了过去十年。直到最近几年,大家开始觉得不对劲了:微服务越拆越碎,基础设施越叠越厚,稍微复杂一点的状态流转就要在三四个中间件之间来回倒腾。回过头再看一眼当年觉得“语法怪异”的 Erlang,才发现当年那帮电信老哥给出的答案,到底有多超前。

1. 为什么“协程”终究不是“进程”

过去十年,Go 的 Goroutine、Rust 的 tokio,再加上 Java 21 之后的虚拟线程(Virtual Threads),基本把“百万级轻量并发”变成了标配。很多没怎么写过 Erlang 的朋友常会问:现在大家都有用户态 M:N 调度器了,Erlang 当年引以为傲的轻量级进程(Process),是不是早就没啥稀奇的了?

如果只是跑个 wrk 压测、比一下空转 HTTP Echo 的 QPS,那确实看不出太大区别,甚至 Go 和 Rust 的数字还要漂亮得多。但在真实线上环境里,主流语言的协程解决的是 I/O 复用效率,而 Erlang 的进程解决的是 故障隔离与尾延迟(Tail Latency)。这两件事在虚拟机底下完全不是一个物种。

共享堆 vs. 私有堆

不管是 Go 的 Goroutine 还是 Java 的虚拟线程,骨子里依然是共享内存模型。你开十万个 Goroutine,它们底下踩着的还是同一个全局堆(Shared Heap)。

共享堆意味着两件事你永远绕不开:

第一,一旦出事,整锅端。只要十万个 Goroutine 里有一个手滑写了空指针,或者两个协程不小心并发写了同一个 map(Go 程序员应该对 fatal error: concurrent map writes 都不陌生——这玩意儿连 recover() 都拦不住),整个操作系统进程当场去世,同进程里正在跑的另外九万九千个正常请求全部跟着陪葬。

第二,GC 是全局连坐的。虽然现在的并发 GC 已经能把 STW 压得很短,但只要某几个请求突然分配了一大波内存,全局写屏障、后台标记线程抢 CPU、内存分配器锁竞争,还是会让同一个节点上八竿子打不着的其他协程跟着抖一下。

而 Erlang 的 BEAM 虚拟机是怎么干的呢?在 BEAM 里,一个 Process 压根不是“线程”,它就是一个完全不跟别人共享内存的袖珍虚拟机。

创建一个 Erlang 进程的初始内存只有 309 个机器字(64 位下也就 2.4 KB 左右),每个进程都有自己独立的栈和独立的堆。更绝的是,它的栈和堆放在同一块连续内存的两头,对着长,连栈扩容拷贝都省了。进程 A 给进程 B 发消息 PidB ! Msg,除了超过 64 字节的大块二进制数据(Refc Binary)会放在全局堆里做引用计数之外,普通数据默认直接从 A 的私有堆深拷贝一份到 B 的私有堆。

刚接触的人往往会嫌弃:“发个消息还要深拷贝?这不多费 CPU 吗?”

但等你真正跑起一个高负载系统,就会明白这点拷贝换回来的东西有多值钱:

  • 很多时候连 GC 都不需要跑:Erlang 里大量进程都是短命的,比如接一个请求、干完活就退出。进程一死,它自己那块几 KB 的私有堆直接整块回收,连垃圾回收器都不用唤醒;
  • 就算要 GC,也是自己关起门来搞:每个进程只回收自己那点私有堆(Per-Process GC)。进程 A 在那儿做垃圾回收,旁边的进程 B、C、D 完全无感,连半微秒的停顿都不会传染过去。

协作式让出 vs. 按预算硬抢占

另一个很容易踩坑的地方是调度怎么抢占。

像 Rust 的 async/await 或 Node.js,本质上还是靠 .await 也就是 I/O 边界做协作式让出;Go 虽然加了基于信号的异步抢占,但在很多密集计算或特定调用边界上依然不够彻底。一旦某个任务里不小心塞了一段耗时的 JSON 解析、循环计算或者恶心的正则回溯,同一个工作线程队列后面排着的几十个任务就全被堵死了(队头阻塞),表现在监控上就是 P99 延迟突然拉出一根长刺。

BEAM 因为当年要干软实时的电话交换,用的是非常强硬的 Reduction(归约)预算制抢占。

你可以把 Reduction 简单理解成“函数调用次数”。BEAM 给每个被调度上 CPU 的进程发 4000 个 Reductions 的预算,每调一次函数扣 1 点。只要 4000 点花光(通常不到 1 毫秒),不管你正在算圆周率还是在跑死循环,调度器直接把你薅下来塞回队尾,换下一个进程上。

维度 OS 线程 现代协程 (Go / Tokio) BEAM 进程 (Erlang)
初始内存 1~8 MB 栈 2 KB (Go) / 状态机 (Rust) 309 Words (~2.4 KB)
内存隔离 全局共享堆,靠锁同步 全局共享堆,可变状态可逃逸 Share-Nothing 私有堆
GC 范围 全局 STW / 全局并发标记 全局堆统一 GC 进程级独立 GC,退出即释放
抢占机制 内核时间片硬中断(切换重) I/O 挂起或安全点半抢占 4000 Reductions 硬抢占
崩溃半径 整个 OS 进程挂掉 整个 OS 进程挂掉(panic) 仅当前微秒级进程退出

这种底层差异带来的真实体感特别明显:你把一台 Go 或 Java 机器的 CPU 压到 90% 以上,各种接口超时和毛刺基本就开始乱飞了;但你把一台 BEAM 节点的 CPU 压到 99%,所有请求只是均匀地变慢了一点点,你这时候敲个命令连进线上 Erlang Shell 看状态,甚至还能秒回,一点都不卡。

2. “Let It Crash” 不是摆烂,而是最省心的容错方案

只要聊起 Erlang,就一定绕不开那句被传烂了的口号:“Let It Crash(任其崩溃)”。

老实说,我最早写习惯了 C/Go/Java 的时候,听到这句话第一反应也是:“这不纯属胡扯吗?线上服务怎么能说崩就崩?”

后来线上事故修多了,才慢慢想通一个道理:我们平时在业务代码里写满屏的 if err != nil 和 try/catch,很多时候其实是在自欺欺人。

为什么主流语言不敢崩?

图灵奖得主 Jim Gray 早在 1985 年就写过一篇神作 《Why Do Computers Stop and What Can Be Done About It?》。他把软件 Bug 分成两种:

一种叫 波尔 Bug(Bohrbug),确定性极强,只要输入固定就必现。这种 Bug 好办,写单测、跑集成测试,上线前基本能抓出个七七八八。

真正要命的是第二种,叫 海森堡 Bug(Heisenbug)——平时怎么测都好好的,偏偏在线上凌晨三点,遇到某个诡异的并发时序、网络闪断或者半脏不脏的缓存状态时啪一下炸了;等你第二天加好日志想去复现,它又死活不出来了。

Jim Gray 的结论很扎心:在通过测试上线的生产系统里,99% 以上的致命故障都是 Heisenbug。而对付 Heisenbug 最管用的办法,根本不是写一万行防御代码去猜所有不可能的分支,而是把出问题的那块局部状态直接扔掉,重置回干净的初始状态。

问题来了:为什么在 Go、Java 或 C++ 里,你不敢这么干?

因为共享内存是有传染性的。假设线程 A 拿了一把互斥锁,正在改一棵全局内存树的左半边,改到一半因为某个奇葩数据抛了异常。这时候:

  • 你要是不捕获异常,整个进程直接挂掉;
  • 你要是用 try/catch 或 recover() 硬把异常吞了,其实更可怕——因为那棵树才改了一半,内存里的数据结构已经处于被污染的残废状态(Corrupted State),锁甚至可能都没释放干净。接下来整个服务表面上还活着,实际上已经在吐脏数据或者死锁了。

进程隔离 + 死亡信号 + 监督树

Erlang 敢喊出“Let It Crash”,底气就在于它用三样东西把“擦屁股”这件事彻底标准化了:

  1. 互不污染的私有内存:进程 A 算到一半崩了,它的私有堆直接销毁,绝不可能在邻居进程 B 的内存里留下一半改坏的指针;
  2. 内核级的生死契约(Link / Monitor):进程之间可以用 link 绑在一块。一旦某个进程非正常退出,BEAM 虚拟机底层保证会给监管它的进程发一条 {'EXIT', Pid, Reason} 信号;
  3. 监督树(Supervision Tree):这是 OTP 的灵魂。整个系统是一棵树,干活的叶子节点(Worker)只管写最顺畅的正常逻辑(Happy Path),连 try/catch 都懒得写;当官的树干节点(Supervisor)一行具体业务都不碰,专职负责看管子节点、按策略重启。

看两段极简的 Erlang 代码就能明白这种分工有多爽。假设我们写一个维护设备会话的 gen_server 进程:

device_session.erl
erlang
1-module(device_session).
2-behaviour(gen_server).
3
4-export([start_link/1, push_metric/2]).
5-export([init/1, handle_call/3, handle_cast/2]).
6
7start_link(DeviceId) ->
8 gen_server:start_link(?MODULE, [DeviceId], []).
9
10push_metric(Pid, Metric) ->
11 gen_server:cast(Pid, {metric, Metric}).
12
13init([DeviceId]) ->
14 %% 注意看这里:根本不写 case ... of {error, ...} 的防御分支!
15 %% 如果拉不到配置,{ok, Config} 模式匹配直接失败崩溃,交给上级 Supervisor 处理。
16 {ok, Config} = registry:fetch_config(DeviceId),
17 {ok, #{device_id => DeviceId, config => Config, window => []}}.
18
19handle_cast({metric, Value}, #{window := Win} = State) when is_number(Value) ->
20 NewWin = lists:sublist([Value | Win], 100),
21 {noreply, State#{window := NewWin}}.
22
23handle_call(summary, _From, #{window := Win} = State) ->
24 Avg = lists:sum(Win) / length(Win),
25 {reply, {ok, Avg}, State}.

你可能会问:万一 registry:fetch_config 因为网络抖动失败了,或者 Win 是空列表导致除以 0 崩了怎么办?别急,看它头顶上的 supervisor:

session_sup.erl
erlang
1-module(session_sup).
2-behaviour(supervisor).
3
4-export([start_link/0, init/1]).
5
6start_link() ->
7 supervisor:start_link({local, ?MODULE}, ?MODULE, []).
8
9init([]) ->
10 %% one_for_one:哪个子进程崩了,就单独重启哪一个,别碰它的兄弟进程。
11 %% intensity => 5, period => 10:10 秒内最多允许重启 5 次;
12 %% 如果 10 秒内连崩 6 次,说明不是偶发抖动,当前 Supervisor 自己主动崩溃,把问题捅给更上一级!
13 SupFlags = #{
14 strategy => one_for_one,
15 intensity => 5,
16 period => 10
17 },
18 ChildSpecs = [
19 #{
20 id => device_worker,
21 start => {device_session, start_link, ["dev-001"]},
22 restart => permanent,
23 shutdown => 5000,
24 type => worker
25 }
26 ],
27 {ok, {SupFlags, ChildSpecs}}.

我一直觉得 OTP 的 intensity 和 period 设计得就像家里电箱的分级空气开关:台灯短路了,先跳插座这路的支路空开(重启单个 Worker);如果你合闸 5 次它连跳 5 次,说明根本不是灯泡接触不良,而是整条线路或者上游连接池彻底坏了。这时候支路不再无脑重试,直接让上一级总闸跳闸,把整棵子树连同共享连接池一起重置。

把正常业务逻辑和容错恢复逻辑彻底拆开之后,你会发现代码量少了一小半,而且再也不会写出那种“为了防崩把异常吞掉,结果服务半死不活挂在那里”的诡异 Bug。

3. 云原生折腾了十年,其实是在重造一台外置的 BEAM

这几年每次配 Kubernetes 的 YAML、调 Envoy Sidecar、接 Redis 和消息队列的时候,我都有一种很强烈的既视感:云原生这套东西,本质上就是因为主流语言运行时太简陋,逼着我们用 Linux 容器和网络代理,在操作系统层面重新拼了一台笨重的 BEAM 虚拟机。

The Cloud-Native Tax
每次你把原本在同一个运行时里发条内存消息就能搞定的事,拆成跨容器、跨进程的网络调用,都在隐式交一笔不菲的“基础设施税”:序列化反序列化(JSON/Protobuf)、内核网络栈进出、Sidecar 转发、连接池保活、超时重试,以及最让人头秃的分布式状态一致性。

我们不妨把现在互联网公司最标准的“云原生全家桶”,跟一个单体的 Erlang/OTP 节点摆在一起比比看:

工程需求 云原生拼装方案 Erlang / OTP 内置原语 量级与代价对比
故障自愈 K8s Deployment + LivenessProbe OTP supervisor 监督树 K8s 重启几百 MB 容器(秒级);OTP 重启 2.4KB 进程(微秒级)
服务发现 Consul / Envoy Mesh + gRPC 位置透明通信 {Name, Node} ! Msg + pg 无需 IDL 与 Sidecar,给本地和远端进程发消息写法一模一样
高速 KV 外挂 Redis 集群 + 本地缓存同步 ETS / persistent_term 直接常驻 BEAM 堆外内存,支持无锁并发读,零网络开销
分布式存储 外置数据库 / 分布式锁 Mnesia 内置多副本数据库 直接存语言原生结构,没有 ORM 转换的折腾
异步削峰 Kafka / RabbitMQ 进程邮箱(Mailbox)+ gen_stage 顺带一提,RabbitMQ 本身就是用 Erlang 写的
动态观测 Prometheus + OTel + 改代码重新发版 observer / recon / 内置 Trace 不用提前打日志、不用重启,线上直接挂载任意进程看收发消息和入参

当然,并不是说 Kubernetes 和 Redis 没用——在几百号人的多语言团队里,用容器做标准交付界面是完全合理的。但现实情况往往是:很多只有几个人的小团队、业务量一台 32 核机器都跑不满,却一上来就上了全套微服务 + K8s + Redis + Kafka,把大把精力花在了跟分布式基础设施斗智斗勇上。

而在 BEAM 的世界里,WhatsApp 当年只靠几十个工程师,单机就能扛住 200 万并发长连接;Discord 用 Elixir(跑在 BEAM 上)轻松管着几百万个实时语音和文字房间。对它们来说,一个聊天室、一个用户会话,根本不需要去 Redis 里占一个 Key 再加把分布式锁,它就只是内存里的一个普通进程(GenServer)而已。

4. 2026 的新风向:为什么大家又悄悄回到了 Actor?

如果在十年前,业务大多是“前端表单提交 -> 后端查个 MySQL -> 拼个 JSON 返回”的无状态 CRUD,那用不用 Erlang 确实没差。但到了 2025–2026 年,两个新趋势把大家硬生生逼回了“有状态计算”的路上。

AI Agent 说白了就是 Actor

回想一下 2023 年大家是怎么写大模型应用的:写个无状态的 Python 接口,拼段 Prompt 调一下 OpenAI API,拿到返回存进数据库,完事。

但到了今天,真正在生产环境跑的 AI Agent 系统早就不是一次性的问答了,而是一张长生命周期、多工具、多子任务协同的状态网:

  • 主 Agent 接到一个复杂任务,得同时派生(Spawn)出好几个子 Agent,一个去搜代码,一个去跑沙箱测试,一个去查日志;
  • 每个 Agent 脑子里都揣着自己那份上下文和短期记忆(私有状态);
  • 调外部工具随时可能因为 API 限流、沙箱卡死或者模型抽风输出了非法格式而报错崩溃;
  • 遇到高危操作还得原地挂起等人工点确认(Human-in-the-loop),可能过几个小时收到一条异步事件才接着往下走;
  • 某个子 Agent 要是进了死循环或者炸了,父 Agent 得收到信号,决定是重试一次,还是换个模型重新拉起一个干净的子 Agent。

你把上面这几条需求连起来看一眼——这哪是什么新鲜概念,这不就是教科书里的 Actor 模型加上 Erlang 的监督树吗?

用无状态的 Python 脚本外挂 Celery + Redis 去硬搓这套状态流转,最后写出来的往往是一堆分布式锁、轮询定时任务和清理不掉的僵尸状态;而在 BEAM 里,一个 Agent 天然就是一个进程,发消息、设超时、挂起等待、崩了由父进程兜底,全都是语言自带的本能反应。

Durable Execution 与 Elixir / Gleam 的焕新

不光是 AI,这几年后端基础架构圈子里最火的两个词——持久化执行(Durable Execution,比如 Temporal、Restate) 和 虚拟 Actor(比如 Orleans、Cloudflare Durable Objects),说穿了也是在帮主流语言补上 Erlang 的作业:把状态重新拉回计算节点内存里(靠单线程事件循环消灭锁),再由运行时接管重试和崩溃恢复。

以前很多程序员对 BEAM 望而却步,主要卡在两个槽点上:一是 Erlang 那套继承自 Prolog 的上古语法(句号 .、分号 ;、逗号 , 混用,刚写的时候真能把人逼疯);二是动态类型在重构大项目时心里没底。

不过站在 2026 年看,这两个痛点在 BEAM 生态内部其实已经解得差不多了:

  • 如果你喜欢现代语法和丰富的 Web / AI 生态,Elixir 已经非常成熟了。尤其是 Phoenix LiveView,直接用服务端一个轻量进程通过 WebSocket 驱动前端 DOM 增量更新,连前后端分离那套繁琐的 Redux/API 状态同步都省了;
  • 如果你是坚定的静态类型党(比如写惯了 Rust 和 Go),这几年异军突起的 Gleam 则是个巨大的惊喜——它在 BEAM 之上做了一套非常干净的 ML 风格静态类型系统,有代数数据类型(ADT)、穷尽模式匹配和 Result<T, E>,编译出来还是原汁原味的 Erlang 字节码,直接跑在 BEAM 的调度器和监督树上。

5. 泼点冷水:Erlang 绝不是银弹

吹了这么多,最后必须得泼盆冷水。我最反感把某门技术捧上神坛——BEAM 所有让人惊艳的特性,底下都是拿实打实的代价换来的。

第一个代价就是 纯 CPU 算力真的不够看。为了做到进程彻底隔离、不可变数据和随时抢占,BEAM 里的数据结构几乎全是指针串起来的装箱对象(Boxed Terms),内存局部性跟 C++/Rust 的连续结构体数组完全没法比。哪怕从 OTP 24 开始引入了 BeamAsm(原生 JIT 编译器),直接把字节码编译成 x86/ARM 机器码,性能翻了好几倍,但真要拿它去算矩阵乘法、做音视频编解码或者超高吞吐的零拷贝解析,它还是比 Rust 或 Go 慢一个数量级。好在现在的标准做法是“BEAM 管控制面与状态编排,Rust 写 NIF(通过 Rustler)管重度计算”,把脏活累活丢给脏调度器(Dirty Schedulers)去跑,算是扬长避短。

第二个新手常踩的大坑是 邮箱积压(Mailbox Bloat)。在 Erlang 里发消息 Pid ! Msg 是异步非阻塞的,默认没有反压(Backpressure)。如果上游塞消息的速度比下游进程处理的速度快,那个进程的邮箱就会无限膨胀,一路把整台机器的内存吃光(OOM)。更要命的是,如果代码里写了选择性接收(Selective Receive),邮箱一长,每次取消息都要扫一遍旧消息,CPU 直接退化成 $O(N)$。所以在线上写 Erlang/Elixir,核心路径基本都得老老实实用带超时的同步调用(gen_server:call)或者 gen_stage 来做流控。

第三个边界是 原生集群的规模上限。Erlang 自带的分布式集群(Distributed Erlang)默认是全网格互联(Full-Mesh),节点之间两两心跳。十几个、几十个节点在同机房跑起来简直像魔法一样顺滑;但一旦节点数破百,或者跨机房网络经常抖动,原生心跳带来的广播风暴和脑裂(Net Split)还是挺让人头疼的,往往得换上 Partisan 或者基于 Raft 的 ra 库来自己管拓扑。

6. 写在最后

Lisp 圈子里有句很出名的俏皮话,叫“格林斯潘第十定律”(Greenspun’s Tenth Rule):

“任何足够复杂的 C 或 Fortran 程序,都包含一个临时拼凑、只有一半功能、漏洞百出且运行缓慢的 Common Lisp 实现。”

2026 年再回看这四十年后端架构的兜兜转转,我觉得并发与分布式领域其实也有一条一模一样的 “阿姆斯特朗定律”(Armstrong’s Law):

“任何足够复杂的分布式或高并发后端系统,最终都会在业务代码或基础设施层,临时拼凑出一套只有一半功能、配置繁琐且脆弱不堪的 Erlang/OTP 实现。”

今天,我们当然没必要把公司里跑得好好的 Go、Rust 或 Python 服务全都推倒重来。但如果你平时也在被微服务超时、协程泄漏、分布式锁死锁或者复杂的 Agent 状态机折磨,真的非常建议抽个周末,去读一读 BEAM 的调度和内存模型,亲手写一棵简单的 OTP 监督树。

看懂了四十年前那群电信工程师是怎么对付“并发”与“失败”的,你再回来看今天的架构图,很多纠结的问题一下就通透了。