【译】Erlang 之禅(第一部分)

623 阅读22分钟
原文链接: github.com

Erlang 之禅

我在由 Genetec 组织的 ConnectDev'16 会上受邀演讲,这个是我所介绍部分的一个松散的文字记录(或者也可以说是较长的释义)。

Erlang 之禅

我假定这里的大多数人都没有使用过 Erlang,或许可能已经听说过它,或许就只是知道这个名字。在这种情况下,这个介绍只会涵盖 Erlang 中的高层次理念,使用这种方式来讲述的话即使你从未接触过这个语言,它也会对你的工作或副项目有所帮助。

就让它奔溃吧

如果你之前曾经了解过 Erlang,那你应该已经听过“就让它奔溃”的箴言。当我第一次遇到这句话时就在想这到底是什么鬼玩意。Erlang 应该对并发和容错有着很好的支持,但是我却被告知就让它奔溃吧,这和我想要这个系统发生的完全相反。这个主张让人不可思议,但是 Erlang 之禅却与之息息相关。

爆炸

在一定程度上,在 Erlang 中使用“就让它奔溃”这句话就和在火箭科学中使用“就让它爆炸”一样好笑。在火箭科学中“就让它爆炸”也许是你最不想发生的事 - 挑战者号空难就是一个鲜明的提醒。相对的如果你用不同的方式来看待这件事,火箭和它的整个推进机制都是要处理危险且会爆炸的可燃物(这是其中危险之处),但是它通过可控的方式来使用这种能量来驱动空间旅行或者把载荷送上轨道。

这里的重点在于控制;你可以尝试把火箭科学看作是一种如何正确驾驭爆炸的方式 - 或者至少是驾驭其中包含的能量 - 用于做我们想要的事情。从同一个角度看就让它奔溃也是一样的道理:它所有一切都是关于容错。这个想法并不是说让不可控制的错误遍布各处,而是把失败,异常和奔溃转化为我们可以使用的工具。

用火来灭火

回燃法和受控制的燃烧是真实世界中用火来灭火的真实例子。在我出生的加拿大萨格内-圣约翰,蓝莓田会例行以受控的方式被烧毁,以帮助支持和延续它们之后的生长。为了防止森林火灾,使用火来清除森林中已经枯萎了的部分是很常见的行为,这样森林才能被适当地监管和控制。这里的主要目的是用这种方式来去除可燃材料,这样真实的火灾就不能进一步蔓延。

在所有这些情况下,火对庄稼或者森林的破坏力被用于确保庄稼的健康或者是防止森林地区发生更大的无法控制的火灾。

我认为这就是“就让它奔溃”所想表达的。如果我们可以通过一种非常好的控制方式来拥抱失败,奔溃和异常,它们就不会是需要避免的可怕事物,而能成为构建大型可靠系统的强大基石。

进程 / 蜜蜂

所以这个问题就变成想出一个办法确保奔溃是促进者而不是毁灭者。对于这个,Erlang 使用进程作为它的基本棋子。Erlang 的进程是完全独立的,进程之间不共享任何东西。任何进程都不能访问其它进程的内存,或者通过修改它所操作的数据来影响它的工作。这样就很棒了,因为这意味着我们可以保证一个进程死亡只会把问题保留在进程内,从而为你的系统带来了非常强大的故障隔离。

Erlang 的进程也非常轻量,你就算运行成千上万个也不是问题。这里的想法是使用你__需要__运行数量的进程,而不是你__能__运行数量的进程。这里通常的比较就是说,如果你有一个面向对象的语言,该语言在任何运行时间中只能有 32 个对象,你很快就会发现使用这种语言构建程序会受到过多的限制而且是非常荒谬的。拥有许多小的进程可以确保在拆分事物中有更高的粒度,而且在一个我们想要利用这些失败力量的世界中,这很棒!

现在想象这样 Erlang 中进程是如何工作的可能会有些奇怪。当你写一个 C 程序时,你有一个大的 main() 函数做大量的事情。这个函数是你程序的入口点。在 Erlang 中就没有这样的东西。没有进程是这个程序指定的主进程。每一个进程都运行一个函数,这个函数在对应单个进程中扮演着 main() 函数的角色。

我们现在有一群蜜蜂,但是如果它们之间不能通过任何方式沟通,那可能就很难管理它们加固蜂巢。蜜蜂是通过舞蹈进行沟通,而 Erlang 的进程是通过消息传递。

消息传递

在并行环境中,进程间消息传递是最直观的通信形式。这是我们工作过最古老的通信方式,从我们开始写信通过邮差骑马来送到目的地的日子,到这个幻灯片中展示的拿破仑信号塔。在这种情况下,你只需要带着一群人进入塔楼,给他们一个消息,他们就会挥舞旗帜以比骑马更快的方式把消息传递到远方,但这很容易让人疲劳。最终这些都被电报取代了,之后又被电话和收音机取代了,现在我们拥有所有这些很棒的技术来传递消息,并且可以传的很远、很快。

所有这些消息转递方式特别是过去的其中一个关键在于它们都是异步的,而且传输的消息都会被复制。没有人会为了等寄信的信使回来而在门廊上站好几天,也没有人(至少我认为)会坐在信号塔中等消息的响应发回来。你只是会把消息发送出去,然后回去做你的日常工作,最终会有人告诉你有回信了。

这很合理因为如果另一边没有回应,你不会什么事都不做而是傻傻的在门廊前一直等到死去。相反,如果你死了,消息交流通道另一边的收信人也不会奇迹般的马上收到或改变你的消息。数据__应该__在被发送出之前被复制一遍。这两个原则确保通信过程中的失败不会导致一个损坏或者无法恢复的状态。Erlang 实现了这两个原则。

为了能阅读消息,每个进程都有一个邮箱。任何一个进程都可以写消息到一个进程的邮箱,但是只有拥有这个邮箱的进程能查看它。这些消息会默认以它们到达的顺序被读取,但是也有可能通过模式匹配[我们在先前的演讲中讨论过这个]之类的特性来让进程临时只关注一种,从而驱动不同优先级消息的执行顺序。

链接 & 监视器

你们之中的部分人会注意到我刚才所提到的一些事项;我一再重申隔离和独立性是伟大的,这样一个系统的组件就可以在不影响其它组件的情况下死亡和奔溃,同时我也提到了在多个进程或者代理之间进行交流。

每当有两个进程开始交流,我们在它们之间就创建了一个隐性的依赖。系统中会有一些隐性的状态将它们绑定在一起。如果进程 A 向进程 B 发送了一个消息,但是 B 进程已经死亡而没有响应 A,那么 A 进程所能做的要么就是永远等着,要么就是在一段时间后放弃和 B 进程的交流。后者是一个有效的策略,但是它也是一个十分模糊的策略:它并不知道远端的那个进程是已经死了还只是处理时间比较长,解除绑定之后远端进程的消息才发送到你的邮箱。

相反,Erlang 为我们提供了两种机制来处理这种情况:监视器和链接

监视器所做的全部就是作为一个观察者,一个攀缘植物。你决定去留意一个进程,如果该进程出于任何原因死亡了,你就可以在你的信箱中获取到关于这个的消息。你就可以对此作出反应并且通过你新发现的信息来做出决策。其它的进程永远也不会知道你对它做了什么。如果你是一个观察者或者关注对等进程的状态,那么监视器可以是非常棒的工具。

链接是双向的,建立一个链接就会将其所相连的两个进程命运绑定。当其中一个死亡时,任何与之链接的进程都会收到退出信号,这个退出信号会杀死这些进程。

现在这就真变得很有趣了,因为我们使用监控器来快速地检测到失败,而且我还能使用链接作为一个架构构造够把多个进程绑定在一起作为一个共同的失败单元。无论何时我独立构建的模块开始有相互之间的依赖,我能够开始把这些依赖写入我的代码中。这很有用,因为这样就可以防止我的系统意外奔溃进入到一个不稳定的局部状态。链接是一种工具,它可以让开发人员确保当其中一件事失败时,最终会完全失败并留下一个空的白板,而不会影响这个运行中没有牵涉到的组件。

在这个幻灯片中,我选了一张登山者通过绳子连在一起的照片。现在如果登山者之间只有这个链接,他们恐怕会陷入一个糟糕的境地。任何时间你队伍里的一个登山者滑倒,队里的其他人也会马上滑倒死去。这并不是一个做事情的好办法。

相反,Erlang 可以让你指定某些进程是特殊的,这些进程会使用 trap_exit 选项作为标记。然后他们可以接受链接发送过来的退出信号并且将它们转化为消息。这可以让它们从错误中恢复过来并且可能启动一个新的进程来做之前死掉进程的工作。不像登山者那样,这种特殊的进程不能阻止一个对等进程奔溃;这是这个对等进程的责任来确保自己不会挂掉,比如说通过 try ... catch 表达式。一个收到退出信号的进程还是没有办法进入另一个进程的内存然后保存这些内存,但是它可以避免因为这个而死去。

这成为了实施监督者的关键特性。如果你从来没有听说过这些,我们很快就会接触到这些。

抢占式调度

在进入监督者这部分之前,我们仍需要一点调料才能成功地烘焙出一个系统,这个系统利用奔溃来获得自身的优势。其中之一与进程如何调度有关。对于这方面,我想提到的真实世界例子是阿波罗 11 号的登月计划。

阿波罗 11 号是在 1969 年的登月任务。在这个幻灯片中,我们看到 Buzz Aldrin 和 Neil Armstrong 的登月舱,这张照片我认为是 Michael Collins 拍的,在这次任务中他留在了指挥舱中。

在他们登月的途中,登月舱将由 Apollo PGNCS(主要指挥,导航和控制系统) 所引导。这个指导系统有多个任务在上面运行,它们的运行周期数是被仔细斟酌过的。NASA 也指出所有任务运行只占用了处理器 85% 的容量,还剩下 15% 的空间。

现在,为了在应对需要终止计划的情况,宇航员们需要制定一个完善的备份计划。于是他们还用处理器运行了一个交会雷达以防万一它能派上用场,这会用掉 CPU 所剩容量中的一大部分。当 Buzz Aldrin 输入指令时会出现大量关于溢出和容量耗尽的报错信息。如果控制系统因此失控,它将无法正常工作,并且害死两名宇航员

这主要是由于雷达存在已知的硬件错误会使它的运行频率和指挥计算机不匹配,这就导致它窃取了比其本来所应该有的更多的运行周期。当然 NASA 的人也不是白痴,在这种关键的任务中他们重用了他们所知道之前用过很少发生错误的组件,而不是研发一个新的技术。但是更重要的是,他们设计了优先级调度。

这意味着即使因为这种雷达或者输入命令导致处理器过载的情况下,如果它们的运行优先级与性命攸关的事情相比很低,那么这些任务将被杀死,从而把 CPU 运行周期给真正迫切需要它的任务。那是在 1969 年;在今天仍然有大量的语言或者框架给你的__只是__合作调度,除此之外别无他有。

Erlang 并不是一种用于构建生命攸关系统的语言 - 它只遵循软实时时间约束,而不是实时时间约束所以在这些场景中使用它并不是一个好主意。但是 Erlang 为你提供了抢先试调度以及相应的进程优先级。这就意味着作为一个开发者或者系统设计人员,你并不__需要__去关心确保每个人都仔细统计了他们所有组件的(包括使用的库)CPU 使用量以免使整个系统变慢。他们并没有这个能力。而且如果你需要一些重要任务在它必须运行时总能运行,你也能实现这个。

这似乎并不是一个大的或者通用的需求,人们还是能通过协作式的并发任务开发真正成功的项目,但是它确实十分有价值,因为它可以使你免受他人和你自己错误的影响。它还为像自动负载平衡,惩罚和奖励好或者坏的进程或者给予需要做大量工作的进程更高优先级提供了实现机制。这些东西最终都会使你的系统更好的适应生产环境负载和处理意外事件。

网络意识

我想讨论获得优雅容错性的最后一个调料是网络意识。在我们开发的任何需要长时间运行的系统中,让多台计算机快速的运行这个系统是一个先决条件。你不会想坐在由钛门锁在里面的金色机器旁边,却不能忍受任何方式引起的中断影响到你的用户。

所以最终你需要两台计算机,这样一台机器可以在另一台破环时继续提供服务,如果你想要在损坏计算机还是你系统一部分的时候部署,那么你或许就需要第三台。

这个幻灯片中的飞机是 F-82 双生野马,这是一架在第二次世界大战期间设计的飞机,用于护送大多数其它战机无法覆盖范围内的轰炸机。它有两个驾驶舱,这样随着时间推移,当一个驾驶员累的时候另一个可以互相替换;在一些情况下他们也能相互配合,其中一个人飞行的时候,另一个可以操作雷达作为拦截者的角色。现代飞机仍然在做类似的一些事情;他们有数不清的备用方案,经常有机组人员在飞行期间的途中睡觉,以确保总有人能时刻警惕准备好驾驶飞机。

当这个说法用于编程语言或者开发环境,它们中大多数的设计都完全忽略了分布式,尽管人们都知道如果你写的是服务器栈,那么你需要的就不止一台服务器。然而,如果你要使用文件,这些变成语言就会有标准库帮你完成这些事情。大多数语言更进一步就是给你一个套接字库或者 HTTP 客户端。

Erlang 意识到了分布式这个事实并且为你提供了一个实现,这个实现是有文档记录而且透明的。这可以让人们为故障转移设或者是接管奔溃的应用配置所想要的逻辑从而提供更高的容错性,甚至可以让其它语言假装它们是 Erlang 的节点来构建多边形系统。

就让它奔溃吧。

所有这些就是 Erlang 之禅食谱的基本调料。这整个语言的目的在于获得崩溃和失败,并使它们如此易于管理,从而有可能将它们当作工具。就让它崩溃开始有道理起来了,这里看到的原则大部分都是可以在非 Erlang 系统中作为灵感重用的。

如何将它们组合在一起是下一个挑战。

监管树

监管树描述的是如何实施你 Erlang 程序的架构。它们源自于一个简单的观念,有一个监管者,它唯一的工作就是启动进程,关注进程的运行,然后在它们运行失败时重启它们。顺便提下,监管者是 ‘ OTP ’ 的核心组件之一,它是被广泛使用的开发框架,名字叫做 ‘ Erlang/OTP ’。

这样做的目的是创建一个层级结构,在这个结构中所有重要必须稳定运行的东西越接近树的根部,而所有易变或正在转移的部分则会积累在叶子部分。事实上,这就是现实生活中大多数树木的样子:树叶不是固定的,树上会有很多树叶,在秋天它们都会飘落下来,而这个树仍然活着。

这意味着当你构建 Erlang 程序时,任何你觉得脆弱的允许运行失败的进程应该处于这个层级的更深处,而稳定而且可靠性要求很高的应该移到层级上面。

监管者们

监管者是通过使用链接和捕获退出来实现这个功能的。它们的工作从一次启动它们的子进程开始,从上往下,从左到右。只有当一个子进程完全开始之后它才会返回上个层级开始创建下一个子进程。每一个子进程都会被自动链接。

每当一个子进程死亡时,有以下三个策略可供选择。第一个策略在这个幻灯片中就是 ‘一对一’,通过替换死去的子进程来实现。这是用于监管者的所有子进程相互之间都独立时的策略。

第二个策略是‘一即是全部’。这个策略用于子进程之间存在相互依赖关系。当它们中的任何一个死去时,监管者就会在把它们全部重新启动之前把其它所有子进程都杀掉。当失去一个特殊的子进程会使其它进程陷入一个不确定的状态时,你就可以使用这个策略。让我们想象三个进程进行一个对话,该对话以投票结束的。如果在投票过程中其中一个进程死亡,那么可能我们并没有编写任何代码来处理这个问题。用一个新的替换死去的进程会在表格上带来一个新的同伴,而其中的所有进程完全不知道接下来该做什么。

如果我们没有真正定义当一个进程在投票过程中造成严重出故障时要怎么做,那么这种不一致的状态可能是有危险的。相比于这个,杀死所有的进程可能会更安全,然后从已知稳定的状态重新开始。通过这样做我们就可以限制错误的范围:在错误发生时早点及时奔溃会比慢慢且长时间毁坏数据要更好。

当进程之间根据它们的启动顺序有依赖关系时通常可以用最后这种策略。它的命名叫做‘一个所剩下的’,当一个子进程死亡时,在之后它后面启动的进程会被杀死。然后进程就会像之前预期的那样重新启动。

每个监管者还额外有可配置的控制和忍耐级别。一些监管者可能中断之前每天只能忍受一个故障,而其它的或许可以每秒承受 150 个故障。

Heisenbugs

在我提到监管者之后大家通常都会提及的评论就是“但是如果我的配置文件就是错的,重启并不能解决任何问题!”。

这完全正确。重启有效的原因在于生产环境系统中所遇到的错误性质。为了讨论这个问题,我必须提及 Jim Gray 在 1985 年提出的 ‘ Bohrbug ’ 和 ‘ Heisenbug ’ 这两个术语(我建议你尽可能多读下 Jim Gray 的论文,它们都写的很棒!)。

基本上来看,一个 bohrbug 是一个稳定的,可观察的而且可复现的错误。它们倾向于可以被开发者容易地推测出问题的原因。相反 Heisenbug 具有不可靠的行为,它不会在确定的条件中出现,而且如果只是采取简单的行为尝试去观测这些问题时它们可能会被隐藏起来。比如说在系统中使用每个操作都会被循序执行的调试器时,并发错误就无法查找出来。

Heisenbugs 是这些在一千次,百万次,十亿次或者万亿次错误中才会出现一次的令人讨厌的错误。当你看到有人打印了一页又一页的代码以及在它们中填上一大堆标记时,你就知道他已经处理这种类型的错误有一段时间了。

定义了这些术语之后,让我们来看看它们的出现频率应该是多少。

在生产环节中查找错误就是这么简单

在这里,我把 bohrbugs 列为可重复的错误类型,把 heisenbugs 列为暂时的错误类型。

如果你在你系统的核心功能中有 bohrbugs,那么当这个系统到达生产环境之前它们应该能很容易被找出来。通过可重复性,以及这类错误通常在程序运行的关键路径上,你应该迟早会遇到它们,而且在到达下一个阶段之前修复它们。

那些发生在次要的,更少使用的功能上的错误,更像是提醒和错过的事。每个人都承认的是修复软件中的全部错误是一件艰苦的战争,为此得到的收益是递减的;随着你继续编写代码,除去其中的小缺陷可能要花越来越多的时间。通常情况下,这些次要功能往往会收到较少的关注,不仅因为较少的客户会使用它们,还因为它们对满意度的影响并没有那么重要。或者也许它们只是要晚些时候被安排修复而且把时间表拖后最终会降低开发人员处理这个的重要度。

在任何情况下,它们在一定程度上都挺容易找到的,我们只是没有时间或者资源来做这件事。

Heisenbugs 几乎不可能在开发过程中发现它们。像形式证明,模型检查,穷举测试或者基于属性的测试这些很棒的技术可能会增加发现其中一部分或者全部问题(取决于所用方法)的可能性,但是坦白讲,除非手头上的任务是非常关键的,否则我们中很少有人使用这些技术。在数十亿次中出现一次的问题就需要大量的测试和验证才能发现,而且如果你已经看到过这个错误,那么很可能没那么好运气再次产生这个错误。

更多内容请见本文第二部分:Erlang 之禅:第二部分


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏