Erlang 的重点不是轻量级进程和消息传递...

目录

发布于 2023 年 1 月 18 日

我过去认为 Erlang 的核心思想是它的轻量级进程和消息传递。但在过去的几年里,我意识到其中蕴含着更深刻的见解,我想在这篇文章中与你分享。

Background

Erlang 有着一段有趣的历史。如果我理解正确,它最初是一个用于构建可靠分布式系统的 Prolog 库,后来演变成 Prolog 的一种方言,最终成为一种独立的语言。

其目标似乎始终是解决构建可靠分布式系统的问题。它在 Ericsson 开发,并用于编写他们的电话交换机程序。那是 80 年代和 90 年代的事情,当时互联网的使用尚未普及。我想他们已经在处理“互联网规模”的流量,即数亿用户,并且有着比今天大多数互联网服务更严格的 SLA。因此,从某种意义上说,他们超前于时代。

1998 年,Ericsson 决定禁止使用所有 Erlang1。负责开发它的人员认为,如果他们要禁止它,那么他们不妨开源它。Ericsson 确实这样做了,之后不久,创建 Erlang 的大部分团队辞职并创办了自己的公司。

其中一人是 Joe Armstrong,他也是 Erlang 设计和实现背后的主要人员之一。这家公司名为 Bluetail,后来被收购了几次,但最终 Joe 在 2002 年被解雇。

此后不久,同样在 2002 年,Joe 开始在 Swedish Institute of Computer Science (SICS) 撰写他的博士论文。Joe 出生于 1950 年,所以那时他可能已经 52 岁了。论文的主题是 Making reliable distributed systems in the presence of software errors,并在第二年,也就是 2003 年完成。

这篇论文在很多方面都很不寻常。首先,大多数论文都是由二十多岁、没有实际应用经验的人撰写的。而在 Joe 的情况下,他从 80 年代开始就一直从事这个主题的专业工作,大约有 20 年的时间。这篇论文不包含任何数学或理论,仅仅是 Erlang 背后的思想以及他们如何使用 Erlang 来实现构建可靠分布式系统的最初目标的陈述。

我强烈建议阅读他的论文并形成你自己的观点,但对我来说,显而易见的是,其中的核心思想不是轻量级进程2和消息传递,而是 Erlang 中称为 behaviours 的通用组件。

Behaviours

我将首先更详细地解释什么是 behaviours,然后我会回到它们比轻量级进程的思想更重要的观点。

Erlang behaviours 就像 Java 或 Go 中的接口。它是一组类型签名,可以有多个实现,一旦程序员提供了这样的实现,他们就可以访问针对该接口编写的函数。为了更具体,这里有一个 Go 中精心设计的示例:

[](https://stevana.github.io/<#cb1-1>)// The interface.
[](https://stevana.github.io/<#cb1-2>)type HasName interface {
[](https://stevana.github.io/<#cb1-3>)    Name() string
[](https://stevana.github.io/<#cb1-4>)}
[](https://stevana.github.io/<#cb1-5>)
[](https://stevana.github.io/<#cb1-6>)// A generic function written against the interface.
[](https://stevana.github.io/<#cb1-7>)func Greet(n HasName) {
[](https://stevana.github.io/<#cb1-8>)  fmt.Printf("Hello %s!\n", n.Name())
[](https://stevana.github.io/<#cb1-9>)}
[](https://stevana.github.io/<#cb1-10>)
[](https://stevana.github.io/<#cb1-11>)// First implementation of the interface.
[](https://stevana.github.io/<#cb1-12>)type Joe struct {}
[](https://stevana.github.io/<#cb1-13>)
[](https://stevana.github.io/<#cb1-14>)func (_ *Joe) Name() string {
[](https://stevana.github.io/<#cb1-15>)    return "Joe"
[](https://stevana.github.io/<#cb1-16>)}
[](https://stevana.github.io/<#cb1-17>)
[](https://stevana.github.io/<#cb1-18>)// Second implementation of the interface.
[](https://stevana.github.io/<#cb1-19>)type Mike struct {}
[](https://stevana.github.io/<#cb1-20>)
[](https://stevana.github.io/<#cb1-21>)func (_ *Mike) Name() string {
[](https://stevana.github.io/<#cb1-22>)    return "Mike"
[](https://stevana.github.io/<#cb1-23>)}
[](https://stevana.github.io/<#cb1-24>)
[](https://stevana.github.io/<#cb1-25>)func main() {
[](https://stevana.github.io/<#cb1-26>)    joe := &Joe{}
[](https://stevana.github.io/<#cb1-27>)    mike := &Mike{}
[](https://stevana.github.io/<#cb1-28>)    Greet(mike)
[](https://stevana.github.io/<#cb1-29>)    Greet(joe)
[](https://stevana.github.io/<#cb1-30>)}

运行上面的程序将显示:

Hello Mike!
Hello Joe!

这希望说明了 Greet 如何通过接口 HasName 通用化或参数化。

Generic server behaviour

接下来,让我们看看 Joe 的论文(p. 136)中摘取的 Erlang 中一个更复杂的示例。这是一个键值存储,我们可以在其中 store 键值对或 lookup 键的值,handle_call 部分是最有趣的:

[](https://stevana.github.io/<#cb3-1>)-module(kv).
[](https://stevana.github.io/<#cb3-2>)-behaviour(gen_server).
[](https://stevana.github.io/<#cb3-3>)
[](https://stevana.github.io/<#cb3-4>)-export([start/0, stop/0, lookup/1, store/2]).
[](https://stevana.github.io/<#cb3-5>)
[](https://stevana.github.io/<#cb3-6>)-export([init/1, handle_call/3, handle_cast/2, terminate/2]).
[](https://stevana.github.io/<#cb3-7>)
[](https://stevana.github.io/<#cb3-8>)start() ->
[](https://stevana.github.io/<#cb3-9>) gen_server:start_link({local,kv},kv,arg1,[]).
[](https://stevana.github.io/<#cb3-10>)
[](https://stevana.github.io/<#cb3-11>)stop() -> gen_server:cast(kv, stop).
[](https://stevana.github.io/<#cb3-12>)
[](https://stevana.github.io/<#cb3-13>)init(arg1) ->
[](https://stevana.github.io/<#cb3-14>) io:format("Key-Value server starting~n"),
[](https://stevana.github.io/<#cb3-15>) {ok, dict:new()}.
[](https://stevana.github.io/<#cb3-16>)
[](https://stevana.github.io/<#cb3-17>)store(Key, Val) ->
[](https://stevana.github.io/<#cb3-18>) gen_server:call(kv, {store, Key, Val}).
[](https://stevana.github.io/<#cb3-19>)
[](https://stevana.github.io/<#cb3-20>)lookup(Key) -> gen_server:call(kv, {lookup, Key}).
[](https://stevana.github.io/<#cb3-21>)
[](https://stevana.github.io/<#cb3-22>)handle_call({store, Key, Val}, From, Dict) ->
[](https://stevana.github.io/<#cb3-23>) Dict1 = dict:store(Key, Val, Dict),
[](https://stevana.github.io/<#cb3-24>) {reply, ack, Dict1};
[](https://stevana.github.io/<#cb3-25>)handle_call({lookup, crash}, From, Dict) ->
[](https://stevana.github.io/<#cb3-26>) 1/0; %% <- deliberate error :-)
[](https://stevana.github.io/<#cb3-27>)handle_call({lookup, Key}, From, Dict) ->
[](https://stevana.github.io/<#cb3-28>) {reply, dict:find(Key, Dict), Dict}.
[](https://stevana.github.io/<#cb3-29>)
[](https://stevana.github.io/<#cb3-30>)handle_cast(stop, Dict) -> {stop, normal, Dict}.
[](https://stevana.github.io/<#cb3-31>)
[](https://stevana.github.io/<#cb3-32>)terminate(Reason, Dict) ->
[](https://stevana.github.io/<#cb3-33>) io:format("K-V server terminating~n").

这是 gen_server behaviour/接口的实现。请注意 handle_call 如何在 store 的情况下更新状态 (Dict) 并在状态中 lookup 键。一旦 gen_server 给定此实现,它将提供一个可以处理并发 storelookup 请求的服务器,类似于 Greet 提供显示功能的方式。

此时你可能会想“好吧,那又怎样?很多编程语言都有接口……”。这是真的,但请注意 handle_call 是完全顺序的,即所有并发都隐藏在通用 gen_server 组件中。“是的,但这只是良好的工程实践,可以在任何语言中完成”你说。这同样是真的,但该论文将这个想法推向了相当远的境地。它确定了六种 behaviours:gen_servergen_eventgen_fsmsupervisorapplicationrelease,然后说这些足以构建可靠的分布式系统。作为一个案例研究,Joe 使用了 Ericsson 的一个电话交换机(p. 157):

当我们查看第 8 章中的 AXD301 项目时,我们将看到有 122 个 gen_server 实例、36 个 gen_event 实例和 10 个 gen_fsm 实例。有 20 个 supervisors 和 6 个 applications。所有这些都打包到一个 release 中。

Joe 给出了应该使用 behaviour 的几个理由(pp. 157-158):

  1. 应用程序员只需要提供定义他们问题的 semantics (或“业务逻辑”)的代码部分,而 infrastructure 代码由 behaviour 自动提供;
  2. 应用程序员编写顺序代码,所有并发都隐藏在 behaviour 中;
  3. Behaviours 由专家编写,基于多年的经验,代表“最佳实践”;
  4. 新团队成员更容易入门:业务逻辑是顺序的,与他们以前在其他地方可能见过的类似结构;
  5. 如果整个系统都是通过重用一小部分 behaviours 来实现的:随着 behaviour 实现的改进,整个系统将得到改进,而无需任何代码更改;
  6. 坚持只使用 behaviours 可以强制执行结构,这反过来使测试和形式验证变得更加容易。

我们稍后会回到关于测试的最后一点。

Event manager behaviour

让我们首先回到我们上面列出的 behaviours。我们查看了 gen_server,但是其他的用途是什么?有 gen_event,它是一个通用的事件管理器,它允许你注册事件处理程序,然后在事件管理器收到与处理程序关联的消息时运行这些处理程序。Joe 说这对于例如错误日志记录很有用,并给出了以下简单记录器的示例(p. 142):

[](https://stevana.github.io/<#cb4-1>)-module(simple_logger).
[](https://stevana.github.io/<#cb4-2>)-behaviour(gen_event).
[](https://stevana.github.io/<#cb4-3>)
[](https://stevana.github.io/<#cb4-4>)-export([start/0, stop/0, log/1, report/0]).
[](https://stevana.github.io/<#cb4-5>)
[](https://stevana.github.io/<#cb4-6>)-export([init/1, terminate/2,
[](https://stevana.github.io/<#cb4-7>)     handle_event/2, handle_call/2]).
[](https://stevana.github.io/<#cb4-8>)
[](https://stevana.github.io/<#cb4-9>)-define(NAME, my_simple_event_logger).
[](https://stevana.github.io/<#cb4-10>)
[](https://stevana.github.io/<#cb4-11>)start() ->
[](https://stevana.github.io/<#cb4-12>) case gen_event:start_link({local, ?NAME}) of
[](https://stevana.github.io/<#cb4-13>)  Ret = {ok, Pid} ->
[](https://stevana.github.io/<#cb4-14>)   gen_event:add_handler(?NAME,?MODULE,arg1),
[](https://stevana.github.io/<#cb4-15>)   Ret;
[](https://stevana.github.io/<#cb4-16>) Other ->
[](https://stevana.github.io/<#cb4-17>)  Other
[](https://stevana.github.io/<#cb4-18>) end.
[](https://stevana.github.io/<#cb4-19>)
[](https://stevana.github.io/<#cb4-20>)stop() -> gen_event:stop(?NAME).
[](https://stevana.github.io/<#cb4-21>)
[](https://stevana.github.io/<#cb4-22>)log(E) -> gen_event:notify(?NAME, {log, E}).
[](https://stevana.github.io/<#cb4-23>)
[](https://stevana.github.io/<#cb4-24>)report() ->
[](https://stevana.github.io/<#cb4-25>) gen_event:call(?NAME, ?MODULE, report).
[](https://stevana.github.io/<#cb4-26>)
[](https://stevana.github.io/<#cb4-27>)init(arg1) ->
[](https://stevana.github.io/<#cb4-28>) io:format("Logger starting~n"),
[](https://stevana.github.io/<#cb4-29>) {ok, []}.
[](https://stevana.github.io/<#cb4-30>)
[](https://stevana.github.io/<#cb4-31>)handle_event({log, E}, S) -> {ok, trim([E|S])}.
[](https://stevana.github.io/<#cb4-32>)
[](https://stevana.github.io/<#cb4-33>)handle_call(report, S) -> {ok, S, S}.
[](https://stevana.github.io/<#cb4-34>)
[](https://stevana.github.io/<#cb4-35>)terminate(stop, _) -> true.
[](https://stevana.github.io/<#cb4-36>)
[](https://stevana.github.io/<#cb4-37>)trim([X1,X2,X3,X4,X5|_]) -> [X1,X2,X3,X4,X5];
[](https://stevana.github.io/<#cb4-38>)trim(L) -> L.

有趣的部分是 handle_eventtrimreport。它们一起使用户可以记录,跟踪和显示最近的五个错误消息。

State machine behaviour

自论文撰写以来,gen_fsm behaviour 已重命名为 gen_statem(用于状态机)。它与 gen_server 非常相似,但更适合于实现协议,这些协议通常被指定为状态机。我认为任何 gen_server 都可以实现为 gen_statem,反之亦然,因此我们不会详细介绍 gen_statem

Supervisor behaviour

下一个有趣的 behaviour 是 supervisor。Supervisors 是一些进程,其唯一的工作是确保其他进程是健康的并执行其工作。如果受监视的进程失败,则 supervisor 可以根据一些预定义的策略重新启动它。这是一个 Joe 的示例(p. 148):

[](https://stevana.github.io/<#cb5-1>)-module(simple_sup).
[](https://stevana.github.io/<#cb5-2>)-behaviour(supervisor).
[](https://stevana.github.io/<#cb5-3>)
[](https://stevana.github.io/<#cb5-4>)-export([start/0, init/1]).
[](https://stevana.github.io/<#cb5-5>)
[](https://stevana.github.io/<#cb5-6>)start() ->
[](https://stevana.github.io/<#cb5-7>) supervisor:start_link({local, simple_supervisor},
[](https://stevana.github.io/<#cb5-8>) ?MODULE, nil).
[](https://stevana.github.io/<#cb5-9>)
[](https://stevana.github.io/<#cb5-10>)init(_) ->
[](https://stevana.github.io/<#cb5-11>) {ok,
[](https://stevana.github.io/<#cb5-12>) {{one_for_one, 5, 1000},
[](https://stevana.github.io/<#cb5-13>) [
[](https://stevana.github.io/<#cb5-14>)  {packet,
[](https://stevana.github.io/<#cb5-15>)   {packet_assembler, start, []},
[](https://stevana.github.io/<#cb5-16>)   permanent, 500, worker, [packet_assembler]},
[](https://stevana.github.io/<#cb5-17>)  {server,
[](https://stevana.github.io/<#cb5-18>)   {kv, start, []},
[](https://stevana.github.io/<#cb5-19>)   permanent, 500, worker, [kv]},
[](https://stevana.github.io/<#cb5-20>)  {logger,
[](https://stevana.github.io/<#cb5-21>)   {simple_logger, start, []},
[](https://stevana.github.io/<#cb5-22>)   permanent, 500, worker, [simple_logger]}]}}.

{one_for_one, 5, 1000} 是重启策略。它表示如果其中一个受监视的进程(packet_assemblerkvsimple_logger)失败,则仅重启失败的进程(one_for_one)。如果 supervisor 需要在 1000 秒内重新启动超过 5 次,则 supervisor 本身应该失败。

permanent, 500, worker 部分意味着这是一个 worker 进程,应永久保持活动状态,并且如果 supervisor 想要重新启动它,则给予它 500 毫秒的时间来优雅地停止它正在做的事情。

“如果 supervisor 尚未死亡,为什么要重新启动它?”可能会有人想知道。好吧,除了 one_for_one 之外,还有其他重启策略。例如,one_for_all,如果一个进程失败,则 supervisor 重新启动其所有子进程。

如果我们还考虑到 supervisors 可以监视 supervisors,这些 supervisors 不一定在同一台计算机上运行,那么我希望你能了解这种 behaviour 有多么强大。并且,不,这不仅仅是 “只是 Kubernetes”,因为它是在线程/轻量级进程级别而不是 docker 容器级别。

supervisors 及其重启策略的思想来自观察到重启通常可以解决问题,正如 IT Crowd 的 Have You Tried Turning It Off And On Again? 草图中所捕捉到的那样。

知道失败的进程将被重新启动,再加上 Jim Gray 的快速失败思想,要么根据规范产生输出,要么发出失败信号并停止运行,从而导致 Joe 的口号:“Let it crash!” (p. 107)。另一种考虑方式是,程序应该仅表达其“快乐路径”,如果快乐之路出现任何问题,它应该崩溃,而不是试图聪明地解决问题(可能会使问题变得更糟),supervisor 树中更高的另一个程序将处理它。

Supervisors 和 “let it crash” 理念似乎产生了可靠的系统。Joe 再次使用了 Ericsson AXD301 电话交换机示例(p. 191):

也没有以任何系统的方式收集有关系统长期运行稳定性的证据。对于 Ericsson AXD301,有关系统长期稳定性的唯一信息来自 power-point 演示文稿,该演示文稿显示了一些数字,声称一家主要客户运行了一个具有 99.9999999% 可靠性的 11 节点系统,但如何获得这些数字却未记录在案。

从长远来看,五个 9(99.999%)的可靠性被认为是好的(每年 5.26 分钟的停机时间)。根据某个有偏见的公司的报告,“59% 的 Fortune 500 强公司每周至少遭受 1.6 小时的停机时间”。请注意,每年每周 的区别,但是由于我们不知道如何获得任何可靠性数字,因此可以安全地假设真相介于两者之间 - 仍然存在很大差异,但每年 31.56 毫秒(九个 9)的停机时间与每周 1.6 小时的停机时间不同。

Application and release behaviours

我不确定 applicationrelease 在技术上是否是 behaviours,即接口。它们与论文中的其他 behaviours 位于同一章中,并且它们确实提供了一个清晰的结构,这是其他 behaviours 的特征,因此我们将它们包括在讨论中。

到目前为止,我们已经从下到上介绍了 behaviours。我们从“ worker ” behaviours gen_servergen_statemgen_event 开始,它们共同捕获了我们问题的 semantics 。然后,我们看到了如何定义 supervisor 树,这些树的子级是其他 supervisor 树或 workers,以处理故障和重新启动。

下一个级别是 application,它由 supervisor 树以及交付特定应用程序所需的一切组成。

一个系统可以由多个 application 组成,这就是最终 “behaviour” 的用武之地。一个 release 打包了一个或多个应用程序。它们还包含用于处理升级的代码。如果升级失败,则必须能够回滚到以前的稳定状态。

How behaviours can be implemented

我希望到现在为止,我已经设法说服你,实际上不是轻量级进程和消息传递本身使 Erlang 非常适合构建可靠的系统。

充其量,人们可能会声称轻量级进程和 supervisors 是发挥作用的关键机制3,但我认为更诚实的做法是认识到 behaviours 提供的结构以及最终如何实现可靠的软件。

我还没有遇到任何其他语言,库或框架可以提供如此相对简单的构建块,这些构建块可以组合成像 AXD301 这样的大型系统(“超过一百万行的 Erlang 代码”,p. 167)。

这就引出了一个问题:为什么语言和库的设计人员不窃取 Erlang behaviours 背后的结构,而是复制轻量级进程和消息传递的思想?

让我们退后一步。我们之前说过 behaviours 是接口,并且许多编程语言都有接口。我们如何开始在其他语言中实现 behaviours ?

让我们从 gen_server 开始。我喜欢将其接口签名视为:

[](https://stevana.github.io/<#cb6-1>)Input -> State -> (State, Output)

也就是说,它需要一些输入,它的当前状态,并产生一对新的更新状态和一个输出。

我们如何将此顺序签名转换为可以处理并发请求的内容?一种方法是启动一个 HTTP 服务器,该服务器将请求转换为 Input 并将它们放在队列中,然后有一个事件循环从队列中弹出输入并将其馈送到顺序实现,然后将输出写回客户端响应。通过为每个 gen_server 命名并让请求在输入之外还包括名称,将此通用化为能够同时处理多个 gen_server 并不难。

可以通过允许注册回调到队列上某些类型的事件来实现 gen_event

supervisor 更令人感兴趣,一种简单的思考方式是:当我们从队列中向 gen_server 函数馈送下一个输入时,我们将该调用包装在异常处理程序中,如果它抛出,我们会通知其 supervisor 。如果 supervisor 未与 gen_server 在同一台计算机上运行,则会变得更加复杂。

我还没有过多考虑 applicationrelease,但鉴于配置,部署和升级是难题,因此它们似乎很重要。

Correctness of behaviours

仅仅写一篇关于窃取 Erlang 的文章似乎并不公平,即使这样做是正确的,所以我想以我们如何基于 Joe 和 Erlang 社区的见解来结束本文。

一段时间以来,我对测试很感兴趣。最近,我一直在研究 simulation testing distributed systems à la FoundationDB

简而言之,simulation testing 是在模拟世界中运行你的系统,其中模拟可以完全控制哪些消息何时通过网络发送。

FoundationDB 构建了自己的编程语言,或 C++ 的一种方言,其中包含 actors,以便进行 simulation testing。我们的团队似乎仅仅使用以下类型的状态机就能够取得很大的进展:

[](https://stevana.github.io/<#cb7-1>)Input -> State -> (State, [Output])

其中 [Output] 是一系列输出。

这样做的想法是,模拟器会跟踪按到达时间排序的消息优先级队列,它会弹出一个消息,将时钟推进到该消息的到达时间,将该消息馈送到接收状态机,为所有输出消息生成新的到达时间,然后将其放回优先级队列,进行冲洗和重复操作。只要一切都是确定性的,并且使用种子生成到达时间,我们就可以探索许多不同的交错并获得可重现的故障。它也比 Jepsen 快得多,因为消息传递是在内存中完成的,并且我们将时钟推进到到达时间,从而触发任何超时而无需等待它们。

我们过去常说这种状态机类型的程序是用 “network normal form” 编写的,并且推测每个可以通过网络接收和发送东西的程序都可以重构为这种形状4。即使我们有证据,“network normal form” 总是感觉有点随意。但是后来我读了 Joe 的论文,意识到 gen_servergen_statem 基本上具有相同的类型,所以我不再担心它了。因为我认为如果一种结构被不同的人发现有用,那么这通常表明它不是任意的。

无论如何,在 Joe 的至少一个 talks 中,他提到了正确实现分布式 leader election 有多么困难。

我认为,如果可以访问模拟器,则可以大大简化此问题。有点像我想象的,可以访问风洞会使制造飞机更容易。两者都可以让你在极端条件下(例如不可靠的网络或电源故障)测试你的系统,然后再在 “生产” 中发生。此外,此模拟器可以是通用的,或由 behaviours 参数化。这意味着开发人员可以免费获得它,而模拟器的复杂性则隐藏起来,就像 gen_server 的并发代码一样!

FoundationDB 是 simulation testing 工作的一个很好的例子,正如这个 tweet 所证明的那样,有人要求 Kyle “aphyr” Kingsbury 进行 Jepsen 测试 FoundationDB:

“haven’t tested foundation[db] in part because their testing appears to be waaaay more rigorous than mine.”

如果程序是用状态机编写的,则形式验证也变得更加容易。基本上,Lamport 的所有模型检查 work with TLA+ 都假设规范是状态机。最近,Kleppmann 也 shown 如何利用状态机结构进行(结构)归纳证明,以解决状态爆炸问题。

所以你已经拥有了它,我们已经完成了完整的循环。我们首先从 Joe 和 Erlang 的 behaviours 中汲取灵感,最终使用 gen_server behaviour 的结构来简化解决 Joe 过去遇到的问题。

Contributing

我已经开始研究许多相关的想法:

如果你发现其中任何有趣的东西,并想参与其中,或者你有任何评论,建议或问题,请随时与我联系。

See also