这是发表在《程序员》杂志第9期上的一篇简化版Erlang介绍文章。有人提起,于是就转发到这里来了。

Erlang 是程序界的幽灵船,它凭空出现,没人知道它从哪儿来,是怎么回事,也无人知晓它来做什么,要往何处去。
——江湖传言

相信对很多人来说,上面的说法颇为传神的捕捉到了围绕在 Erlang 周围的神秘气息。我们越来越多的见到这个名词,或者道听途说有关它的种种神奇。但每次试图一探究竟,总会在面对它那“怪异”的代码时感到气馁,而那些“不合情理”种种限制,则更加让人费解。很难想象,为什么有人会发明这么怪异的语言,而它为什么又会受到广泛的关注,更别提要如何用这么“别扭”的语言来编写程序了。本文试图为这些问题提供一些线索,但真正的答案其实一直深埋在你的心底,象往常一样,也要靠你自己的思考和努力才能获取。

从哪里来?

话说早在 1981 年(20 多年前了),瑞典电信巨头 Ericsson 就成立了 CSLab 实验室。其目标之一就是“为未来的软件系统提出一些新的架构(architecture)、概念(concepts)和
结构(structure)”,具体来说,也就是为“未来的电信设备”准备技术。到 1985 年 Joe Armstong 老爷爷( Erlang 的创始人之一)加入 CSLab 实验室,第二年,他开始着手进行以后被称作 Erlang 的技术的研究工作。这项工作最初的目标被设定为 Prolog 语言增加对并行进程的支持。到了 1987 年,一切进展顺利,雏形之中的 Erlang 被以 Prolog 扩展的形式实现了出来。而在这一年的晚些时候 Erlang 这个名称(来自于瑞典的数学家 Erlang,而不是所谓的 ERicsson-LANGuage )也第一次被人提出。此时的 Erlang 尚未成形,只有名字没有语法。

此后 Ericsson 的一个小组开始用 Erlang 来实现一个交换机原型,而 CSLab 的 Erlang  小组则忙于提高语言的性能。到了 1990 年,这些尝试终于有了成果:Erlang 的许多思想在原型项目中得到印证并被确定下来(这就是日后 Erlang 以及 OTP 的核心思想),而 Erlang 也有了自己的虚拟机(不再是 Prolog 的实现)和自己的语法(不再是 Prolog 的方言)。我们可以说,在这个时刻,作为一门计算机语言的 Erlang 正式诞生了。此时的 Ericsson 看到了 Erlang 的潜力,并于 1993 年成立 Erlang System AB 子公司来向合作伙伴们推广 Erlang,同年还发布了 Erlang 的第一个商用版本。

1995 年是 Erlang 的幸运年,知名的 AXD301 项目启动(它是迄今为止最为庞大的采用 Erlang 以及最为庞大的采用函数编程语言的商用项目),其目标是接替失败了的 AXE-N 项目,构造下一代的交换机。这是 Erlang 被发明之后的第一次大规模商用,此后 Erlang 团队一直围绕着这个多达 1.7M 行的庞大项目开展工作,整个过程一直持续到 1998 年 AXD301 项目成功才告一段落。这一阶段的主要成果是 OTP(Open Telecom Platform) 库的成熟。1998 年 Erlang 的第一次好运宣告结束。虽有 AXD301 项目的大获成功,但 Erlang 仍被 Ericsson 禁止在新的项目中使用。受到挫折的 Joe Armstrong 和 15 位原 Erlang 团队的同事黯然离开,成立了 Bluetail AB ,继续 Erlang 的事业(这个公司后来辗转被北电收购,而 Joe Armstrong 本人现在又回到了 Ericsson,中间的过程尚不为人知)。

Ericsson 放弃 Erlang 的原因至今都是众说纷纭,其中一个说法是“Ericsson 想成为一个软件技术的消费者而不是一个软件技术的生产者”,另一个说法则是“Ericsson 希望回归电信业界的标准”(即,回归C/C++),但这两个说法都流于太过冠冕堂皇,让人无法信服,整件事情始终透着一股“公司政治”的味道,事件的真正的原委我们只能猜测,或许只有当事人才能解释清楚。

从事后诸葛的角度来说,此次弃用事件,可谓影响深远。一方面,这当然是 Ericsson 的昏招(想想 Sun 和 Java),但何尝又不是程序界的幸事?若无此事,恐怕也不会有 1998 年底 Erlang 的开源,假若果真如此,那今天我们大概也无法得见作为 Ericsson 秘密武器的 Erlang 。而另一方面,此次事件也大大打击了 Erlang 团队的士气,使得 Erlang 的发展一度停滞,直到今天,事件的一些影响仍在持续(比如,在此之后发展出来的一些库和工具仍然散落在历史的秘辛之中,不为人知)。不过对于 Joe 本人而言,结果并不全是坏事,至少他所创立的 Bluetail AB 公司受到投资者追捧,历经多次收购,应是大有收获。

从 2001 年到 2004 年,业界遭遇 .COM 泡沫的崩溃(史称第一次互联网寒冬),受到波及的 Erlang 只能“苟延残喘”地过日子,好在项目和人才并没有随着泡沫的崩溃而流失。相反,挨过了冰河期的 Erlang 渐渐又恢复了活力。时间到了 2006 年, Erlang 实现了支持 SMP 架构的虚拟机,在多核冲击波来临之前,它已为此做好了准备。此后的事情就不再是掌故,而是当下的现实。当多核芯片和问题一起最终被摆上软件开发人员的桌面,对性能的焦虑,让“不走寻常路”的 Erlang 终于走到了聚光灯下。

这就是 Erlang 从过去一路跌跌撞撞走到现在的由来。

是怎么回事?

破除疑惑的不二法门就是——动手。更何况,安装并让 Erlang 跑起来,根本就不是什么难事。

Erlang 几乎可以运行于从手机到大型机的一切计算机系统。在 Erlang 的官方网站,能够下载到它的最新版本(源码或二进制发布版)。对于 Windows 用户而言,最为简便,下载回最新的安装包(.exe文件),运行,标准的安装步骤,一路 next 即可。基于 Debain 的 Linux 系统,也有二进制的发布版可以安装,在命令行输入以下命令就可以了。

$ apt-get install erlang

apt 安装的 Erlang 版本稍微有一些延迟,与之同样便利以及同样延迟的还有 Mac OS 下的 MacPorts 系统。若想使用时下最新的版本,可以采用源码编译的方式来进行安装。在任何平台下,都可以用这种方式来安装 Erlang 。一个典型的从源码安装的步骤是这样的:

$ tar -xzf otp_src_R11B-4.tar.gz
$ cd otp_src_R11B-4
$ ./configure
$ make
$ sudo make install

安装完毕即可启动 Erlang 的 Shell 开始感受,如果用过 ruby 或 python ,那么对于这样的交互式 Shell 你一定不会感到陌生。简单的 hello world 是这样的:

$ erl
Erlang (BEAM) emulator version 5.6.3 [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.6.3  (abort with ^G)
1> io:format(”~s~n”, ["Hello World!"]).
Hello World!
ok
2> q().
ok

* Erlang 中所有语句的执行都会产生结果,比如 io:format 函数是进行格式化输出,它的执行结果是 ok , q 函数是退出 Shell ,它的执行结果也是 ok 。
* ok 是 Erlang 中的值,它属于Atom (原子)类型,Atom 类型正如其名,乃是 Erlang 中“不能继续分割的最小粒子”,在概念上可以对应于常量或枚举类型。

准备随便逛逛的同学要注意了,因为此时的你马上就会碰到让你“水土不服”的“Erlang 几大怪”。

变量不能改

这条很简单,在 Erlang 中有规定:所有的变量都不可以二次赋值。是的,这么简单的操作也是不被允许的,比如:

1> X = 1.
1
2> X = 2.
** exception error: no match of right hand side value 2

* 上述的 X 是变量, Erlang 中的变量必须以大写字母开头。
* Erlang 中的变量是动态类型的,也就是说它可以“装下”任何类型的数据,因而也就无须类型声明。

所有第一次碰到这个规则的人,脑子里都会冒出无穷的疑问。反应大多类似于:“疯了,连变量的值都不能改!”或者“我晕,要怎么写程序?”。其实,这并不是 Erlang 的怪癖,很多其他的 FP (函数编程)语言都有相同的特性。而“变量不能改”也并不意味着“不能表达变化的状态”。 Erlang 会通过其他的方式来表达,其中的一种就是递归,确切的说,就用“新的状态”作为参数,让函数进入下一次的递归。

没有循环

在 Erlang 中,翻遍手册也找不到你所熟悉的 for 循环和 while 循环,不仅如此,根本也找不到任何形式的循环语句。我知道你在想:“没有循环结构的程序,也能算得上是程序?”。跟上面一样,“没有循环语句”也不意味着“不能实现循环结构”。Erlang 用递归来表达循环的逻辑,比如这样:

-module(ex1).
-export([fac/1]).

fac(1) -> 1;
fac(N) -> N * fac(N-1).

* Erlang 的程序构造单位是 module (对应 Java 中的 class)。
* Erlang 用 export 声明函数函数可以在 module 外部调用(对应 Java 中的 public,所不同的是只能公开函数)。
* fac/1 表示有一个参数的 fac 函数,也就是说,如果另外还有带两个参数的 fac 函数,可以用 fac/2 表示。基本上,这是两个完全独立的函数,只是恰好名字相同而已。
* fac/1 函数有两个子句(可以理解为,两个逻辑分支),子句之间用分号分隔,用句号结束整个函数。Erlang 有“模式匹配”,也就是说在调用 fac 时,它会检查参数,如果参数值为 1 则执行第一个子句,否则,会执行下一个子句。可以想象,若有 N 个子句,这样的参数检查和流程分支过程会一个子句一个子句的逐一进行。

将这个文件命名为 ex1.erl ,然后这样编译和运行这个程序:

$ erl
Erlang (BEAM) emulator version 5.6.3 [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.6.3  (abort with ^G)
1> c(ex1).
{ok,ex1}
2> ex1:fac(10).
3628800

* ext1:fac(10) 意味着使用参数 10 来调用 ext1 模块的 fac 函数()。
* c 是 Erlang Shell 默认引入的函数(也就是说,不用指定模块),用来编译源文件。

用这样的代码来计算阶乘,可谓极简。但“常识”告诉我们,递归太深常常意味着堆栈溢出。象上面这样的递归终会带来问题(比如用一个非常大的 N 来调用),在 Erlang 中也是一样。若要依赖递归来实现循环,上面的代码必须重构,变成这样:

-module(ex2).
-export([fac/1]).

fac(N) -> i_fac(1, N).

i_fac(Acc, 1) -> Acc;
i_fac(Acc, N) -> i_fac(N * Acc, N - 1).

将上面的代码保存为 ex2.erl 再来编译和运行:

$ erl
Erlang (BEAM) emulator version 5.6.3 [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.6.3  (abort with ^G)
1> c(ex2).
{ok,ex2}
2> ex2:fac(10).
3628800

我们看到,在不改动接口的前提下,重构实际上只是引入了一个名为 Acc 的新参数,它表示“上一次递归的运算结果”。但这个重构使得 i_fac 对于自身的调用(也就是递归)成为了“函数末尾的调用”,而且新的递归也不需依赖于堆栈之中的任何数据(因为上次计算的结果已经被重构成了函数的一个新参数)。满足这种条件的递归被称作“尾递归”,在编译层面,尾递归可以被优化成一个简单的跳转语句,因而,相比之前的递归,已经消除了堆栈溢出的风险。

现在回头看一下,没有变量,也没有循环语句,我们同样实现了阶乘的计算,你能说这不是一个程序么?也许你会认为“真正复杂的应用程序”,必然会有一些无法用上面这种方式来表达(或者至少是表达起来非常别扭)的“复杂业务逻辑”。我认为这是“很有项目经验”的想法,如果只有这些设施,确实不够用。我们看到 Erlang 从程序员手中拿走了许多东西(这些都是我们的心爱之物),基于常识判断,它必须要提供一些新东西,否则就会变成一个只有脑残人士才会使用的东西。没错,不知道你准备好了没有,但它确实提供了一大堆新东西。在这些新东西中,最为核心的概念就是进程。进程?没错,就是进程,甚至都不是听起来更加拉风的线程或者纤程。但,名虽一样实则不同,此进程非彼进程也。

一切皆是进程

习惯了 OOP 的“一切皆是对象”,猛然听到一个句式相同的“一切皆是进程”,难免会有一种相当复杂的感觉(怕怕)。而看到与 OOP 相对应的 COP (Concurrent Orient Programming 面向并发编程),如果觉得“李逵碰见了李鬼”,恐怕也不能算是大惊小怪。Erlang 是以进程为核心概念的系统,它的进程是自己实现的“超轻量级”进程,而非操作系统所提供的大家伙(据说它们也准备减肥了)。老实说,对于“超轻量级”这种“销售用语”大部分人都已经有了免疫,未免大家睡着,要来点感性认识振奋一下才好—— Erlang 的系统能轻松支持十万数量级的进程数目,这并不需要动用大型机,任何一台普通 PC 就够,退休在家的 486 都可以。这么说吧,在 Erlang 这样的 COP 系统里,我们可以象以往在 OOP 系统中使用对象一样,只管放心大胆的使用进程就对了。

回到“真正复杂的应用程序”,对于那些“复杂业务逻辑”,在 COP 中的做法其实也和 OOP 中的差别不大,无非是拆开了装装上了拆的那套老把式。吃透业务逻辑,将其分解为若干个进程(而不是对象),再把这些进程装起来,如果没出什么岔子就可以收工回家了。谈到组装,在 Erlang 里组装进程不用焊锡(或者螺丝),而是用消息。也就是说,一个进程就像是一个黑漆漆的集成块,只能看到那几个脚(接口),你永远也不知道那块黑塑料里面到底发生了什么,你所能做的就是把这些脚和其他的元件焊在一起(让它们能够发送和接收消息)。实际上,消息是进程之间通信(协作)的唯一方式。换句话说,进程之间并不共享任何东西,它们之间只能互相发送消息而已。说了一堆虚的,现在来点实际的,让我们来动手实现一个微小的“业务单位”——计数器。

-module(counter).
-export([start/0, stop/1]).
-export([inc/1, dec/1, reset/1, current/1]).

start() ->
    spawn(fun() -> loop(0) end).

stop(Pid) ->
    Pid ! stop.

inc(Pid) ->
    Pid ! inc.

dec(Pid) ->
    Pid ! dec.

reset(Pid) ->
    Pid ! reset.

current(Pid) ->
    Pid ! {current, self()},
    receive
        Any -> Any
    end.

loop(Count) ->
    receive
        stop -> ok;
        inc -> loop(Count + 1);
        dec -> loop(Count - 1);
        reset -> loop(0);
        {current, Pid} ->
            Pid ! Count,
            loop(Count)
    end.

* spawn 就是启动一个新的进程,新进程执行其中定义的匿名函数(执行完所有的流程,新进程会自己退出),新进程的标识作为 spawn 的结果被存在变量 Pid 之中。
* fun() -> loop(0) end 定义了一个匿名函数,这个函数的唯一任务就是用参数 0 来调用 loop 函数。你最好习惯这种语法,因为这在任何一个 FP 之中都是司空见惯的东西(包括 JavaScript)。
* Pid ! stop 的意思是向 Pid 所标识的进程发送消息,消息的内容是 Atom 值 stop。在 Erlang 中,只要你知道一个进程的标识就可以向它发送消息。你会看到 counter 的各个“业务接口”其实就是向进程发送各种各样的消息。
* receive 的意思是接收消息。每一个进程都有自己的消息队列。如果消息队列为空, receive 语句会等待消息。而如果收到了消息,那么和函数的子句一样,它会对接收到的消息逐一进行“模式匹配”(在 Erlang 中模式匹配无处不在,是一种更为便捷的分支结构),如能匹配,则将这个消息从队列中删掉,并执行其后的流程;如果无法匹配任何模式,那么这条消息就不会被处理(没准会在执行到另外一个接收语句的时候能够匹配得上呢),继续对下一条消息从头进行匹配。象 Any -> 这样的模式能够匹配任何消息(别忘了,Erlang 是动态类型语言,它的变量能装下任何类型的数据)。
* 在 loop 的模式中,如果匹配了 stop ,则 loop 函数会返回 ok ,除此之外的其他模式,都是在用新的 Count 值继续递归调用自己,也就是说并未返回。对于由 spawn 语句生成的新进程来说,如果 loop 返回,也就意味着它执行完了所有的流程,会自行退出。

将上面的代码保存为 counter.erl 编译运行之:

$ erl
Erlang (BEAM) emulator version 5.6.3 [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.6.3  (abort with ^G)
1> c(counter).
{ok, counter}
2> Pid = counter:start().
<0.32.0>
3> counter:inc(Pid).
inc
4> counter:inc(Pid).
inc
5> counter:current(Pid).
2
6> counter:reset(Pid).
reset
7> counter:current(Pid).
0
8> counter:stop(Pid).
stop

运转良好!COP 和 OOP 的版本,在实现方式上大有不同,但对外的接口却几乎一样。实际上,上述代码之中的 loop 就是其中的一种“状态变化”机制。它的参数 Count (也就是当前的计数值)在每次递归调用时都会“更改”,并在这些递归调用之中得以“传递”。如果我们将视角拉开一点,可以把这个 counter 看作是“复杂应用程序”当中的一个部件,你可以想象,还会有若干个完成其他各种功能的部件,它们彼此就是这样,通过“消息”来组装。这种组合方式,可以构造任何复杂程度的系统。

为什么要这么做?

上面我们看到 Erlang “绕了一个大圈子”来实现“不过是相同的功能”,而且相比 OOP 的实现,似乎更为繁琐。那么,这么做的目的又是什么呢?要回答这个问题,我们不妨看看一段简单的代码(Java 代码):


public void some_operate(int x){
  this.value = something_slow(this.value, x);
}

假设在某一个时刻,线程 1 进入这个函数准备计算 something_slow,而与此同时,线程 2 刚刚算完 something_slow,准备更新 this.value 的值。那么,在线程 1 执行完 something_slow 时, 此前线程 2 的计算结果就会被覆盖。这是标准的“线程互斥”,解决方法也很“标准”,那就是加锁(枷锁?):


public void synchronized some_operate(int x){
  this.value = something_slow(this.value, x);
}

加锁的要义就是——好东西大家共享,但要排队,只能一个个的来,否则就会乱了套了。暂且不谈多个锁会带来多么复杂的互锁和死锁,将会累死多少脑细胞,单就效率而言“一个线程在做操作,其他的就都得等着”,尤其是在多核 CPU 泛滥的时代,这种“高档硬件当低档硬件使”的做法多少有点说不过去。好吧,我承认,有的“业务逻辑”本身就是顺序排队的,但那是在“业务层面”的(用一个进程来作排队不就结了),这并不意味着每一行代码也都必须遵循这种毫无必要的限制。现在让我们回到锁的源头,问问自己为什么需要锁?因为我们允许“共享和更改”某个东西。如果不允许共享,也不允许更改,那么锁岂不是也就失去了存在的必要么?现在再来看看之前 Erlang 让我们水土不服的限制:“变量不能更改”以及“进程之间并不共享任何东西”。悟了吧(没悟的去找个榔头再敲一下)。绕这么大的圈子,不就是为了绕开锁么。

用来做什么?

绕开了锁,请出了进程。费了牛鼻子的劲整出来的 Erlang 究竟可以做点什么呢?看好了:

  • 将一个任务的各个环节(针对流程切分)分给不同的进程执行,各干各的都不耽搁,此之为并行。
  • 将一个任务的各个环节(将流程切分到多台机器上)分给不同机器上的进程执行,当负载增加时,只需加入新的机器即可满足需要,此之为分布。
  • 将某个海量数据的处理任务(针对数据切分)分给多个进程执行,每个进程处理一部分,进程越多效率越高,此之为并发。
  • 将某个海量数据的处理任务(将数据切分到多台机器上)分给不同机器上的进程执行,在其中的一部分失效时,整个服务不受影响,此之为容错。
  • 上述由多个进程组成的系统,在多核系统上运行,在 CPU 数量增加时,程序执行的性能也同时自动获得增长,此之为多核加速。


是的,进程为所有这些提供了统一的概念体系,用 COP 的方式来思考,将能更容易的达成这些目标。

要往何处去?

“一切皆是进程”是 Erlang 的核心概念,可以说,掌握了进程这把钥匙,理解其他的 Erlang 概念会变得非常容易。而如果说能将 Erlang 比作是一场精彩的电影,那么,毫无疑问,更多的剧透只会带来更少的观影的乐趣,更多更精彩的 Erlang 世界,还是留待你自己去探索。

此外,还请记着“所有的计算机技术都只是工具,帮助你更好地达成目标的工具”。Erlang 也只是工具,而没有灵魂,就算它是绝世神兵,离开了主人也不过只是一块没有生命的死物。所以,象“你和我”这样的使用者才是真正的关键。“你的 Erlang ”要往何处去?乃至“整个社群的 Erlang ”要往何处去?这就要看“你”的了。

posted on 2008-12-14 12:07  易煜  阅读(2183)  评论(0编辑  收藏  举报