乔·阿姆斯特朗(Joe Armstrong),Erlang最初的设计者和实现者,也是Erlang OTP系统项目的首席架构师。他拥有瑞典皇家理工学院博士学位,是容错系统开发领域的世界级专家。此外,他还在开发旨在替代XML的标记语言ML9。现任职于爱立信公司。
程序语言的新星:走近Erlang的世界编辑本段回目录
提起Erlang语言,相信许多人都会挠头,因为它实在是太陌生了。在2007年6月由TIOBE Programming Community提供的程序语言排名中,Erlang占有率仅为0.08%,排名第49位。与之形成鲜明对比的是,Java以20.025%的占有率高居榜首,紧随其后的是C(15.967%)、C++(11.118%)、VB (9.332%)、PHP(8.871%)、Perl(6.177%)、C#(3.483%)、Python(3.161%)、JavaScript (2.616%)和Ruby(2.132%)。相对于传统老牌“大佬”语言相比,Erlang语言绝对算得上是一种“小众”语言,但其未来的发展前景却是无法估量的,因为它可以解决传统语言很难解决在并行计算中的难题,甚至有专家预言可能成为下一个Java,在正在迅猛发展的并行计算时代,Erlang将会迅速的崛起。
认识Erlang
Erlang并非一门新语言,它出现于1987年,只是当时对并发、分布式需求还没有今天这么普遍,当时可谓英雄无用武之地。Erlang语言创始人Joe Armstrong当年在爱立信做电话网络方面的开发,他使用Smalltalk,可惜那个时候Smalltalk太慢,不能满足电话网络的高性能要求。但Joe实在喜欢Smalltalk,于是定购了一台Tektronix Smalltak机器。但机器要两个月时间才到,Joe在等待中百无聊赖,就开始使用Prolog,结果等Tektronix到来的时候,他已经对 Prolog更感兴趣,Joe当然不满足于精通Prolog,经过一段时间的试验,Joe给Prolog加上了并发处理和错误恢复,于是Erlang就诞生了。这也是为什么Erlang的语法和Prolog有不少相似之处,比如它们的List表达都是[Head | Tail]。
1987年Erlang测试版推出,并在用户实际应用中不断完善,于1991年向用户推出第一个版本,带有了编译器和图形接口等更多功能。1992年,Erlang迎来更多用户,如RACE项目等。同期Erlang被移植到 VxWorks、PC和 Macintosh等多种平台,两个使用Erlang的产品项目也开始启动。1993爱立信公司内部独立的组织开始维护和支持Erlang实现和 Erlang工具。
目前,随着网络应用的兴起,对高并发、分布部署、持续服务的需求增多,Erlang的特性刚好满足这些需求,于是Erlang开始得到更多人的关注。
Erlang特性
Erlang是一种函数式语言,使用Erlang编写出的应用运行时通常由成千上万个轻量级进程组成,并通过消息传递相互通讯。使用Erlang来编写分布式应用比其它语言简单许多,因为它的分布式机制是透明的,即对于程序而言并不知道自己是在分布式运行。Erlang运行环境是一个虚拟机,有点类似于Java虚拟机,代码一经编译,同样可以随处运行。它的运行时系统甚至允许代码在不被中断的情况下更新。另外如果需要更高效的话,字节代码也可以编译成本地代码运行。
Erlang的结构图
相较于其它语言,Erlang有很多天生的适应现代网络服务需求的特性: ◆并发性,Erlang具有超强的轻量级进程,这种进程对内存的需求是动态变化的,并且它没有共享内存和通过异步消息传送的通讯。Erlang支持超大量级的并发线程,并且不需要操作系统具有并发机制。 ◆分布式,Erlang被设计用于运行在分布式环境下。一个Erlang虚拟机被成为Erlang节点。一个分布式Erlang系统是多个Erlang节点组成的网络(通常每个处理器被作为一个节点)。一个Erlang节点能够创建运行在其它节点上的并行线程,而其它节点可以使用其余的操作系统。线程依赖不同节点之间的通讯,这完全和它依赖于单一节点一样。 ◆ 软实时性 Erlang支持可编程的“软”实时系统,这种系统需要反应时间在毫秒级。而在这种系统中,长时间的垃圾收集(garbage collection)延迟是无法接受的,因此Erlang使用了递增式垃圾收集技术。
◆ 热代码升级 一些系统不能由于软件维护而停止运行。Erlang允许程序代码在运行系统中被修改。旧代码能被逐步淘汰而后被新代码替换。在此过渡期间,新旧代码是共存的。这也使得安装Bug补丁、在运行系统上升级而不干扰系统操作成为了可能。
◆ 递增式代码装载 用户能够控制代码如何被装载的细节。在嵌入式系统中,所有代码通常是在启动时就被完全装载。而在开发系统中,代码是按需装载的,甚至在系统运行时被装载。如果测试到了未覆盖的Bug,只需替换具有Bug的代码即可。
下一个Java?
Erlang将会成为一个非常重要的语言。如果有了大公司的支持,它甚至可能成为下一个Java。因为它是个开源项目,非常适合多核处理、Web服务等领域。事实上,它也是编写在多核机器上运行的高可靠性系统的唯一成熟语言。
Erlang始于20年前,是一个并发性Prolog,Joe Armstrong创造了它。第一个大型Erlang项目是一个由几百人创建的电信交换系统,系统有数百万行代码。系统主要关注的就是可靠性,并且系统有难以置信的可靠性历史。据Joe介绍,“它有99.9999999%的可靠性”。
这意味着每10亿秒才有1秒宕机时间,或者说10亿分钟有1分钟宕机时间。十亿秒大概是30年,10亿分钟大概有2000年。99.999%的可靠性大概是每年宕机5分钟,这已经是很好的了。了解可靠性的人都知道,可靠性系统有 99.9999%的,甚至99.99999%的,但是估计没听过有99.9999999%可靠性的,可基于Erlang的系统实现了。
但这还不是令Erlang壮大的理由,因为不是什么人都关注可靠性。也不是因为 Erlang是一个函数式语言,更不是并行Erlang是个面向对象语言。其发展迅速的主要原因是唯一一个有可靠实现和完善类库的成熟的并行开发语言,在不久的将来所有的桌面系统、笔记本电脑都将是多核的,而要让程序在多核上更快的运行就要使程序能充分利用多核处理的能力。
Erlang带有一组类库。多数类库是用于构建各类Internet服务的。 Erlang有Web服务器和数据库。Erlang社区认为它是构建可靠Web服务器和Web服务的首选语言。Erlang是一个构建可靠系统的框架/平台,它构建的平台可以持续运行而无需关闭,可以每天更新软件,甚至可以定期的更换硬件。这些特性是电信应用所需要的,它还是在线银行、在线商城等各类在线应用所迫切需要的。
Joe Armstrong最近写了本书《Programming.Erlang》,所有关注Erlang的人都值得一读。Erlang符合所有面向对象语言特性,虽然它是个函数式语言,而不是面向对象语言。Erlang区分与面向对象语言的一个方面就是它的错误处理。在某消息出错时,进程不是抛出出错的部分,而是直接进程纠错。系统结构被设计为底部是工作进程(它们可能会失败),上层是管理进程,它们可以重新启动失败的进程。 我不相信其它语言能迅速赶上Erlang。对其它语言而言,加入像Erlang这样的语言特征是很容易的。但这将花费他们大量的时间构建一个高质量的VM和成熟的并发性与可靠性类库。因此Erlang很自然会成功。如果将来要在多核系统上进行开发,Erlang是非常理想的选择。
Erlang在中国
目前,Erlang在全球都还是个小众语言,其在中国影响力就更小了,好在有国内的 Erlang爱好者已经组织起来,在进行相关的工作,成立了Erlang-china.org,发布了部分Erlang相关中文文档,并且组织了两次 Erlang爱好者聚会,Erlang-China.org将继续为对Erlang感兴趣的中文用户提供便利,促进用户彼此之间的交流,推动对这一语言的深入研究,促成一些Erlang开源项目,帮助中文用户为整个Erlang社区做出贡献。
Erlang没有类似Java、C++的语法,它不是面向对象语言,它是函数编程语言(Functional programming Language)。大量程序员并不熟悉函数式编程,我们的计算机教育里也都是基于面向对象和面向过程语言的,这会是所有想尝试Erlang的用户遇到的首要问题,这会使得培训成本加大,决策人员也需要足够勇气来选择一个新语言来构建应用。 另外,Erlang虽然内建了并行、分布的支持,但是程序员还需要学习和掌握并行的思维模式,并行的思维模式也许是更加难以跨越的门槛。
要解决计算时代,可伸缩性、容错性以及运行时可更新系统需求,就目前而言,只有 Erlang语言可以很好的解决。Erlang语言也正面临这一场大的变革,从默默无闻走向更多人视野,会向更广的网络应用领域渗透。也许,不久的将来,当你听到Erlang时,就如同听说Java一样平常。
Erlang应用场合
未来的计算是并发计算。现今甚至桌面CPU也是多核的,当用户给服务器购买了越来越多的CPU时,他们更期望能最大限度地利用他们的新投资,但是今天的许多软件系统并不能很好地做到这一点。
整个软件行业也在发生重大变革,由卖工具软件转向卖服务(软件免费,这也是开源软件兴起的过程),由单纯客户端向B/S或C/S转化,相应的存储和计算向服务器端转移,由原来的PC客户端向客户端多元化(如手机、PDA、电视机顶盒等)转化。这些变革趋势,使得用户可以更方便地访问到服务的同时,服务器也要承受越来越高的负荷,并行/分布的需求逐渐增加。
Erlang语言不是用来解决所有问题的语言,至少现在还不是。Erlang最初专门为通信应用设计的,比如控制交换机或者变换协议等,非常适合于构建分布式,实时软并行计算系统。它是一门专注的语言,可以适应现代服务器要求高负荷、高可靠、持续服务的需求。它要解决的问题域包括:高并发、分布式、持续服务、热升级和高可靠等问题。
Erlang应用实例
典型的Erlang应用是由很多被分配不同任务的“节点(Node)”组成的“集群 (Cluster)”。一个Erlang节点就是一个Erlang虚拟机的实例,用户可以在一台机器(服务器、台式机或者笔记本)上运行多个节点。 Erlang节点自动跟踪所有连接着的其他节点。要添加一个节点仅仅需要将其指向任何一个已建节点就可以了。只要这两个节点建立了连接,所有其他节点马上就会感应到新加入的节点。Erlang进程使用进程ID向其他进程传递报文,进程ID包含着运行此进程的节点信息。因此进程不需要理会正在与其交流的其他进程实际在何处运行。一组相互连接的Erlang节点可以看作是一个网格计算体或者一台超级计算机。
erlang的odbc应用程序结构图
Yaws是一个Erlang写的Web服务器。ErLang本身带有一个HTTP Server,叫做inet。Yaws对于inet,就相当于Servlet对于Http Server。Yaws也可说是一个Web开发框架,Yaws的ehtml类似于jsp、 php、ruby template。Yaws并发能力是Apache的15倍,有人利用16台集群服务器所做的显示,Yaws可以承受超八万并发活动,Apache在四千就宕机了。
erlang和ruby的简单测试
Ejabberd也是Erlang很好的应用实例,也是目前可扩展性最好的一种 Jabber/XMPP服务器,支持分布多个服务器,并且具有容错处理,单台服务器失效不影响整个集群运作。Ejabberd基于ErLang+ Mnesia构建,项目已成功发展5年,占据30%左右Jabber服务器市场。 Tsung则是多协议分布式压力测试工具,可用于测试Http、Soap、Postgresql和Jabber/XMPP服务器。而Wings则是一个3D建模程序,软件支持Windows、Mac OSX和Linux等操作系统,这两个项目都基于Erlang构建。
Erlang:真的能成为下一代Java语言吗?编辑本段回目录
上周我参加了ECOOP(面向对象程序设计欧洲会议),不过我没有坚持到最后就中途退场了。我觉得最为精彩的部分就是Joe Armstrong关于Erlang的讲话,事后我还直接和他进行了交谈。我是去年碰到Joe的,也就是在那个时候,我就一直追随着Erlang语言。
一、高可靠性的Erlang,有望取代JavaErlang将成为一个非常重要的语言。它也许就是下一代的Java语言。目前Erlang发展的主要问题就在于没有一个大的公司来支持它,作为它强大的后盾。结果,Erlang被推动称为一个开源的项目。Erlang语言最大优势就是它非常适合多核,web服务的特点。事实上,Erlang是唯一成熟的,非常稳定可靠,适合开发运行在多核机器上的高伸缩性的系统。Erlang最为并行的Prolog,始于20年前。Joe Armstrong发明了它,并成为推动它发展的主要人物。Joe Armstrong在Erickson公司工作,Erickson是一家瑞典的科技公司。最大的第一个Erlang项目是开发一个电子开关系统,此系统有几百人开发,他们写了几百万行的代码。这个系统的要求重点就是可靠性,不专门是速度,最终,这个项目具备了令人难以置信的优良可靠性。Joe声称他们取得了“9个9的可靠性”。“9个9的可靠性”意识是什么了?意思就是说在一百万秒钟,只有一秒出现故障时间,或者说在一百万分钟,只出现一分钟的故障时间。然而,一百万秒大约就是30年。一百万分钟大约是2000年。这个系统生产出来已经有上10年了,但是我认为少于15年。该公司已经卖出来上百个该系统,或许有上千个。200个系统运行10年的话,加起来就有2000年来了,如果所有的系统加起来总的故障时间少于1分钟的话,那么他们就可以说该系统达到了“9个9的可靠性”。“5个9的可靠性”是指一年中只有5分钟的故障时间,能达到这个水平就已经很不错了。人们非常热衷追求6个9,或者7个9。要说达到9个9是简直前所未有的。但是,Erlang开发的系统达到了这个空前的水平。优良的可靠性还不能说明问题,并不能使得Erlang伟大。并不是有足够的人关注稳定性。“顺序的Erlang”作为一个函数型的编程语言也不是使得Erlang伟大的原因。“并行的Erlang'”作为一种面向对象的语言也不是Erlang指的推崇的原因。值得我们称道的是Erlang'是唯一一个成熟的语言,带有可靠的实现工具,和一组非常好的库,能让你的软件无缝的伸缩,从单个处理器系统到使用多个处理器系统使得你的应用程序运行的更快。
二、丰富的多处理器的支持,使Erlang如虎添翼当使用Erlang构建一个系统的时候,你只能在进程间通过传递消息,来使得一组进程间通信。在Erlang里面没有共享的状态,唯一的方式就是通过发送消息和进程通信。不像Java 或者 Smalltalk,只是在并发操作的时候,写一些线程/进程,Erlang程序员使用进程来模块化,提高可靠性,重用性。以后它们就能自发的并行运行。理论上说,你可以在一个处理器上构建你的系统,但是实际中没有这样的Erlang程序员这么做。他们更喜欢当作有上千个处理器来开发系统。当在一个处理上运行的时候并不影响性能。但是最好利用多处理器,来提高系统的性能。接着,把应用程序放在10个处理器的系统上,你的应用程序就会运行快了十倍(或许八九倍,但是还是不错的)。当然,因为你写你的应用程序时候,写了上千个的进程并不能说有伸缩性。像任何一个系统一样,你也有瓶颈的。在等待另外一个进程的时候,你要浪费很多的等待时间,希望得到其它进程提供的结果。为了实现Erlang系统的伸缩性,有许多的设计模式供你选择。Erlang自带了很多程序库。大多数的程序库是为了构建应用程序,或者使用不同种类的网络服务。Erlang有web服务和数据库的功能。Erlang社区将Erlang定位为构建可靠性web服务和web服务应用程序的语言。但是自带的程序库名字大多称为OTP,即开源电信平台。毫不奇怪名字为什么这么起,虽然它与电信没有任何关系。但是Erlang构建运行几十年,在需要每天更新软件,或者周期更换硬件而不中断的开发框架和平台。这正是电信应用程序所需要的,同时是在线银行,在线商店等等所需要的。Joe Armstrong已经完成了一本关于Erlang的书,已经由Pragmatic Programmers.出版了。Joe还写了一篇关于这本书的文章。这是一本非常好的书,任何对Erlang有兴趣的人应该去读一读。对于这本书,令我很发疯的是它展现的更多的是Erlang最为函数式的语言,而没有很多的关于面向对象的方面。事实上,它否认Erlang是面向对象的。
三、另类的设计,将会带给Erlang程序员更多的思想圣宴 Erlang里面的进程就是一个对象。最初我在讲述面向对象的设计的课程上,我从三个角度解释面向对象编程。从表面的角度说,一个面向对象的系统就是它的开发者要认识到程序设计是一个模拟。从更深的角度来说,面向对象的系统就是它有对象构成,通过对象之间的发送消息来通信,通过对象间传来传去的消息来完成计算。从软件工程的角度看面向对象的系统就是它支持数据抽象化,通过函数调用后期绑定,和继承完成多态。Erlang是行为驱动模型的完美例子,也是从更新的角度来看的例子。进程当然支持数据抽象多多态。一个Erlang进程就是一个函数,它从消息队列中读取消息,找到自己相匹配的消息,接着做出响应。这种函数式结构处理方法很像Smalltalk中的类。更有甚者,好几个线程共同遵照一个协议,有一些共同的东西,很容易分析出他们有一些相同的函数可以调用。这就很像类的继承性了。因此,你可以说Erlang支持继承的,虽然它和Java 和Smalltalk有很大的区别。我能想象的出很多的Erlang开发者认为程序设计就是模型化。因此,Erlang适合所有的具有面向对象特征的系统,虽然说顺序的Erlang是函数式的语言,不是一种面向对象的语言。Erlang唯一和面向对象的语言不同的是它强调失败故障。任何消息的发送可能失败。进程不能引发异常,它们出现失败故障。系统构造出工作进程,在底层有可能出现失败故障,而在它们上面有管理进程,能够重启失败的进程,因为开发者能够预期到失败的进程。Joe讲述了Erlang太多的函数式语言特性,他认为Erlang缺乏易变的状态,也就是暗示没有加锁。但是,它的确是缺乏SHARED状态。你可以用Basic, perl, 或者C写进程。我能肯定许多的人会学习Erlang,然后说:“我能把Erlang的优秀特点引入到我们的语言中去”。但是依照我的观点,Erlang的并发程序设计的特点,为并行和可靠性而设计的成熟的实现方法和强大的程序库是它的特别之处。
相信任何一个其它的语言能追上Erlang。其它的语言也许会增加一些和Erlang相似的语言特点。但是需要它们花很长的时间取构建如此一个高质量的虚拟机和为并发和可靠性开发的程序库。因此,我认为Erlang将会取得成功,并在10年内成为现在Java语言的替代者。如果你想在将来的构建多核的应用程序,你应该学习Erlang。(胡磊)
在存在软件错误的情况下构建可靠的分布式系统编辑本段回目录
- (第二章)构架模型
作者:Joe Armstrong(Erlang的作者)
译者:glove@smth
译者注:
1. 转载请注明出处
2. 论文大纲粗略翻译了一下,因为涉及到的概念很广,每翻译一节更新一节。 *?是指不知道翻译是否合适的地方。
这个名词没有一个标准的被一致认同的定义,因为软件架构在它所处的领域中还处于幼年时期,……因为它没有标准的定义,它也没有缺点……
卡内基梅隆软件工程学院
本章描述了构建容错系统的架构。每个人对架构都有这模糊不清的定义,存在一些被广泛接受的定义,这些定义造成了很多的误解。以下的定义表述了软件架构的通常含义:
架构是指在软件系统的组织上的一系列意义显著的决定,依据系统的构成选择结构元素和接口,结构元素的协同运作中所规范的行为,结构或是行为元素如何组成一个渐进的层状系统,以及指导这些组织——元素、界面、它们的行为和组合——的构成风格。
Booch, Rumbaugh, Jacobson [19]
2.1架构的定义
在最抽象的层面上来说,架构是指构想世界的方式。但从实用的角度来说,我们必须把对世界的构想转变成为实践中的守则,一系列的规程,它们告诉我们如何利用这一看待世界的特定方式来构建一个特定的系统。
软件架构可以用以下的描述来阐明:
一个问题领域 - 架构是被设计用来解决何种问题的?人们设计软件软件架构是用来解决特定问题,而不是某一通用问题的。缺乏对架构用来解决的问题的描述,软件架构的描述是不完整的。
一种哲学 - 软件构建方法的背后是什么样的理论在支撑?架构的中心思想是什么?
一系列关于构建的指导方针 - 我们如何编写出一个系统?我们需要一系列明确的构建指导。我们的系统会被一个程序员团队编写和维护——因此,确保所有的程序员和系统设计者明确系统架构和与其相关的哲学是非常重要的。出于实用的原因,这些认识以一种构建指导方针的形式很方便地维护着。完整的指导方针包括一系列的编程规则,范例和教学材料等。
一套预先定义好的组件 - 从一套预先定义好的组件中选择一些用于设计远比从头开始设计要容易得多。Erlang的OTP库包含了一套完整的预先定义好的组件(叫做behaviours*?),使用它们就可以构建通常使用的系统。它们其中的一些例子包括用来构建客户端系统的gen_server behaviour,用来构建基于消息的程序的gen_event behavior。预先定义的组件将在6.1中详细讨论。
6.2.2给出了利用gen_server behavior编程的简单例子。
一种描述事物的方式 - 我们如何描述一个组件的接口?我们如何描述我们系统中两个组件之间的通信协议?我们如何描述我们系统的静态和动态结构?为了回答这些问题,我们需要引入一些不同的专有的标记法。一些用来描述程序的API,另一些用来描述协议和系统结构。
一种配置事物的方式 - 我们怎样启动、停止、配置这一系统?我们怎样在系统运行时重新配置系统?
2.2问题领域
我们的系统最初被设计用来构建电信交换设备。电信交换设备对可靠性和容错性等提出了很高的要求。电信设备被预期应该"永远"运行,它们必须显现软实时(soft real-time)特性,它们在存在软件和硬件错误时也应该合理的反应。Dacker[30]给出了电信系统的十项需求:
系统必须能处理极大数目的并发活动。
活动必须在某一点及时得到处理,或者在一定时间内得到处理。
系统可能分布在一些计算机上。
系统被用来控制硬件。
软件系统非常庞大。
系统显现出复杂的功能,如:特征相互作用(feature interaction)。
系统应该能够在许多年的时间里连续操作。
应该能在不停止系统运作的情况下进行软件维护(如重新配置等)。
有严格的质量和可靠性要求。
必须同时对硬件失败和软件错误容错。
我们能够从中激发提炼出这样的一些需求:
并发性 - 交换系统存在内在的并发需求:一个典型的交换机中,数万人可能同时与交换机交互。这意味着系统必须能有效处理数万的并发活动。
软实时 - 在电信系统中,许多的活动必须在一段特定的时间内执行。一些限时的操作是被严格强制执行的,即,如果一个特定的操作没有在一个给定的时间范围内成功完成,那么整个操作就会被放弃。其它的操作仅仅被各种形式的计时器所监控,在操作完成之前,如果计时器触发了一个事件,那么整个操作将被重复。
编写这样的系统需要以一种非常高效的方法维护数万的计时器。
分布式 - 交换系统存在内在的分布需求,因此,我们的系统必须以能方便地从单点系统扩展到多点分布式系统的方式构建。
硬件交互 - 交换系统需要控制和监测大量的外设硬件。这意味着可能需要编写高效的设备驱动,并且不同的设备驱动的上下文切换必须高效。
大型软件系统 - 交换系统很庞大,比如,爱立信的AXE10和AT&T的5ESS交换机都有几百万的程序代码[71]。这意味着我们的软件系统必须能够运行数百万行的源代码。
复杂的功能 - 交换系统拥有复杂的功能。市场的压力鼓励拥有大量复杂特性的系统的开发和部署。通常情况下,在没有很好地理解这些特性的交互的情况下,系统就被部署了。在整个系统的生命周期中,它的特性集可能以很多的方式改变和扩充。特性和软件升级必须在不停止系统的情况下,以在线(in place*?)的方式执行。
持续运作 - 电信系统被设计用来多年连续性的运作。这意味着像软件和硬件维护这要的操作必须在不停止系统的情况下进行。
质量要求 - 交换系统必须在提供可接受的服务的层次上运行,即使存在错误。电话交换必须极端的可靠。
容错 - 交换系统必须是"容错"的。这意味着我们从一开始就知道错误将会发生,我们必须设计一种软件和硬件架构来处理这些错误,在即使存在错误的情况下,也能提供可被接受层级的服务。
虽然这些需求原本来自于电信领域,它们决不排斥其它特定的问题领域。许多现代的互联网服务(如网页服务)也存在这样非常相似的需求。
2.3哲学
我们如何在存在软件错误的情况下,构建能够以合理的方式运行的容错的软件系统?这篇论文接下来都是试图回答这个问题。首先我会给出一个较短的回答,在论文的其它部分,我将逐步提炼和完善它们。
在存在软件错误的情况下,构建能够以合理的方式运行的容错的软件系统,我们将遵循下面的步骤:
我们将系统需要执行的步骤组织成有层次的任务。每一个任务对应着一些目标的完成。解决某一给定任务的软件必须尝试处理该任务并实现这一任务对应的目标。
任务依照复杂度排序。最高等级的任务也最复杂,当所有的最高等级的任务都被实现时,系统应该完美地工作。低等级的任务也应该让系统以可以接受的方式运作,尽管它们可能只提供一个较低等级(reduced level)的服务。
低等级的任务的目标比高等级任务的目标应该更容易实现。
我们尝试处理高等级任务。
在实现任务的过程中,如果遇到错误,我们尝试修复该错误。如果该该错误不能被修复,我们立即放弃现有的任务,并且开始处理一个较简单的任务。
为有层次的任务编写程序需要很强健的封装方法(a strong encapsulation method*?)。我们需要强健的封装(strong encapsulation)来进行错误隔离。我们希望终止系统中对其他部分软件有不利影响的程序错误。
为了实现一个目标,我们需要隔离所有运行中的代码,这样我们能够检测尝试达到某个目标时是否发生了错误(*?)。并且,当我们尝试同时实现多个目标时,我们不希望系统某个部分发生的软件错误传播到系统的其它部分。
因此,错误隔离(fault-isolation)是构建容错软件系统中必须解决的一个问题。不同的程序员编写不同的模块,一些模块是正确的,一些的模块存在错误。我们不希望一个模块中的错误对其他没有错误的模块的行为产生不利的影响。
为了提供错误隔离,我们使用传统的操作系统对进程的表示法(traditional operating system notion of a process)。进程提供保护区域,这样一个进程中的错误不会影响到其它进程的操作。不同的程序员编写不同的应用程序,这些应用程序在不同的进程中运行;一个程序中的错误对系统中运行的其他程序不应该有负面的影响。
当然,只有第一个估计是正确的。因为所有的进程使用相同的CPU和内存,进程会尝试霸占整个CPU或者尝试使用不属于它的内存,这对系统中的其他进程造成了不利的影响。进程互相影响的程度取决于操作系统的设计特性。
在我们的系统中,进程和并发是程序语言的一部分而不是由宿主操作系统提供的。相对于操作系统进程,它有一系列优点:
并发程序在不同的操作系统下能相同运行——我们不会受限于不同操作系统中进程的实现方式。迁移到不同的操作系统和处理器所能观察到的唯一不同只是因为不同的CPU速度和内存大小等。同步以及进程内通信的所有问题同样与宿主操作系统的性质无关。
和操作系统进程相比,我们语言所基于的进程是大大轻量级(lighter-weight)的。在我们的语言中创建一个进程非常高效,比大部分操作系统的进程创建和编程语言的线程创建要快几个数量级[12, 14]。
我们的系统对操作系统只有非常少的需求。我们只使用了很少的系统服务,这使将我们的系统移植到诸如嵌入式系统等其他特定环境的工作变得相对简单。
我们的系统由大量相互通讯的并发进程组成。我们使用这样的方法是因为:
它提供了一个基础架构 — 我们能够将我们的系统组织成为一系列相互通讯的进程。通过枚举我们系统中的所有进程,定义进程之间的信息传递通道,我们能够方便地将这个系统划分成为许多定义良好的子组件,它们能够独立地实现和测试。这是SDL[45]系统设计方法学最高等级所指出的方法*?。
潜在的效率 — 被设计成用许多独立的并发进程实现的系统能够在多处理器上实现或在网络分布的处理器上运行。需要注意的是,这样的效率仅仅是潜在的,并且只有当应用程序确实能够划分为许多独立的任务时才能工作得最好。如果各个任务之间有很强的数据联系,这种效率并非一直可能的。
错误隔离 — 没有数据共享的并发进程提供了很强的错误隔离程度。在并发进程中的软件错误不应该影响系统中其他的进程运行。
这三项并发的应用中,前两个并不是必要的特性,这两项能够由某种内置的调度器提供,这些调度器在进程间提供了不同类型的伪并行分时(pseudo-parallel time sharing)。
第三个特性对于构建容错系统是必须的。每一个独立的活动必须在完全隔离的进程中进行。这些进程不应该共享任何数据,并且只通过消息传递联系。这样可以限制软件错误造成的后果。
只要两个进程开始共享通用资源,比如,一块内存,一个指向内存的指针,或者一个互斥锁(mutex)等,一个进程中的软件错误可能会损坏这些共享资源。因为在大型软件系统中消除这样的软件错误仍然是一个尚待解决的问题,我认为,构建大型可靠系统的唯一现实方法是将系统隔离成为独立的并行进程,并且提供一个监控和重启这些进程的机制。
2.4面向并发编程
在我们的系统中,并发扮演了一个中心角色,它是如此重要,我不得不造出"面向并发编程"这个名词来将这种风格与其他的编程风格区分开来。
面向并发编程中,程序的并发结构必须遵从应用的并发结构。它对现实世界建模或交互的应用尤其适合。
面向并发编程同样提供了面向对象编程相关的两个主要优点。他们是多态,以及在不同操作类型的实例间使用具有相同消息传递接口的协议。
当我们将一个问题划分为许多并发的进程,我们能够使这些进程对相同的消息起作用(也就是说他们是多态的),它们遵循相同的消息传递接口。
"并发"这个词是指同时发生的一系列事件。我们的现实世界就是并发的,它拥有庞大数量的事件,这些事件中相当一部分是同时发生的。从微观的层面看,我们的身体是由原子和分子以一种协同的方式构成的。从宏观的层面看,宇宙是在星系协同作用下形成进化的。
当我们进行一项简单的行动,比如沿着高速公路开车时,我们意识到这样一个事实,在眼前这一刻,高速公路上有数以百计的汽车在飞驰,但我们仍然能够进行开车这一项复杂的工作,并且在不知不觉中避免所有可能的危险。
在现实世界中,序列化的活动是很少的。当我们沿着街行走的时候,我们会很惊讶地发现,只有一件事情在发生,那就是我们会遇到许多同时发生的事件。
如果我们没有能力分析和预测许多同步发生事件的后果,我们将处于巨大的危险中,即便像开车这样的活动也简直是不可能的。我们能够做很多需要处理大量并行信息的工作的事实说明我们具有一种感觉机制,这种感觉机制允许我们能不加思考,直觉地理解并发。
在计算机编程上,事情马上颠倒过来了。编写序列化的活动被认为是一个规范,在某种使程序简单的意义上来说,编写一系列并发的活动是应该尽可能避免的,它们也通常被认为是很困难的。
我相信这是因为我们通常所见编程语言在并发上几乎都只提供很有限的支持。大量的程序语言本质上就是序列化的;这些语言所提供的任何并发支持都不是由其语言本身,而是由其所运行的操作系统所提供的。
在这篇论文里,我展示了这样一种观点,并发是由程序语言,而不是由其所运行的操作系统所支持的。对并发具有良好支持的语言被我称为面向并发的语言(Concurrency Oriented Languages),或者简称为COPL。
2.4.1通过观察现实世界编程
我们常常希望编写一个以现实世界为模型(model the world, 为现实世界建模,译者认为作者要表示"以现实世界为模型"的意思)或者与现实世界交互的程序。以COPL来编写这样的程序是一件很容易的事情。首先我们进行一个三阶段的分析:
我们确定在现实世界活动中所有真正并行的活动。
我们确定这些并行活动中所有的消息通道。
我们写下在不同的消息通道中传播的所有消息。
现在我们开始编写程序,程序的结构应该遵循问题的结构。现实世界中每一个并行的活动应该恰好映射到我们编程语言的一个并行进程上。如果问题和程序之间是一一对应的关系,我们就称该程序与问题是同构异形的。
这种对应是否一对一相当重要。它如此重要的原因是因为这样能使问题和解决之间的概念界限最小化。如果这种对应不是一对一的,程序将迅速地退化(degenerate*?),并且变得难以理解。当使用非面向并发的语言来解决并发问题时,我们常常能够观察到这种退化(*?)。通常,解决这样的问题的唯一方式就是强制这些独立的活动被同一语言线程或进程所控制。这毫无疑问将导致程序清晰性的丧失,并且使程序易于受到复杂和不可再现的错误的干扰。
为了对问题进行分析,我们必须为我们的模型选择合适的粒度(granularity)。打个比方来说,如果我们想要编写一个即时通讯系统,我们会为每个用户使用一个进程,而不是为用户身上的每个原子使用一个线程。
2.4.2 COPL的特性
COPL有以下6种特性:
COPL必须支持进程。一个进程可以想象成为一个自包含(self-contained)的虚拟机。
统一及机器运行的不同进程必须被强健地隔离。一个进程的错误将不会对其他进程产生不利的影响,除非这些进程之间的交互是被写明的。
每一个进程必须被一个唯一的不可伪造(unforgeable)的标识符所标识。我们把这个标识符叫做进程的Pid。
进程之间没有被共享的状态。它们依靠传递消息进行交互。如果你知道进程的Pid,你就可以向这个进程发送消息。
消息的传递应被假设成不可靠的,不能保证消息能够正确地送达。
一个线程应该可能侦测到另一个线程的错误。我们也应该可以知道这个错误的原因。
需要留意的是,COPL必须提供真实的并发,因此表示成为进程的对象也应该真实地并发,进程之间的消息是真实的异步消息,而不像在许多面向对象语言中伪装的远程过程调用(Remote Procedure Call, RPC)一样。
另外需要注意的是,错误的原因并不是一直都正确的。比如,在一个分布式系统中,我们可能会收到一个消息,通知我们一个进程停止了,而事实上仅仅是因为网络发生了错误。
2.4.3 进程隔离
理解隔离是理解COP和构建容错软件的核心。在同一机器上运行的两个进程必须表现得好像它们是在两个物理上不同(separate,不翻译成分开或者隔离,因为隔离通常意味着也不存在网络连接)的机器上运行一样。
固然,运行面向并发程序的理想架构是为每一个软件进程指派一个新的物理处理器。但这样的条件意味着我们必须处于一个在同一机器上装备多个处理器的环境。我们也还是需要将它们设想成好像在物理上不同的机器上运行一样。
隔离会导致这样一些推断:
进程需要有"不共享(Share Nothing)"的语义。因为它们是被设想成在物理上不同的机器上运行,这样的推断是很显然的。
进程之间交换数据的唯一途径是消息传递。同样的,进程之间不共享(译者注:寄存器,内存等),交换数据的唯一方法只能是消息传递。
隔离意味着消息传递是异步的。如果进程间通讯是同步的,那么消息接收端的一个软件错误将导致消息的发送端无限地被阻塞(阻塞),这样会破坏隔离性。
因为进程间没有任何东西被共享,进行分布计算所需要的任何东西都需要被拷贝。同样地,因为进程间没有任何东西被共享,进程间通讯的唯一途径只能是消息传递,这样我们就不会清楚我们的消息是不是被送达(我们之前提到,消息传递从本质上来说就是不可靠的)。了解消息是否被正确送达的唯一途径是返回一个确认消息。
编写进程系统导致的上述规则初看起来实现上很困难——毕竟,所有顺序执行的程序语言的同步扩展都提供了与上述规则相反的基础设施,如锁(lock),信号量(semaphore),共享数据和可靠的消息传递等。幸运的是,这些规则被证明是正确的——编写这样的系统被证明是惊人地容易,你能够毫不费力地编写易于扩展和容错的程序。
因为我们所有的进程都要求是完全隔离的,加入更多的新进程不会影响到原有的系统。软件必须能处理大量隔离的进程,这样,加入新的处理器通常不会对应用软件造成任何大的影响。
因为我们没有对消息传递的可靠性作任何假设,软件必须能在不可靠的消息传递下运行,这样,在发生消息传递错误时,它们也还能够正常运行。最初付出的努力将在我们尝试扩展我们的系统时给予我们相应的回报。
2.4.4 进程的名称
我们需要进程的名称不能被伪造。这意味着猜测进程名并尝试利用它联系进程是不可能的。我们将假设进程知道它们自己的名字,进程的创建者知道其所创建的进程的名字。换句话说,一个父进程知道它所有子进程的名字。
为了编写COPL,我们需要一种找出进程涉及的进程名的机制。如果我们知道了某个进程的名字,我们就能够向这个进程发送消息。
我们的系统的安全性与是否知道进程名息息相关。如果我们不知道进程的名字,我们将不能与其交互,因此系统就是安全的。一旦进程名被广泛地了解,系统就没有那么安全了。我们将"在被控制的方式下把进程名透露给其它进程"的过程叫做"名称分发问题"(name distribution problem)——安全性的关键就在于名称分发问题。当我们将一个Pid暴露给另一个进程,我们称之为我们发布了这个进程的名字。如果一个进程的名字从未被发布过,就不会有任何安全问题。
因此,是否知道一个进程的名字是安全性的关键要素,而进程的名称是不能伪造的,只要我们能将一个进程的名称局限在被信赖的进程里,系统就将会是安全的。
在许多早期的宗教里,人们相信如果能够知道幽灵的真实名称,并用其命令它们,人们就会拥有控制幽灵的本领。知道幽灵的真实名字可以给你控制幽灵的本领,用这个名字,你就可以命令幽灵为你做各种各样的事情。COPL也应用了这样的想法。
2.4.5 消息传递
消息传递遵循下列规则:
消息传递呈现出原子性,这意味着消息要么被整个送达,要不完全没有送达。
消息传递呈现出有序性,这意味着在进程间传送或接收的消息,将以它们被发送的顺序被接收端收到。
消息不能含有指向进程内数据结构的指针——它们只能包含常量或者(和)Pid。
值得注意的是,第2点是一个设计决定,并不是反映用于传递消息的网络的任何基础语义。用于传递消息的网络可能会记录下这些消息,但在任何一对进程间,消息能够被缓冲,并在送达前重新组合成正确的顺序。与允许无序的消息相比,这个假设让编写消息传递程序变得较为简单。
我们说,这样的消息传递有"边发边祈祷"(send and pray)的语义。我们一边发送(send)消息,一边祈祷(pray)它们能够被送达。我们依靠回复确认消息来确认消息是否送达,有时候,这也叫做回环确认(round-trip confirmation)。有趣的是,许多程序员只相信回环确认,即使在基础传输层能提供可靠的数据传输,或者回环确认不合时宜的情况下,也使用回环确认。
消息传递也又来同步。试想我们需要同步A,B两个进程。如果A向B发送了一个消息,B只可能在A发送消息后的某个时间收到。这在分布式系统理论中被成为因果顺序(casual ordering)。在COPL中,所有进程间同步都基于这个简单的理论。
2.4.6 协议
隔离各个组件,在它们之间采取消息传递的方式交互,对于保护系统免受软件错误的后果影响,在架构上来说已经足够了。但是对于明确系统的行为,或者在某些种类的错误中判断到底是哪个组件发生了错误而言,这些还是不充分的。
到目前为止,我们还假定错误是单个组件的性质,单个组件要么完成了它要完成的任务,要么很快地失败。但是,还有一种情况可能会发生,就是没有发现哪个组件发生了错误,但是整个系统仍然没有按照它预想的那样运行。
因此,为了完成我们的程序模型,我们加入了一点新的东西。我们不仅需要完全隔离整个组件,在它们之间采用消息传递的方式通讯,我们也需要指明组件间互相联系所使用的协议(protocol)。
通过指明两个组件间所需要的通讯协议,我们能够很容易地找出包含的组件中哪些违反了协议。在可能的话,应该通过静态分析(static analysis)来保证协议被强制执行,如果不能的话(or failing this),在代码编译时,就将运行时检查(run-time check)编译进代码。
2.4.7 COP和编程团队
构建一个大型系统需要许多程序员的共同工作,有时候,数以百计的程序员将参与到工作中来。为了组织他们的工作,程序员被组织到小的团队。每一个团队负责系统中一个或多个逻辑组件。在日常的组织中,团队之间通过消息传递(电子邮件或者电话)而不是例会的方式交流。在有些情况下,这些团队甚至在不同的城市工作,从不见面。这是很有趣的现象,不仅仅是软件系统因为种种原因需要组织成隔离的组件,并通过纯粹的消息传递联系,甚至大型的编程团队也需要这样来组织。
2.5 系统需求
为了支持面向并发风格的编程,并使我们的系统满足电信系统的需要,我们对系统的必需特性提出了一系列的要求。这些需求是作为一个整体对系统提出的——在这里,我并不关心他们是在编程语言或是库,还是在伴随这门语言的构建方法(*?)中被满足的。
对底层的操作系统和编程语言,有六点必要的需求:
并发 - 我们的系统必需支持并发。创建和销毁并发进程的计算负荷应该是相当小的,并且,创建大量的并发进程应该没有副作用(penalty,译者注:即创建大量的进程和创建单个进程相比不应该有很大的资源和效率罚分)。
错误封装 - 一个进程发生的错误不能损害系统中的其它进程。
故障检测 - 可以在本地(异常发生的进程中)和远程(异常发生在非本地的进程)检测异常。
故障确定 - 我们应该能够确定异常发生的原因。
代码升级 - 应该有一个机制可以在不停止系统运行的情况下,改变它执行的代码。
稳定的存储 - 我们需要一种储存数据的方式,即使在系统崩溃的情况下,也能保持数据不丢失。
这些需求被有效地实现与系统满足这些需求同样重要——如果我们不能可靠地创建数以万计的进程,并发是没有太多用处的。同样,如果故障确定不能包含足够多的信息,使我们在稍后可以纠正这些错误,那么,故障确定也不是那么有用的。
有很多不同的方式可以满足上面的需求。拿并发来说,它可以在语言基础(primitive)上就被提供(像Erlang),或者在操作系统的层面上被提供(像Unix)。C和Java这样的不具有并发性的语言能够利用操作系统提供的并发,给用户他们可以并发的假象(illusion);不具有并发性的语言因此确实可以写出并发的程序。
2.6 语言需求
我们用来编写系统的语言需要具有以下的特性:
封装作为语言的基础(encapsulation primitives) - 必须存在很多的机制来限制错误的后果。必须能隔离进程,这样它们不会对其它的进程产生破坏。
并发 - 语言必须提供一种轻量级的机制来创建并行进程,以及在进程之间发送消息。进程之间的上下文切换和消息传递必须是高效的。并发进程必须以一种合理的方式对CPU进行分时,这样CPU上运行的进程不会独占整个CPU,避免其它的进程总是处于"将要运行"的过程中。
故障检测作为语言的基础(fault detection primitives) - 允许一个进程监测另一个进程,并能检测被监测进程任何原因的终止。
位置透明(location transparency) - 如果我们知道进程的Pid,我们就能够向这个进程发送消息。
动态代码升级(dynamic code upgrade) - 允许在运行的系统中动态地改变代码。需要注意的是,因为许多的进程在运行同样的代码,我们需要一种机制,同时允许现存的进程运行"旧"的代码,而"新"的进程运行修改过的代码。
语言不仅需要满足这些需求,它们还需要以一种合理有效的方式来满足它们。当我们编写程序时,我们不希望"计数过程(counting processes)"或是其它什么来限制我们表达的自由(*?),也不希望担心一个进程无意中尝试独占整个CPU可能的后果。
一个系统中进程的最大数目应该是"足够"大的,这样,在编程的角度,我们就不需要担心最大数目会成为一个限制因素。打个比方,我们可能需要创建大约(the order of)10万个进程,用来维护1万个同步的用户会话(session)。
我们需要结合这些特性来简化应用程序的编写。如果我们能够将问题的并发结构以一对一的方式映射到解决这个问题的应用程序的进程结构的话,那么将一系列分布式通讯组件语义映射到Erlang程序中将是相当简单的。
2.7 库的需求
语言不是全部——大量的部分会以语言所伴随的系统库的方式提供。库函数需要提供的必需部分包括:
稳定的存储 - 在系统崩溃后存储的数据应该幸免于难。
设备驱动 - 提供一种机制与外部世界通讯。
代码升级 - 允许我们在运行中的系统中升级代码。
基础设施 - 用于启动,停止系统,记录错误日志等。
通过了解我们的库函数(基本上用Erlang编写),我们可以发现它们提供了大部分的原来由操作系统提供的服务。
因为Erlang的进程是彼此隔离,并通过消息传递通讯的,它们表现得非常像操作系统中依靠管道(pipe)和socket通讯的进程。
大量原来由操作系统提供的特性从操作系统转移到了编程语言。原来的操作系统仅仅提供基础的设备驱动操作。
2.8 应用程序库
稳定的存储等特性在Erlang中并不是作为语言的基础提供的,它们是在一些基本的Erlang库中提供的。拥有这些库是编写任何复杂应用软件的前提条件。复杂的应用往往需要比存储等更加高阶的抽象。构建这样的应用,我们需要预先打成包的软件实体(pre-packaged software entities)来帮助我们解决诸如"客户端-服务器"的编写问题。
OTP库为我们提供了用于构建容错应用的一整套设计模式(叫做behaviours)。在这篇论文中,我们将讨论behaviours能够用于构建容错应用的一个最小集。它们是:
supervisor - 一个管理模型(supervision model,译者注,是不是应该是supervision module,管理模块)。
gen_server - 一个用来实现客户端-服务器应用的behaviour。
gen_event - 一个用来实现事件处理软件的behaviour。
gen_fsm - 一个用来实现有限状态机(finite state machine)的behaviour。
这些用来编写容错应用的behaviour中最核心的是管理模型。
2.9 构建中的指导方针
除了阐述编写容错应用的通常哲学之外,在用于编写应用的程序语言上,我们还需要更多具体的指导。我们也需要示例程序,以及怎样使用库函数的例子。
开源的Erlang发行包包含了这些指导方针,它们作为基础,已经被用在了拥有数百万行Erlang代码的系统中。附录B重复了在开源Erlang发行包中能找到的这些指导方针。这篇论文也有一些附加的指导方针,它们按照以下的方式组织:
架构的整体哲学在这一章中说明。
错误这一概念在很多地方都有讨论。5.3节和4.3节描述了一个错误意味着什么(*?);4.4节对于Erlang中的错误编程给出了一些正确编程风格的建议。
我们将在第4章中找到关于编写简单组件的例子,第6章则给出了怎样使用OTP behaviors的例子。
2.10 相关的工作
许多流行的编程语言中缺乏隔离软件部件的能力是它们不能被用来构建自动化的系统软件的主要原因。
从安全上来说,能够将不被信任的程序从其它程序中隔离出来,保护宿主平台不受这些程序干扰是至关重要的。在面向对象的系统中,隔离是很困难的,因为对象很容易有很多别名(aliased)。 - Bryce[21]
Bryce接下来阐述了对象的别名化(object aliasing)是非常困难的,如果在实践中有可能检测到它们的话(*?),他建议使用保护区域(protection domain,与操作系统的进程类似)来解决这个问题。
在Sun公司Java Czajkowski,Daynes的一篇论文里提到:
在同一台计算机上运行多个用Java语言编写的应用程序的唯一安全的做法是为每一个应用程序使用一个独立的Java虚拟机(JVM),并且每一个JVM运行在一个独立的系统进程里。这样给资源的利用带来了很多没有效率的地方,这些没有效率的部分会带来性能、扩展性和应用程序启动时间上的问题。因此,这门语言所能带来的益处被主要局限在可移植性和提高程序员的生产效率上。就算这些方面(译者注:指可移植性和生产效率的提高)是很重要的,在程序语言中提供安全性的所有可能并没有被意识到。取而代之的是,在"语言安全(language safety)"和"真正安全(real safety)"之间存在着不寻常的差别。 - [28]
在这篇论文中,他们引入了MVM(JVM的一个扩展),他们的目标在于:
……把JVM变成一种类似于操作系统的执行环境。实际上,现代操作系统提供的进程的抽象,就是以这些特点为依据(*?)的角色模型(role model);(译者注:这些特点包括)与其他计算机的隔离,对资源的负责和控制(resources accountability and control),以及方便的终止和资源的再利用。
为了达到这样的目的,他们作出了这样的结论:
……任务不能直接共享对象,任务间通讯的唯一方式是运用标准的拷贝通讯机制(copying communication mechanisms),……
这样的结论并不是第一次出现的。二十多年以前,Jim Gray就提出了相同的结论,他在那篇相当易读的论文《为什么计算机会停止运行以及应该如何处理》中描述了坦德姆计算机(Tandem Computer)的架构。他写到:
和硬件容错一样,软件容错的关键是将大型系统有级别地分解成模块,每一个模块都可以作为一个服务或者失败的单元。一个模块的失败不会在其它模块中传播。
……
进程依靠不与任何其它进程共享状态来遏制错误;一个线程仅仅依靠内核消息系统所传递的消息与其它进程发生联系。 - [38]
支持这样的编程风格(并行进程,没有任何共享数据,纯粹消息传递)的语言是Andrews和Schneider所提到的"面向消息语言(message oriented language)"。拥有一个可爱名字的PLITS语言(Programming Language in the Sky,1978)[35]可能是这种语言的最初代表:
在RIG(用PLITS编写的一个小型系统)的实现中,基础的设计决定是允许一种不共享任何数据结构的严格的消息规范。用户和服务器的所有通讯消息都经由Aleph核心路由。这种消息规范被证明是非常灵活和可靠的。 - [35]
从语言上抽身回来,让我们关注一个单独的进程应该有怎样的属性。
Schneider[60,59]用他所认为的,为了适于编写容错系统,硬件系统应该具备的三个特性回答了这个问题。Schneider称这些特性为:
错误停机(halt on failure) - 在遇到一个发生错误的信号时,处理器应该停机而不是继续进行可能出现错误的工作。
失败状态的属性(failure status property) - 当一个处理器失败时,系统中的其它处理器应该得到通知。错误的原因也应该被传达。
稳定存储的属性 - 处理器的存储必须被划分为稳定存储(当处理器崩溃时,数据仍然存在)和易失性存储(volatile storage,当处理器崩溃时,数据也丢失)。
拥有这些性质的处理器被Schneider成为故障停止式(fail-stop)处理器。故障停止式观念认为,当一个错误发生时,继续运行是毫无意义的。线程应该停止运行而不是继续执行,以避免造成更大的损失。在一个故障停止式处理器中,状态被储存在易失性或者稳定的存储器中。当处理器崩溃时,易失性存储器中所有的数据将会丢失,但是在稳定存储器中所有的状态仍旧存在。
Gray在谈到"快速故障(fail-fast,*?)"进程时,也提及了一个相当类似的观点[38]:
面向错误隔离的进程支持处理软件的"快速故障(fail-fast,*?)",它要么正确运行,要么检测到故障,发出失败的信号并且停止操作。进程依靠防御式编程(defensive programming)实现"快速故障(fail-fast,*?)"。他们将检测所有的输入,中间结果和数据结构视为理所当然的事(as a matter of course)。如果检测到任何错误,它们发出一个失败信号并且停止运行。在[Cristian]的术语里,"快速故障(fail-fast,*?)"型软件与一般的软件相比,存在较短的错误检测延迟时间(fault detection latency)。 - [38]
Schneider和Gary的想法从本质上来说是一致的;他们一个谈论硬件,一个谈论软件,但是基础的理论都是相同的。
Renzel也认为,当一个不能更正的错误发生后,处理尽快终止是非常重要的:
软件系统的一个失败会造成一个或者多个错误。从故障存在到错误发生之间的延迟时间可能相当长,这使得错误的回溯分析变得复杂……
为了有效地处理错误,我们必须尽快检测到错误并且停止错误的运行。 - [58]
通过汲取这些观点和我们先前提到的需求,我提出了拥有以下属性的系统:
进程是错误封装的单元 - 一个线程中发生的错误不会影响系统中的其它线程。我们将这个属性叫做"强健的隔离(strong isolation)"。
进程要么做他们应该做的,要么尽快失败。
失败,和失败的原因能够被远程线程检测到。
进程间不共享任何状态,通过消息传递通讯。
拥有这样性质的语言和系统,就拥有了编写容错软件的前提条件。在这篇论文稍后的部分,我们将看到Erlang语言和库是如何满足这些性质的。
这篇论文中的许多观点都不是新的——Gray的论文[38]描述了构建容错系统的基本原则。坦德姆计算机的许多特性与OTP系统的设计原则,以及我们之前提到的面向并发编程的基本原则非常类似。
这儿是Gray论文中的两段引述,第一段是论文15页提到的设计原则。
这种软件容错性的关键是:
软件通过进程和消息实现的模块化。
通过"快速故障(fail-fast,*?)"软件模块遏制错误。
宽容硬件和暂时性的软件错误的进程对(process-pairs)。
数据提供方面的事务机制和消息完整性。
事务机制加上进程对来便利异常的处理,并使软件容错。
软件通过进程和消息实现模块化。和硬件容错一样,软件容错的关键是将大型系统有级别地分解成模块,每一个模块都可以作为一个服务或者失败的单元。一个模块的失败不会在其它模块中传播。
如何实现软件的模块化一直有相当多的争议,从Burroughs的Espol语言(译者注:Executive Systems Programming Oriented Language)开始持续到像Mesa和Ada这样的语言,编译器的作者假设硬件是完美的,并且声称他们可以通过静态的编译时类型检查(static compile-time type checking)提供良好的隔离。与之相对的是,操作系统的设计者鼓励进行运行时检查,并结合将进程作为保护和失败的单元(来提供隔离)。
尽管编译器检查和程序语言提供的异常处理非常有价值,但从历史上来看似乎更倾向于运行时检查加上遏制错误的进程。它在本质上更简单——如果一个进程或者它的处理器错误地运行,那么就停止它。进程提供了一个干净的模块化单元,服务,错误遏制和失败。
通过"快速故障(fail-fast,*?)"软件模块实现错误遏制。
进程依靠不与任何其它进程共享状态来遏制错误;一个线程仅仅依靠内核消息系统所传递的消息与其它进程发生联系。 - [38]
如果将它与我们现在的Erlang系统相比较,我们可以发现很多非常相似的地方。它们之间当然存在着差别——在Erlang中,"防御式编程"并不被推荐,因为编译器已经进行了必要的检查,所以不必要使用"防御性编程"风格。Gray提到的"事务机制"通过mnesia数据库(以Erlang编写)实现。而错误的遏制和处理由OTP库中的"supervision tree"这一behaviour管理。
"快速故障(fail-fast,*?)"模块的想法被复制到我们关于程序编写的指导方针中,我们提到,进程只能做它们依照说明应该做的东西,否则它们应该崩溃。我们系统中的管理等级对应于Gary提到的模块等级。这个想法在Candea和Fox的工作[22]中也能够找到,他们提到了"只能崩溃的软件(crash-only software)"——他们认为,允许模块崩溃,然后重新启动该模块,这样的方式会导致更简单的错误模型和更加可靠的代码。
面向对象系统的更多最新工作同样认识到隔离软件组件的重要性。Bryce和Razafimahefa[21]指出,将程序互相隔离,并且将它们与运行在宿主操作系统中的程序隔离开来是非常必要的(译者注:他们指的可能是运行在虚拟机中的线程*?)。他们同样认为这是任何对象系统所必须包含的特性。按照他们在论文里中所指出的,在面向对象的环境下,这是一个很困难的问题。