Maples7's Blog

了不起的 Erlang

Shared mutable state is the root of all evil
共享的可变状态是万恶之源

Pete Hunt

大概是从去年年底开始,我开始断断续续地了解 Erlang 这门语言。之所以会想要去学习它,是因为它跟我已经比较熟悉的任何一门语言都有着截然不同的设计理念,而且我了解得越深,就越为其设计思想所折服。

虽然这门语言的年纪早已不算年轻,而且似乎从未「大众流行」过,但在如今 CPU 多核化和云计算的时代背景下,Erlang 却焕发出了不一样的生机。

Erlang 在构建高可用服务上有其独特的优势,这与 Web 服务的要求不谋而合,其设计理念值得每一名 Web 后端开发工程师去了解。

在介绍 Erlang 的几个核心概念之前,需要先说明的是,本文所指的 Erlang 不仅仅指这门语言本身,而是指整个构建于 Erlang 虚拟机之上的技术体系,这就至少包括了 Erlang 语言、Elixir 语言及围绕它们所展开的技术模型的设计。

语言并不那么重要,重要的是蕴含在语言之中的设计理念和设计思想。

不可变状态

Erlang 是一种一次性赋值(single-assignment variable)的动态类型函数式编程语言。单次赋值意味着每个变量只能被赋值一次,如果试图在变量被赋值后改变它的值,程序会出错。

如果你初次接触单次赋值这个概念,可能会感到很难理解,但这并不是 Erlang 的原创。函数式编程的重要特点之一就是「不可变状态」(immutable state),Erlang 正符合这个特点。

实际上,在 Erlang 里,变量获得值是一次成功模式匹配操作的结果。如果你有着 C-like 编程语言的背景,你一定知道 = 表示的是一个赋值语句,而且大有各种入门书籍告诉你务必要与数学中的 = 符号区分开来,并很可能会举出 i = i + 1 这样在 C-like 语言中很常见,但在数学中明显不存在的式子(除非是在反证法中)。而在 Erlang 里,= 是一次模式匹配操作,Lhs = Rhs 的真正意思是:计算右侧(Rhs)的值,然后将结果与左侧(Lhs)的模式相匹配。这更多的像是回归了数学中 = 符号的本意。实际上,这也并不奇怪,函数式编程这种编程范型就是为了将电脑运算视为数学上的函数计算,同时还要避免使用程序本身的状态和易变的对象。

那为什么「不可变状态」会让编程变得更美好呢?

一个表面上明显的好处是让调试程序变得更简单。引起程序出错的常见原因就是变量得到了意料之外的值,为了防止意料之外的状态进入内部,我们可能不得不在 API 入口处进行参数校验,这意味着我们不得不写出大块的「防御式编程」的代码(这与后文要讲到的 “Let it crash” 特性一脉相承)。在 C-like 的语言里,变量可以被多次修改,因此每一个修改了变量值的地方都有可能是错误产生的地方,而在 Erlang 里,检查这样的错误只需要 check 一处即可。

另一个更深层次的好处是,「不可变状态」是使得程序运行不会产生副作用的保证之一,而没有副作用意味着可以让程序并行,这与后文的 Actor 并发模型又是一脉相承的。

Actor 并发模型

并发模型有很多种,大多数人最熟悉的还是 Java 所采用的基于锁和线程的并发模型,但也有其他的如 Golang 采用的 CSP 模型和 Erlang 的 Actor 模型,如果你对并发模型感兴趣,可以参阅《七周七并发模型》这本书。

如果是传统编程语言来为多核 CPU 编程,就不可避免的需要程序员去对付共享内存的问题。进而为了不破坏共享内存,自然而然地又产生了「锁」的概念,即在必要的时候给这些内存加锁来解决共享内存的问题,而且访问这些共享内存的程序在操作共享内存时还万万不能崩溃,否则很可能产生难以追踪且无法预料的错误(可以与后文的 “Let it crash” 特性对比)。这就跟《人类简史》中所说的「农业社会是人类构建错误社会形态的开始」的概念类似,我们很可能从一开始就构建了不正确的模型,从而只能滚雪球式的将错就错、一错到底。而在 Erlang 里,没有可变状态,没有共享内存,也没有锁,这使得要让程序并行变得很简单。

Erlang 的基本并发单元是进程(Process),它们是一些独立的小型虚拟机。但这与操作系统的进程并不相同,Erlang 的进程是是隶属于编程语言而不是操作系统的。一方面,这意味着 Erlang 是跨平台的,而且它在任何操作系统上都会具有相同的逻辑行为,使得编写可移植的代码完全不是问题;另一方面,这些进程是 Erlang 虚拟机自身实现的,所以它们非常的轻量,创建和销毁都十分快速,占用的资源也非常小,这意味着大量的进程可以并存。

当然,Erlang 的进程是不共享任何内存的,它们相互之间完全独立,而唯一的交互方式是消息(CSP 模型也是如此)。

到此为止,我想题记中的那句「共享的可变状态是万恶之源」就不难理解了。

Let it crash

在一般的传统语言里,我们总是被教导着要写「防御性代码」。那可能意味你代码中任何层次抽象出的每一个接口都应当检查传入的参数、可能意味着代码中需要大量的与业务逻辑交织在一起的错误检查代码。这是可以理解的,因为在基于锁和线程的并发模型中,编写多进程代码极其困难,多数程序都只有一个进程,所以如果这个进程随随便便就会崩溃,用专业的话说那就是程序的「鲁棒性」太差了。

而在 Erlang 里对于错误的处理方式与在传统顺序编程的处理方式完全不同。

在 Erlang 里,由于我们有大量的轻量级进程可供支配,所以任何的单进程的故障都不会那么重要。一般情况下,在 Erlang 里我们只需要编写极少量的防御性代码(注意,这里有个过犹不及的误解,在编程时我们不能把 Let it crash 当成一个可以被滥用的特性,也就是不要明知道某个地方可能 crash 却不予理睬),而可以把重点放在纠正性代码上。

在 Erlang 里,系统中的 Process 会划分为两个角色:一部分负责解决业务问题,另一部分则负责在错误发生时纠正错误。负责解决业务问题的部分会尽可能少的使用防御性代码,同时也会假设传入函数的所有参数都是正确的。而纠正错误的部分不会与解决业务的部分耦合在一起,所以这也意味着它们在大部分情况下是可以被复用的。

抽象来说,传统的错误处理方式与 Erlang 的错误处理理念实际上是对于问题发生时解决问题的两种不同思路。传统的方式强调尽可能的避免错误发生的可能,但有过编程经验的人基本都知道,要想避免所有错误发生的可能,这是不可能的。而 Erlang 处理错误的角度则不同,它是在错误已经发生后,不管这个错误是怎样的产生的(当然事后还是要具体分析原因的),或者是什么类型的错误,我们都把 A 进程发生的错误交由与之相关的 B 进程来处理,来试图纠正这个错误,使得整个系统恢复到一个我们已知的可控的状态。同时 A 进程直接 crash 掉就好。Erlang 把错误处理的注意力放在的错误发生后的清理工作上。这一点,其实与 Node.js 集群部署时的理念有类似之处,进程分为主进程和工作进程,但 Erlang 在这一点上做得更为彻底,而且在语言层面上就是这样设计的。

Let it crash 所带来的好处其实有很多,除了前面已经说到的不用写大量与业务代码交织在一起的防御性代码和在错误发生时可以不管原因统一处理之外,他还能使得发生的错误不至于被恶化造成不可控的更严重的后果,因为发生错误的 Process 已经被 crash 了。它也使得错误能够立即被上述的 B 进程处理,而且错误不会继续运行导致调试也变得困难。更深入地,它还使得我们在设计系统时可以有意识地把业务部分和错误恢复部分当成两个独立的问题来思考,这一点是非常符合 SoC(Separation of Concerns) 的要求的(我在上一篇博文《程序与人生》中也有提到这一点),即特定问题由特定专业分工明确的不同代码来解决。

GitHub 语言颜色

最后,说一个 tricky 的理由,在 GitHub 上代表 Erlang 和 Elixir 的颜色是紫色(Elixir 的颜色更深一点),这个已经暗示了这门技术的尊贵之处了。

哈哈,开个玩笑。

结语

希望这篇文章能真正引发你对于 Erlang 这门设计优秀的语言的兴趣与关注,并同时推荐你看《Erlang程序设计(第2版)》 这本书,它是由 Erlang 的设计和发明者、「Erlang 之父」Joe Armstrong 亲笔撰写的,所以在语言的诸多设计理念上都有非常清晰的讲解。

听说,你想请我吃糖?0.0