本文翻译自 The Isolation Trap,原载于 Hacker News。
我们继续探讨编程语言中并发的问题。在上一篇《消息传递就是共享可变状态》中,我论证了 Go 的 channel 本质上就是多了几层包装的共享可变状态。但你可能会说,Go 的 channel 并不是真正的消息传递。
如果要评选”消息传递的最佳实践”,这个荣誉恐怕非 Erlang 莫属。那么,让我们来仔细看看 Erlang。
最佳实践
Actor 模型自 Carl Hewitt 在 1973 年提出以来,一直是并发编程领域极具影响力的思想。核心理念很诱人:并发实体(actor)之间完全通过消息进行通信。每个 actor 都有自己的私有状态,其他 actor 无法触碰。如果 actor 之间不能共享状态,自然就不会有共享状态的 bug。
各种语言和框架都实现了这个思想的不同版本。Akka 把 actor 带到了 JVM,但由于它们共享 Java 堆内存,隔离只能靠约定来保证,而不是运行时强制。Akka actor 可以在消息中传递可变对象的引用,于是两个 actor 就共享了可变状态。Swift 在 5.5 版本中添加了 actor,有更强的编译时检查,但默认允许重入调用,重新引入了 actor 本应避免的一些问题。Orleans 和 Dapr 提供”虚拟 actor”来解决生命周期管理问题,但并没有解决根本的并发模型。
如果你要构建 actor 模型的最强版本,把安全性和容错性置于一切之上,你最终可能会得到类似 Joe Armstrong 和爱立信团队所构建的东西。
Erlang 进程拥有独立的堆内存,所以它们无法共享内存。消息从一个进程复制到另一个进程,而不是通过引用共享。如果一个进程崩溃了,它的状态也随之消亡,监督树会负责恢复。没有任何机制可以让一个进程破坏另一个进程的内存——因为根本没有共享内存可供破坏。
这不仅仅是学术上的优雅——它让电话交换机达到了 99.999% 的可用性。它让 WhatsApp 用一个小团队支撑了数亿用户。它在真正停机就意味着真实后果的系统中,经历了三十年的生产环境实战检验。
Erlang 是隔离论点的最强形式,它值得被认真对待——这也正是接下来要讲的内容之所以重要的原因。
熟悉的问题
在第一篇文章中,我论证了消息传递就是共享可变状态。通信机制本身(channel、mailbox、消息队列)就是一个共享可变资源,继承了共享可变状态一直以来的问题。Erlang 的 mailbox 也不例外。
Erlang 的单所有者 mailbox 设计比 Go 的 channel 更加规范:只有拥有进程才能读取 mailbox,发送是异步的。然而,共享可变状态的四种失效模式仍然会出现,只是换了一种形式。
考虑两个需要相互获取数据的 Erlang 服务器:
%% Server A 通过调用 Server B 来处理请求
handle_call(request, _From, State) ->
Result = gen_server:call(server_b, sub_request),
{reply, Result, State}.
%% Server B 通过调用 Server A 来处理请求
handle_call(sub_request, _From, State) ->
Result = gen_server:call(server_a, request),
{reply, Result, State}.
这看起来并没有明显的问题。两个服务器协作是一种正常的架构。但是,如果一个请求到达 Server A,触发了对 Server B 的调用,而 Server B 正在处理一个调用 Server A 的请求,那么两个服务器都会永远阻塞。每一个都在等待自己的 mailbox 中永远不会到来的回复,因为另一个服务器也在等待。这就是用消息传递表达的互斥锁死锁。
Erlang 开发者知道这种模式,OTP 设计指南也明确不鼓励这样做。但知道它的存在并不能防止它的发生。研究人员在由专家编写、遵循指南的生产级 OTP 库中发现了以前未知的实例。2026 年的一篇 OOPSLA 论文(Fowler 和 Hu)证明了一个更强的结论:两个单独无死锁的协议组合起来,仍然可能在 actor 系统中产生死锁。唯一的解决方案要么是限制每个 actor 同时只能有一个会话(对于真正的服务器来说限制太强),要么是构建一个流敏感的类型系统,将协议状态穿过每个函数调用。问题不在于开发者意外地写了循环调用,而在于无死锁性不可组合。
其他三种失效模式也遵循同样的模式。Erlang mailbox 是无界的,不提供自动背压(backpressure),所以如果一个进程接收消息的速度超过它处理的速度,mailbox 就会一直增长,直到节点内存耗尽并崩溃。Fred Hébert(《Erlang in Anger》的作者)专门为此构建了一个叫 pobox 的库,他指出”高吞吐量的 Erlang 应用程序经常被 Erlang mailbox 的无界特性所困扰”。来自多个发送者的消息交错是不确定的,产生了语言无法防止的顺序竞争。Erlang 消息是动态类型的,所以一个进程可以向任何其他进程发送任何 term,编译时没有任何检查来确保接收方期望接收它。
这些是真实 Erlang 系统中的真实 bug。mailbox 设计使其中一些比 Go channel 等价物更不可能发生,但并没有从结构上使任何一个变得不可能。
缓解措施
Erlang 对这些问题都有答案,而且是不错的答案:
| 问题 | Erlang 中的形式 | 缓解措施 | 强制方式 |
|---|---|---|---|
| 死锁 | 循环 gen_server:call 链 |
优先使用异步 cast,使用超时 | 设计时约定 |
| 内存泄漏 | 无界 mailbox 增长 | 监控大小,使用 pobox,背压 |
运行时监控 |
| 竞争 | 不确定的消息交错 | 仔细的协议设计,测试 | 设计时纪律 |
| 协议违规 | 无类型消息,不匹配的子句 | OTP behavior,代码审查 | 设计时约定 |
这些缓解措施是有效的。它们是 Erlang 系统如此可靠的重要原因。
但看看最后一列:约定、监控、纪律。每一个都落在程序员身上。没有一个是语言或编译器强制执行的,其中一个甚至直到系统在生产环境真实负载下运行时才能强制执行。每一个缓解措施都依赖于程序员做正确的事,而系统不保证的属性最终会被使用它的人类违反。
跳过 gen_server 使用原始消息传递?你会失去协议结构。忘记在 gen_server:call 上设置超时?你会得到一个隐藏的死锁,直到生产负载触发它才出现。忘记监控 mailbox 大小?凌晨三点节点因溢出而崩溃。使用了错误的 receive 子句模式?静默的错误行为。
每个缓解措施单独看都是合理的,但它们会累积。一个新加入 Erlang 团队的开发者不仅要学习语言,还需要学习哪些约定是承重的,要运行哪些工具,哪些模式是安全的,哪些看起来无害的代码里隐藏着死锁。程序员需要记住的每一件新事物,都是程序员可能忘记的又一件。
这就是纪律税。当团队经验丰富、代码库维护良好、约定始终一致遵循时,它是有效的。当这些条件中任何一个削弱时,它就会侵蚀——而只要有足够的时间和足够的人员流动,这些条件终将被削弱。
瓶颈
即使所有的缓解措施都到位、团队遵循每一条约定,隔离模型仍然有一个结构性的性能限制。
每个进程的状态都通过其 mailbox 访问。一个进程,一个 mailbox,一次处理一条消息。对该进程数据的所有访问都是序列化的。如果你想读取它的状态,你发送一条消息并等待回复。
在中等负载下这没问题,但当一千个其他进程都需要从同一份数据读取时,问题就出现了:路由表、配置存储、会话注册表、共享查找缓存。纯粹模型说”发送消息,等待回复”。每个读取者排队等待,mailbox 变成一个漏斗,吞吐量崩溃。
这不是 bug,也不是设计疏忽,而是隔离模型的直接后果。如果安全性来自于”没有进程可以直接访问另一个进程的状态”,那么所有状态访问都必须经过拥有进程,而拥有进程就变成了序列化瓶颈。
通过隔离获得安全性,意味着安全性和性能之间存在张力。 我想 Erlang 的创造者对此理解得非常清楚。
逃逸舱口
ETS(Erlang Term Storage)的存在就是因为这个瓶颈。ETS 表是位于进程模型之外的、可变的、并发的内存数据结构。任何进程都可以读取或写入公共 ETS 表,而无需向任何人发送消息。
这是 Erlang 团队的一个有原则的工程决策,不是错误。他们认识到纯隔离无法满足现实世界的性能要求,因此提供了一个精心设计的逃逸舱口。
而且压力并没有止步于 ETS。OTP 21 添加了 persistent_term,这是一个全局不可变存储,针对频繁读取、很少写入的数据(如配置、路由表、编译后的正则表达式)进行了优化,因为即使是 ETS 对这些访问模式来说开销也太大了。OTP 22 添加了 atomics 和 counters 模块:直接的共享内存操作,没有复制,没有消息传递,根本不涉及进程。每一次添加都进一步远离了隔离模型,因为每一次都解决了上一个逃逸舱口无法填补的性能差距。
但逃逸舱口终究是逃逸舱口。这些机制完全绕过了进程隔离模型。它们是进程模型之外的共享状态,任何进程都可以并发访问,没有 mailbox 序列化,没有消息复制,没有所有权语义。当你把共享状态引入一个以”没有共享状态”为前提构建的系统时,你重新引入了这个前提本应消除的 bug。
经验丰富的 Erlang 开发者很清楚这种权衡。大型系统通常将状态分片到多个进程,将 actor 与 ETS 结合用于读密集型工作负载,并使用 persistent_term 存储全局配置。这些都是有效的工程模式。但它们的存在本身就是重点:它们是当隔离成为瓶颈时放松隔离的方法。问题不在于 Erlang 工程师能否绕过这个限制,而在于他们不得不这样做意味着什么。
后果
Maria Christakis 和 Konstantinos Sagonas 为 Erlang 构建了一个静态竞争检测器,并将其集成到 Dialyzer(Erlang 的标准静态分析工具)中。他们对 OTP 自己的库运行了检测器——这些库经过了大量测试并被广泛部署。
他们发现了以前未知的竞争条件。不是在代码库的偏僻角落。不是在奇异的边缘情况。而是在每个 Erlang 应用程序都依赖的那种代码中,那些已经在生产环境运行多年的代码。
这些竞争集中在三个类别,全部出现在隔离被打破的地方:
ETS 表竞争。 进程 A 从公共表中读取一个键,决定更新它,但进程 B 在读取和写入之间修改了它。经典的 check-then-act,也称为 TOCTOU(”检查时间到使用时间”)。ETS 明确记录表遍历不提供快照一致性:迭代期间的并发插入可能导致键被遗漏或访问两次。单个键操作是原子的,但多键操作不是。
进程注册竞争。 Erlang 允许进程在全局可变命名空间中注册名称。两个进程竞争注册同一个名称,或者一个进程查找一个名称并发送消息,而命名的进程死亡、名称被重新注册给不同的进程。这些都是典型的共享可变状态 TOCTOU bug。
进程字典竞争。 进程字典是每进程的可变状态,本质上是一个线程本地可变哈希表,它破坏了引用透明性,在与跨越进程边界的操作结合时产生微妙的顺序依赖。
这些不是 Erlang 特有的问题。它们正是共享可变状态一直产生的相同类别的 bug:check-then-act 竞争、没有原子性的并发修改、全局命名空间上的 TOCTOU。它们被发现于一个为解决它们而设计的语言中。
模式
让我们退后一步,看看全貌。
Actor 模型的承诺是通过隔离实现并发。Erlang 是其最强的实现:独立的堆、复制的消息、单所有者 mailbox。社区为仍然泄漏的问题开发了复杂的缓解措施:OTP behavior、监督树、文化约定、监控工具、静态分析。然后性能压力迫使引入共享可变状态,这绕过了所有那些缓解措施,重新引入了模型及其所有累积的安全措施本应防止的问题。
较弱的 actor 实现(如 Akka)甚至走不到这一步。它们从第一天起就有可用的共享可变状态,完全依赖程序员纪律来避免使用它。Erlang 至少在性能压力侵蚀隔离之前,在运行时级别强制执行隔离。
这几乎与第一篇文章中的循环相同,只是从不同角度观察。Go 的 channel 看起来像是逃离共享内存的方法,但结果证明是伪装的共享内存。Erlang 的隔离确实不是共享内存——直到现实世界通过标有”性能”的门把共享内存重新推了进来。
不同的起点,相似的终点。
这并不是对 Erlang 工程或 actor 模型作为概念的批评。Erlang 团队基于他们的基础做出了正确的权衡。问题在于基础本身。任何通过隔离实现安全性的并发模型都将面临这种压力,因为当多个计算同时需要同一数据时,它们需要并发访问它。隔离只能通过序列化提供访问。当序列化跟不上时,选择就在于安全性和性能之间——而在生产环境中,性能往往获胜。
在这两篇文章中,我们看到了两种方法都撞上了相同形状的墙。共同点不是 channel 或 actor 或任何特定机制。而是两种方法都从同一个假设出发:安全性来自于控制线程如何交互。到目前为止,这个假设有着完美的记录——总是通向它本应解决的问题。
我的思考
这篇文章给我留下了深刻印象。作为开发者,我们经常被各种”银弹”所吸引:面向对象、函数式编程、微服务、actor 模型……每一种都承诺解决之前的问题。但 Erlang 的故事告诉我们,没有银弹,只有权衡。
Erlang 的隔离模型确实很优雅,但优雅的代价是性能。当性能成为瓶颈时,我们必须引入 ETS、persistent_term、atomics——这些都是共享状态的”逃逸舱口”。这不是 Erlang 的失败,而是对现实世界的妥协。
这让我想到了几个实践层面的启示:
-
理解你选择的工具的边界。Erlang 很适合高并发、容错要求高的场景,但如果你的应用是读密集型的共享数据访问,可能需要重新考虑架构。
-
没有免费的午餐。隔离带来了安全性,但也带来了序列化的代价。当你在设计系统时,要清楚地知道你在用性能换什么。
-
测试和静态分析工具很重要。即使是 Erlang 这样经过深思熟虑的设计,也会有竞争条件和死锁。 Dialyzer 这样的工具不是可选的,而是必须的。
-
团队纪律是最后的防线。技术手段(类型系统、运行时检查)能做到的有限,最终还是要靠团队的约定和代码审查来保证质量。
最后,这篇文章的系列标题是”Causality”(因果),作者似乎在暗示:如果你选择了一条路(隔离),你就必须承受它的后果(性能瓶颈和逃逸舱口带来的复杂性)。理解这种因果关系,比盲目相信任何一种方法的优越性更重要。