自组织分布式金融业务系统 【技术领域】
本发明是应用于金融业务领域使用pos机的支付以及网上电子支付的持久稳定业务处理系统。
背景技术
金融行业业务系统一般由高性能计算机主机(通常做了双机热备、自动数据备份等安全措施)以及与高性能计算机主机相连的遵循指定业务通讯协议的各种终端设备(pos机、客户端pc机等)构成。对其进行比较深入的维护(比如遇有关键设备故障或软件升级等情形)时需要离线处理。本发明的目的是使系统可容错(包含软件、硬件错误)和可在线维护。
【发明内容】
本发明名为自组织分布式金融业务系统,英文译名Self-Organized DistributedBanking System,缩写为SODBS。本系统在采取了传统的数据自动备份等技术的基础之上,又采用了改进型Beowulf(见注①)式服务器集群(无固定管理节点)分布式处理的方式,用多台相对廉价的标准pc服务器即可实现通常需要小型机才可以达到的性能,获得了传统的双机热备技术无法实现的高性能和故障时快速响应能力。而且所有服务器硬件节点进行网状连接,没有特定的关键节点,实现了即使部分服务器硬件或软件发生故障也不致引起系统整体瘫痪。系统内的软、硬件故障可以由系统自身自动检测出来,并作自动智能处理(如重启或初始化出错节点),同时以声光、手机短信等方式报警。使用自组织的体系结构,达到了硬件节点的故障可以自行检测及自行修复,硬件节点增减无需停机,软件升级也无需重启或暂停服务的有益效果。而且整体造价低廉,性价比高。客户将享受到系统持久在线的高质量金融服务。
注①Beowulf是一种系统结构,它使得多个计算机组成的系统能够用于并行计算。
Beowulf系统通常有一个管理节点和多个计算节点构成。它们通过以太网(或其他网络)连接。管理节点监控计算节点,通常也是计算节点的网关和控制终端。当然它通常也是集群系统文件服务器。在大型的集群系统中,由于特殊的需求,这些管理节点的功能也可能由多个节点分摊。
Beowulf系统通常由最常见的硬件设备组成,例如,PC、以太网卡和以太网交换机。Beowulf系统很少包含用户定制的特殊设备。
Beowulf系统通常采用那些廉价且广为传播的软件,例如Linux操作系统、并行虚拟机(PVM)和消息传递接口(MPI)。
近年来,在全球最强超级计算机的top500排行榜(www.top500.org)里,越来越多地被Beowulf式工作站集群所占据,截至2008年8月,排名第一的超级计算机已经不再是昂贵的蓝色基因或者地球模拟器这种巨无霸式的大型机了,取而代之的是采用开源linux系统的Beowulf式微机工作站集群(命名为走鹃,由近3000台标准pc服务器组成)。这些微机工作站集群应用在核武器安全保障以及太空项目等要求高度安全、持久稳定运行的场合。
【附图说明】
图1为系统整体硬件连接及通讯结构图概览,服务器集群是网状连接的,连接至服务器集群的每条线路以及线路上的其它设备也都是冗余、并行式的,没有单点故障点,这样任何一个设备的故障或任何一条线路的中断都不会影响到系统功能的正常运行。当系统自动检测出问题,如某节点软件或硬件发生故障时,系统将发出“警告”性声光提示和手机短信提示(提示信息中包含必要的诊断信息),提醒监控人员注意,同时采用自动重启出错部分的软、硬件的方式试图从错误中恢复。如遇自动修复无效的问题,如某节点无法重启或重启后仍然报错,系统将发出“严重错误”声光提示和手机短信提示(提示信息中包含必要的诊断信息),提醒监控人员对相关设备采取人工方式修复。
图2为监督者与工作者的图示,监督者记作方角矩形。在矩形的右上角用一个符号T来标明监督者的类型。T的值要么为“0”,代表“或(or)”型监督,要么为“A”,代表“与(and)”型监督。监督者能够监督任意个数的工作者或监督者。对每一个被监督的实体,监督者都要知道如何来启动、停止和重启该实体。这种信息被保存在SSRS中,SSRS即“Start Stop andRestart Specification”(启动停止重启说明)。每个监督者(监督层次体系中的顶层监督者除外)都有且仅有一个监督者直接在它的上方,我们称直接上层监督者为直接下层的监督者的父亲(parent)。相反地,在监督层次体系中某个监督者直接下方的监督者为该监督者的孩子(children)。工作者被记作圆角矩形。工作者由乖函数来参数化。
图3为线性层次结构的或式监督树,每个监督者针对其每一个孩子都有一个SSRS,遵守下面的规则:
如果一个监督者被其父亲停止,那么该监督者将停止其所有的孩子。
如果一个监督者的任何一个孩子崩溃,那么该监督者将重启该孩子。
系统通过最顶层的监督者启动而启动。最顶层的监督者第一次启动时,需要用到SSRS1。顶层监督者有两个孩子,即一个工作者和一个监督者。顶层监督者启动一个工作者(为一个通过用乖函数WBF1进行参数化地behaviour),同时启动一个子监督者。层次体系中的下层监督者也是按照类似的方式启动起来,整个系统就跑起来了。
图4为AND/OR层次监督树。带记号“A”表示一个“与”监督者,带记号“0”表示一个“或”监督者。在一个与/或树中的监督者应遵循如下规则:
如果一个监督者被其父亲停止,那么该监督者将停止其所有的孩子。
如果一个监督者的一个孩子崩溃了,而自己是一个“与”监督者,那么该监督者将停止所有的孩子,然后重启所有的孩子。
如果一个监督者的一个孩子崩溃了,而自己是一个“或”监督者,那么该监督者将重启该孩子。
“与”型监督用于依赖性(dependent)或关联性(co-ordinate)的进程。在“与”型树中,系统运行的成功依赖于所有孩子的成功——因此,当有任何一个孩子崩溃时,就应该停止所有孩子并重启它们。
“或”型监督可以用来协调独立进程(independent process)的行为。在“或”型树中,孩子们的行为被认为是彼此独立的,所以一个孩子不会影响到其它孩子,因此一个孩子出错只需将该孩子进程或孩子设备重启。
图5:两个进程X、Y和一个协议检查器C。当X向Y发送一个消息Q(Q是一个询问)时,Y会以一个响应R和一个新状态S作为回应。值对{R,S}就可以用协议描述中的规则进行类型检查了。协议检查器C位于X和Y之间,根据协议描述对X和Y之间来往的所有消息进行检查。
【具体实施方式】
我们在设计中,采用了“一切皆进程”的核心理念,依托服务器硬件集群设计了SODBS。把软件所有要处理的事务划分成一系列层次化的任务,每个任务有一个“强隔离的进程”来执行,进程之间没有任何共享状态,只能通过“消息传递”来通信。这种“强隔离的进程”不仅可以更真实地描述现实世界的信息处理过程,还成为软件错误发生时保护系统的可靠性的最有力模型。另一个需要强调的思想是关于故障处理。由于业务处理都在一个个“强隔离的进程”中——我们把它们称为“工作者”,就防止了一个进程出错会传播到其他的进程。业务处理进程的运行状况由另外专门的进程来看护——我们把它们称为“监视者”。“工作者”和“监视者”组成一个层次化的监督模型,使得一个进程发生故障时,整个系统可以作出相应的调整,保障系统最大限度地提供服务。对于软件系统中behaviour(行为机制)库的设计思想,则是将程序的并发处理和顺序化处理分开。这样,我们把并发部分抽象出来,让系统从最初的“脆弱”演化到“可容错”。
金融类应用都是一些复杂、大型的程序,虽然经过了严密的测试,但是投入运行后还是难免会有错误产生,硬件也会有意外崩溃的可能。我们假设这些程序和硬件不可避免地会含有错误,进而寻求在软硬件包含错误的情况下构建可靠系统的方法。
我们主要关注点之一是在程序自身包含有错误的情况下如何构建出可靠的系统这一问题。构建这样的系统对所采用的任何一种编程语言都有一些特殊的要求。这里会讨论这些对语言的具体的特殊要求,并将展示本系统是如何满足这些要求的。
这些要求可以在编程语言中解决,也可以在语言所附带的标准库中解决。我们将论证在构建可容错系统时,哪些要求应该在语言中解决,而哪些要求可以重新构建标准库来解决。这些合起来构成了构建可容错软件系统SODBS的基础。
1绪论
我们如何去编写在有软件或者硬件错误的条件下有合理行为的软件呢?这是我们想要回答的核心问题。比如个人电脑的windows操作系统,正式发布后的每个月都会针对新发现的错误而发布一些新的补丁程序,大型的系统也是一样,往往在交付的时候还存在着许多软件错误,然而我们却奢望它们能够运行正常。
系统有不完善的环节,而我们又希望它可靠,这就对系统提出了一定的要求。这些要求是能够被满足的,要么在所采用的编程语言中,要么在应用程序所调用的标准库中。在此文档中,我们会列举出我们认为的可容错系统所必须具备的本质特性,我们还将展示这些特征在我们的系统中如何被满足。
某些本质特性是在我们的编程语言中被满足的,而另外一些则是在我们编写的库模块中被满足的。语言和库合起来构成了构建可靠软件系统的基础,使得即使存在编程错误,系统仍然能够按照合理的方式运行。
此文档关注的是软件的容错性在语言、库和操作系统方面的要求以及实现办法。我们用C语言搭建了一种纯消息传递平台——即一种基于独立性很强的并行进程的新描述性准函数语言,我们的编程模型广泛使用了速错(fail-fast)进程。这项技术在构建可容错系统的硬件平台中被普遍使用,但是在软件设计方面却用得不多。这主要是因为传统的编程语言并不允许不同的软件模块以彼此互不干扰的方式存在。当前普遍使用的是多线程的编程模型,该模型中资源是共享的,这样就造成了线程很难真正隔离起来,就可能导致一个线程中的错误会传播到另一个线程中,这就破坏了系统内部的坚固性。
2架构模型
这里我们提出了一个用于构建容错系统的软件架构。虽然每个人对于架构一词都有一个模糊的概念,但是这个词却几乎没有一个广为接受的定义,这就导致了很多误解。我认为如下定义对软件架构进行了比较全面的总结:
架构是一组有关软件系统组织方式的重要决策;是对系统构成元素、元素接口以及这些元素间协作行为方式的选择;是一种把这些结构和行为元素逐步组合为更大子系统的合成方式;也是一种构建风格,在其指导下把这些元素、元素接口、元素间的协作和合成组织起来。
2.1架构的定义
从最高的抽象层次上看,架构就是“一种思考世界的方式”。然而,从实用性的层次上看,我们就必需得把我们看待世界的方式转化为一本实用的手册和一组规程,它们可以告诉我们如何使用我们看待世界的特定方式来构造一个特定的系统。
我们的软件架构通过如下一些方面来刻画:
1.问题领域——我们的架构是为解决什么类型的问题而设计的?软件架构一定不是通用的,而是为解决某一类特定问题而设计的。缺少了关于用来解决哪类问题的描述的架构是不完整的。
2.哲学——软件构造方法背后的原理是什么?架构的核心思想是什么?
3.软件构造指南——我们如何来规划一个系统?我们需要一个明确的软件构造指南集。我们的系统将由一个程序员团队来编写和维护——所以对所有的程序员和系统设计者来说,理解系统的架构和它的潜在哲学是很重要的。从实用性的角度来讲,这些知识以软件构造指南的方式表现出来更便于维持。一个完整的软件构造指南集包括编程规则集、例子程序和培训资料等等。
4.预先定义好的部件——以“从一组预先定义好的部件中选择”的方式进行设计远比“从头设计”的方式要来得容易。我们的库包含了一个完整的现成部件集(称之behaviour库),一些常用的系统都可以使用这些部件构建起来。例如sodbs server这种behaviour就可以用来构建client-server系统,sodbs_event这种behaviour可以用来构建基于事件(event-based)的程序。
5.描述方式——我们如何描述某一部件的接口?我们如何描述系统中两个部件之间的通信协议?我们如何来描述系统中的静态和动态结构?为了回答这些问题,我们将介绍一些专门的符号。其中一些用来描述程序的API,而其他的则用来描述协议和系统结构。
6.配置方式——我们如何来启动、停止和配置我们的系统?我们可以在系统工作过程中进行重配置吗?
2.2问题领域
我们的系统平台是为开发金融软件而设计的。金融系统对可靠性和容错性有着苛刻的需求。金融系统需要“永久地”运行,必须有软实时的响应能力,当发生软件和硬件故障的时候要有合理的反应。我们总结了金融系统需要具有的十条属性要求。
1.系统必须能够应对超大量的并发活动。
2.必须在规定的时刻或规定的时间内完成任务。
3.系统应该可以跨计算机分布运行。
4.系统要能够控制硬件。
5.软件系统往往很庞大。
6.系统要具有复杂的功能,例如:特性冲突。
7.系统应该能不间断运行许多年。
8.软件维护(例如重配置等)应该能在不停止系统的情况下进行。
9.满足苛刻的质量和可靠性需求。
10.必须提供容错功能,包括硬件失灵和软件错误。
我们可以对上述需求作出如下分析:
并发(concurrency)——金融系统天生就应该是并发的,因为对于金融设备来说,经常同时有数以万计的用户在与金融设备进行交互。这就意味着金融系统必须能够有效地处理成千上万的并发活动。
软实时(soft real-time)——在金融系统中,很多操作必须要在规定的时间内完成。其中有些操作是严格要求实时的,也就是说如果给定的操作在给定的时段里没有执行完,整个操作就被取消。而有些操作只是受到某种形式的定时器的监视,如果定时器超时而操作尚未完成,则重新执行一遍。
编写这样的系统,就需要有效地管理起数以万计的定时器。
分布式(distributed)——金融系统并不是天生分布式的,我们的系统应该以一种便于从单节点系统(single-node system)向多节点分布式系统(multi-node distributed system)转变的方式来创建。
硬件交互(hardware interaction)——金融系统有大量的外围硬件需要控制和监控。这就意味着要能够写出高效的设备驱动程序,并且不同的设备驱动之间进行上下文切换也要高效。
大型软件系统(large software systems)——金融系统都很庞大,这就意味着金融软件系统必须在源代码达到数百万行的时候也能工作。
复杂的功能(complex functionality)——金融系统都有着复杂的功能。市场的压力迫使系统的开发和部署要具有许多复杂的特性。通常,在这些特性之间的相互影响还没有被很好的理解的情况下,就必须得部署系统。在系统的运行期间,这些特性集很可能需要以多种方式进行修改和扩展。功能和软件的升级必须“就地进行”,也就是说,不能够让系统停下来。
持续运行(continuous operation)——金融系统要设计成可以持续运行许多年。这就意味着在系统不停下来的情况下进行软件和硬件的维护(通常要求在40年里停机时间不超过2小时)。
高质量要求(quality requirements)——即使在发生错误时,金融系统也应该提供可接受的服务。特别是银行存取款设备,可靠性要求极高。
容错性(fault tolerance)——金融系统应该是“容错”的。即从开始我们就知道会发生故障,但是我们必须设计出一些可以处理这些错误的软件和硬件基础设施,并在发生故障的时候仍然能够提供可接受的服务。
虽然这些需求最初是来自金融界,但决不仅仅适用于该特定问题领域。许多现代互联网服务(例如电子商务服务器)就有着非常相似的需求列表。
2.3哲学
我们怎么才能够构建出在软件存在错误的时候具有合理行为的可容错的软件系统呢?这是此文档余下部分要回答的问题。我们先给出一个简洁的答案,在本文的剩余部分会对其进行细化。
为了构建出在软件存在错误的时候仍具有合理行为的可容错软件系统,我们做了如下这些事情:
我们将软件组织成一个系统要完成的任务的层次结构,每一个任务对应于一组目标,具有给定任务的软件必须尝试去完成和该任务相关的目标。
所有任务按照复杂性排序。最顶层的任务最复杂。如果最顶层任务完的目标都被完成,那么整个系统就运转正常。较低层次的任务应当能够保持系统以一种可接受的方式运转,即使系统所提供的服务有所折扣。系统中低层任务较高层任务更容易完成其目标。我们将尽力完成顶层的任务。
当在完成某一目标的过程中检测到了一个错误,我们将尝试纠正这个错误。当我们不能够纠正该错误的时候,我们将立即取消当前的任务而启动一个更简单一些的任务。
编写这样一个任务层次需要一套强有力的封装方法。我们需要强有力的封装方法来隔离错误。我们不想再去编写那种系统中的一个部分发生的错误会对其他部分产生不利影响的系统。
我们需要以一种能够检测到在试图完成目标时所发生的所有错误的方式,来隔离为了完成某一目标而编写的所有代码。并且,当我们在试图同时完成多个目标时,我们不希望系统中某个部分所发发生的错误,会传播到系统的另外一个部分中。
因此,在构建可容错软件系统的过程中要解决的本质问题就是故障隔离。不同的程序员会编写不同的模块,有的模块正确,有的存在错误。我们不希望有错误的模块对没有错误的模块产生任何不利的影响。
为了提供这种故障隔离机制,我们采用了传统操作系统中进程的概念。进程提供了保护区域,一个进程出错,不会影响到其他进程的运行。不同程序员编写的不同应用程序分别跑在不同的进程中;一个应用程序的错误不会对系统中运行的其他应用程序产生副作用。
这种选择当然满足了初步的要求。然而因为所有进程使用同一片CPU、同一块物理内存,所以当不同进程争抢CPU资源或者使用大量内存的时候,还是可能对系统中的其他进程产生负面影响。进程间的相互冲突程度取决于操作系统的设计特性。
在我们的系统中,进程和并发编程是语言的一部分,而不是由宿主操作系统提供的。这样做比直接采用操作系统进程拥有很多优势:
并发程序可以一致地运行在不同的操作系统上——不同的特定操作系中是如何实现进程的不会对我们造成限制。我们的程序运行在不同的操作系统和处理器上唯一可见的差异就是CPU的处理速度和内存的大小。
所有的同步问题和进程间通信都应当跟宿主的操作系统的特性没有一点关系。
我们这种基于语言的进程比传统的操作系统进程要轻量得多。在我们的语言里,创建一个进程是非常高效的,要比大多数操作系统中进程的创建快几个数量级,甚至比大多数语言中线程的创建都快几个数量级。
我们的系统对操作系统的要求非常少。我们只用了操作系统很小的一部分服务,所以把我们的系统移植到譬如嵌入式系统等特定环境下是相当简单的。
我们的应用程序是通过大量互相通信的并行进程构建起来的。我们采用这种方式是因为:
它提供了一个架构基础设施——我们可以用一组相互通信的进程组织起我们的系统。通过枚举出系统中的所有进程,并定义出进程间消息传递的通道,我们就可以很方便地把系统划分成定义良好的子部件,并可以对这些子部件进行单独实现和测试。这种方法学也是SDL系统设计方法学的最高境界。
巨大的潜在效率——设计成以许多独立的并行进程来实现的系统,可以很方便地实现在多处理器上,或者运行在分布式的处理器网络上。注意,这种效率的提升只是潜在的,只有当应用程序可以被分解成许多真正独立的任务时,才能产生实效。如果任务之间有很强的数据依赖,这种提升往往是不可能的。
故障隔离——没有共享数据的并发进程提供了一种强大的故障隔离方法。一个并发进程的软件错误不会影响到系统中其他进程的运行。在并发的这三种用法中,前两条并不是其本质特性,可以由某种内置的调度程序通过在进程间提供不同的伪并行(pseudo-parallel)时分方式来获得。
第三个特性对于编写可容错系统的软件来说,则是本质性的。每一项独立的活动都在一个完全独立的进程中来执行。这些进程没有共享数据,进程之间只通过消息传递的方式进行通信,这就限制了软件错误造成的影响。一旦进程之间共享有任何公共资源,譬如内存,或指向内存的指针,或互斥体等等,一个进程中的一个软件错误破坏共享资源的可能性就存在。因为消除大型软件系统中的这类软件错误仍然是一个未解的难题,所以我们认为构建大型的可靠系统的唯一现实的方法就是把系统分解成许多独立的并行进程,并为监控和重启这些进程提供一些机制。
2.4支持高并发的系统
在我们的系统中,并发扮演着核心角色,它是如此核心以至于我们塑造了面向并发编程(Concurrency Oriented Programming)这个术语,以把这种编程风格和其他编程风格区分开来。
在面向并发编程中,程序的并发结构应该遵循应用本身的并发结构。这种编程风格特别适用于编写那些对现实世界建模或与现实世界进行交互的应用程序。
面向并发编程同样也具有面向对象编程的两个主要优点。即多态(polymorphism)以及使用预先定义的协议使得不同进程类型的实例之间可以具有相同的消息传递接口。
当我们把一个问题分解成许多并发进程的时候,我们可以让所有的进程响应同一种消息(即多态),并且可以让所有的进程都遵循相同的消息传递接口。并发一词是指同时发生的活动集合。现实世界就是并发的,是由无数同时发生的活动组成的。在微观上看,我们自己的身体就是由同时运动着的原子、分子组成的。从宏观上看,整个宇宙也是由同时运动着的星系组成的。
我们做一件简单的事情的时候,譬如在高速公路上开车时,我们能觉察到身边行驶着飞速的车流,但是我们一样能够完成开车这一复杂的任务,并且可以不假思索就避开潜在的危险。在现实世界中,顺序化的(sequential)活动非常罕见。当我们走在大街上的时候,如果只看到一件事情发生的话我们一定 会感到不可思议,我们期望碰到许多同时进行的活动。
如果我们不能对同时发生的众多事件所造成的结果进行分析和预测的话,那么我们将会面临巨大的危险,像开车这类的任务我们就不可能完成了。事实上我们是可以做那些需要处理大量并发信息的事情的,这也表明我们本来就是具有很多感知机制的,正是这些机制让我们能够本能地理解并发,而无需有意识地思考。
然而对于计算机编程来说,情况却突然变得相反。把活动安排成一个顺序发生的事件链被视为是一种规范,并认为在某种意义上讲这样更简单,而把程序安排成一组并发活动则是要尽可能避免的,并常常认为会困难一些。我相信这是由于几乎所有传统的编程语言对真正的并发缺乏有力支持造成的。绝大多数的编程语言本质上都是顺序化的;在这些编程语言中所有的并发性都仅仅由底层操作系统来提供,而不是由编程语言来提供。
在此文档中,我展现了这样的一个世界,其中并发是由编程语言来提供的,而不是由底层操作系统来提供。我把对并发提供良好支持的语言称为面向并发的语言(ConcurencyOriented Language),简称COPL。
2.4.1基于现实世界编程
我们常常想编写一些对现实世界进行建模或者和其交互的程序。用COPL编写这样的程序相当容易。首先,我们来进行一个分析,它有三个步骤:
1.从真实世界中的活动中识别出真正的并发活动;
2.识别出并发活动之间的所有消息通道;
3.写下能够在不同的消息通道中流通的所有消息;
然后我们来编写程序。程序的结构要严格保持与问题的结构一致,即每一个真实世界里的活动都严格映射到我们编程语言中的一个并发进程上。如果从问题到程序的映射比例为1∶1,我们就说程序与问题是同构(isomorphic)的。映射比例为1∶1这一点非常重要。因为这样可以使得问题和解之间的概念隔阂最小化。如果比例不为1∶1,程序就会很快退化而变得难以理解。在使用非面向并发的编程语言来解决并发问题时,这种退化是非常常见的。在非面向并发的编程语言中,为了解决一个问题,通常要由同一个语言级的线程或进程来强制控制多个独立的活动,这就必然导致清晰性的损失,并且会使程序滋生复杂的、难以复现的错误。
在分析问题时,我们还必须为我们的模型选择一个合适的粒度。比如,我们在编写一个即时通信系统(instant messaging system)时,我们使用每个用户一个进程的方式,而不是将用户身上的每一个原子对应到一个进程。
2.4.2COPL的特征
COPL可以由如下6个特性来刻画:
1.COPL应当支持进程。每一个进程应该可以看作是一个自包含的虚拟机器(self-contained virtual machine)。
2.运行在同一机器上的各个进程应该被高度隔离。一个进程中的故障不能对其他进程产生副作用,除非这种交互在程序中被明确化。
3.每个进程必须用一个唯一的、不可仿造的标识符来标识。我们称之为进程的Pid。
4.进程之间没有共享状态。进程只通过消息传递来进行交互。只要知道进程的Pid,就可以向它发消息。
5.消息传递被认为是不可靠的,无传输保障的。
6.一个进程应当可以检测另一个进程中的故障,并可以知道发生故障的原因。值得注意的是,COPL提供的并发性一定是真正的并发性,因此以进程的形式存在的对象都是真正并发的,进程间的消息传递也是真正的异步消息,而不像许多面向对象语言中一样是通过远程过程调用(remote procedure call)来冒充。
还应当注意,故障的原因并不总是正确的。例如,在一个分布式系统中,我们可能收到进程已经死亡的通知消息,然而事实上是发生了一个网络错误。
2.4.3进程隔离
对理解COP和创建可容错软件来说,一个核心的概念就是进程隔离(isolation),同一台计算机上运行的两个进程,应当如同分别独立运行在物理上分离的两台计算机上一样。
理想的架构当然是面向并发的程序的每一个进程都给分配一个专用的处理器。但是在理想成为现实之前,我们不得不面对的事实是多个进程要运行在同一台计算机上。然而我们仍然应当认为所有的进程都运行在物理上独立的计算机上。
进程隔离有着许多好处:
1.进程具有“不共享任何资源”的语意。这一点很明显,因为进程被认为是运行在物理上独立的计算机上的。
2.消息传递是进程之间传递数据的唯一方式。因为进程之间没有任何共享资源,进程间交互数据只能采用这种方式。
3.进程隔离意味着消息传递必须是异步的。如果进程通信采用同步方式,那么当消息的接收者偶然发生一个软件错误时,就会永久阻塞住消息的发送者,破坏了隔离的特性。
4.没有共享资源,所以进行分布式计算所需的任何数据都必须通过拷贝。因为没有共享资源,进程间的交互只能通过消息传递,所以我们也不会知道消息什么时候到达接收者(我们说过消息传递是天生不可靠的)。知道消息是否被正确送达的唯一方法就是发送一个确认消息回来。
乍一看,要编写一个满足上述规定的多进程系统是很困难的——毕竟在针对大多数顺序化编程语言所做的并发扩展中,几乎提供了完全相反的功能,诸如锁、信号量、共享数据保护以及可靠消息传递。幸运的是,我们这种相反的做法被证明是正确的——编写一个这样的系统简单得出奇,并且所编写的程序不费吹灰之力就可以变得可伸缩,变得可容错。
因为所有的进程都要求完全独立,所以增加新的进程不会对原系统产生影响。因为整个软件就是一组独立的进程的集合,因此无需对应用软件作大的更改就容纳更多的处理器。
因为没有对消息传递的可靠性加以任何假设,所以我们写的应用程序在消息传递并不可靠的时候必须一样可以工作,在消息传递发生错误的时候也一样要能够工作。我们这样做了以后,当我们需要向上伸缩我们的系统的时候,就会得到回报。
2.4.4进程的名字
我们要求所有进程的名字都是不可仿造的。这就意味着不可能猜测一个进程的名字,从而与之交互。我们假设所有的进程都知道它们自己的名字,以及由它们所创建的其他进程的名字。也就是说,父进程知道其子进程的名字。
要想使用COPL进行编程,我们就需要一种机制来找到相关进程的名字。一旦我们知道了一个进程的名字,我们就可以给它发消息。
系统的安全性与进程名的获取方法是密切相连的。如果别人不知道进程的名字,就没有任何方法可以与之交互,这个系统也就是安全的了。一旦进程的名字广为外界所知,这个系统的安全性就削弱了。我们把以受控的方式向其他进程透露名字的过程称为名字散布问题(name distribution problem)——系统安全性的
关键就在于名字散布问题。当我们把一个Pid透露给另外一个进程,我们就说我们公布了该进程的名字。如果一个进程的名字从未被公布过,就不会存在安全性问题了。
因此,获取进程的名字是安全性的关键因素。因为进程名是不可仿造的,所以只要我们能够将关于进程名字的知识限制在可信进程的范围内,我们的系统就肯定是安全的。
在许多古老的宗教信仰中,人们都相信人类可以通过灵魂的真名来支配灵魂,以获得超越灵魂的力量。一旦获知了灵魂的真名,就可以获得超越它的力量,并且可以用这个真名来驱使灵魂去做很多事。COPL采用的是相同的思想。
2.4.5消息传递
消息传递须遵循如下规则:
1.消息传递当是原子化的(atomic),意思是一个消息要么整个儿被传递,要么根本就不传递。
2.一对进程之间的消息传递是有序的,意思是当在任何一对进程之间进行消息序列的收发时,消息被接收的顺序与对方发送的顺序相同。
3.消息不能包含指向进程中的数据结构的指针——它们只能够包含常量和(或)Pid。注意,第2点只是一个设计决策,并没有对用来传送消息的网络的基础语意作任何反映。下层的传输网络可能将消息重新排序,但是对于任一对进程来讲,这些消息在被交付前会被进行缓存和重组,以使它们形成正确的顺序。比起硬要允许消息按任意顺序传递来,这种假设可以使得编写消息传递的应用程序要容易得多。
我们说这种消息传递具有发送并祈祷(send and pray)之义。我们发送一条消息以后,就祈祷它能够到达对方。一旦收到对方发送回来的确认消息(有时候也叫做往返确认),就可以确认消息已经送达对方。
消息传递还可以用于同步(synchronisation)。假设我们希望同步两个进程A和B。如果A向B发送了一条消息,那么B只能在A发送了这个消息之后的某个时间点才收到该消息。这一点就是分布式系统理论里的因果次序(casual ordering)。在COPL中,所有的进程间同步都是基于这一简单的思想。
2.4.6协议
部件之间隔离,采用消息传递的交互方式,这在架构上对于保护系统免受错误影响来说是足够了。但是对于说明系统的行为来说,是不够的,对于在发生了某种错误时判断到底是哪个部件出了错也是不够的。
到目前为止,我们都只是假设了单个部件出错,单个部件要么就正常运行,要么死了就死了。然而,实际会发生的情况是可能没有观察到有部件死掉,而系统却已没有如期地工作。
为了完善我们的模型,我们添加了一些新的东西。我们不仅需要部件的完全独立性,部件之间只通过消息传递进程交互,我们也需要制定部件之间相互通信所采用的协议。
通过制定出通信协议,如果遵循该协议进行通信的两个部件中一旦有谁违犯了协议,我们就可以很容易地识别出来。我们可以通过对程序的静态分析——如果可能的话,还可以把运行时检查编译到生成码中,以便当静态分析失效时也报告错误——来保证协议被贯彻了。
2.4.7COP与程序员团队
构建大的软件系统需要许多程序员的共同努力,有时候甚至达到好几百人。为了把这么多人的工作都协调起来,通常是把程序员组织成小一些的开发小组或团队,每个小组负责系统中的一个或多个逻辑部件。日复一日,各个小组之间通过消息传递(如email或电话)来进行交流,而不必频繁地见面。在某些情况下,开发小组分布在不同的国家,从来都不见面。我们发现不仅仅是软件系统因各种原因需要被组织成独立的部件,各部件以纯消息传递的方式进行通信,而且这也是大型软件开发群体的组织方式。
2.5系统需求
为了支持面向并发的编程风格,为了构建满足金融系统需求的软件,我们对系统的根本特性提出了一组需求。这些需求对于系统来说,是一个整体——我并不关心这些需求是由编程语言来满足,还是由语言所附带的库或创建方法来满足。
我们对下层的操作系统和编程语言有6条根本需求。
R1.并发性——我们的系统必须支持并发性。创建或销毁一个并发进程的计算开销一定要非常小,即使创建大量的并发进程,也不应当带来灾难。
R2.错误封装——一个进程中发生的错误一定不能破坏系统中其他的进程。
R3.故障检测——一定要可以检测到本地异常(本地进程中发生的异常)和远程异常(非本地进程中发生的异常)。
R4.故障识别——我们要能够识别出异常产生的原因。
R5.代码升级——要有某种机制来替换执行中的代码,而不必停下系统。
R6.持久存储——我们需要把数据按某种策略存储下来,以便恢复一个已经崩溃的系统。
还有一点非常重要,即为了满足上述需求所采用的实现方式一定要高效——如果不能够可靠地创建几十万个进程,那么并发性就没什么大用;如果故障报告中没有包含足够的信息使得随后可以纠正故障,那么故障识别也就没有什么大用。
上述需求的实现方式可以是多种多样。譬如并发性,既能够由语言原语来提供,也能够由操作系统来提供(例如Unix)。像C和Java之类的语言本身并不是面向并发的,但是可以利用操作系统的那些让人觉得可以达到并发性的原语来获得并发性。确实,并发程序可以由本身并不具备并发性的语言来编写。
2.6语言需求
用来编写并行系统的编程语言必须包括:
封装原语——语言必须有多种手段来限制错误的蔓延。应当可以把一个进程隔离起来,免得它会破坏其他进程。
并发性——语言必须提供一种轻量化的机制来创建并行进程,以及在进程间发送消息。进程的上下文切换、消息传递必须非常高效。并行进程
还必须以一种合理的方式来分享CPU时间,以便当前使用CPU的进程不至于垄断CPU,而其他的进程处于“准备好”状态而得不到处理。
错误检测原语——语言应当允许一个进程监控另一个进程,从而检测被监控进程是否因任何原因而终止。
位置透明——如果我们知道了一个进程的Pid,我们就应该可以向它发送消息,无论它是本地还是远程的。
动态代码升级——应该可以动态替换运行时系统中的代码。注意,因为许多进程可能同时按照同一份代码在运行,所以我们需要一种机制,来允许现有的进程按照“老”的代码运行,而同时“新”进程按照修改后的代码运行。
上述对于编程语言的需求不仅要被满足,而且要以一种合理有效的方式被满足。当我们编程的时候,不希望我们的表达自由受到诸如进程数目之类的限制,我们也不希望担心当一个进程试图垄断CPU时会发生什么事情。
系统中进程个数的上限应该足够大,以便我们编程时不用把进程的个数作为一个限制因素来考虑。例如,为了构建一个处理1万个并行用户会话的支付系统,我们可能需要创建多达10万个进程3。
上述6条特性对于简化应用程序的编写是必要的。如果我们能够将问题的并发结构以1∶1的方式映射到解决该问题的应用程序的进程结构上的话,我们把语义上一组分布式的交互部件映射到程序中的过程就会极大地简化。
2.7库需求
语言并不是无所不能的——许多东西是由我们开发的系统库提供的。程序库必须提供:
持久存储——由它存储用于故障恢复的信息。
设备驱动程序——这些程序提供了一种与外界交互的机制。
代码升级——它允许我们升级运行系统中的代码。
运行基础——它解决系统的启动、停止和错误报告问题。
观察一下我们的程序库,不难看出它们虽然是用C编写的,但是它们提供的服务都是本来可以由操作系统很方便地提供的服务。
因为SODBS的进程是彼此隔离的,只以消息传递的方式彼此通信,所以它们的行为就非常像操作系统的进程,后者是通过管道(pipe)和套接字(socket)进行通信。
本来可以很方便就由操作系统提供的许多特性被移到了编程语言中,于是操作系统就只需要提供设备驱动的一组原语就够了。
2.8应用程序库
持久化存储等特性并不是作为Sodbsng的语言原语来提供的,而是由基本SODBS库来提供。这个基本库是构建一个复杂的应用软件的前提条件。更复杂的应用需要比持久化存储等层次更高的抽象。为了构建这样的应用程序,我们需要一些现成的软件实体来辅助我们编写诸如客户-服务器式(client-server)的程序。
SODBS库就给我们提供了用来构建可容错系统的一个完整的设计模式(我们称之为behaviour)库。此文档中我会介绍behaviour库的一个最小集,可以用它们来构建可容错的应用软件,它们是:
supervisor——一个监督模型behaviour。
sodbs_server——一种用于实现客户-服务器式应用程序的behaviour。
sodbs_event——一种用于实现事件处理式应用程序的behaviour。
sodbs_fsm——一种用于实现有限状态机的behaviour。
这些库程序当中,用于编写可容错应用软件的核心部件就是那个监督模型。
2.9相关的工作
各个软件部件不能很好地彼此隔离,是许多流行的编程语言不能够用来构建健壮的软件的主要原因。
安全性的本质,在于要能够将互不信任的程序隔离起来,在于要保护基本平台不受这些程序的破坏。隔离在面向对象系统中是相当困难的,因为对象很容易被别名化(aliased)。
对象的别名化是很难缠的,而且在实际编程中不可能被检测到,建议使用保护域(protection domains)(类似于操作系统的进程)来解决这一问题。
在同一台计算机上执行多个用Java编写的应用程序的唯一安全的方式,是给每一个应用程序开一个JVM,并且每一个JVM运行在一个单独的OS进程中。这样又会造成资源利用的效率方面的大大下降,会引起性能、伸缩性、程序启动时间等方面的恶化。这样一来,Java语言所提供的好处就只剩下可移植性和提升程序员的生产力了。这些固然重要,但是语言提供的所有潜在安全性并没有完全被实现。事实是,在“语言安全性”与“真实安全性”之间存在着离奇的差异。
把JVM变成一个类似于OS的执行环境。尤其是现代OS所提供的进程抽象,也就是基于特性的角色模型;计算之间的相互隔离;资源的审计和控制以及资源的终止和回收。
为了达到这一点:
任务不得直接共享对象,任务之间通信的唯一方式是使用标准的、拷贝式的通信机制
与硬件系统一样,软件的容错性关键在于把大的系统逐级分解成模块,每一个模块既是提供服务的最小单位,也是发生故障的最小单位,一个模块的故障不会传播到模块之外。
进程要想达到容错性,就不能与其他进程有共享状态;它与其他进程的唯一联系就是由内核消息系统传递的消息。在RIG6的实现过程中的基本设计决策就是采用了一种没有共享数据结构的严格的消息规范。用户与服务器之间所有的通信消息都是通过Aleph内核来路由的。这种消息规范被证明是非常灵活、可靠的。——暂时撇开语言不管,让我们想想一个单独的进程应该具备哪些性质呢?
一个硬件系统如果要适合在其上缔造可容错系统应该具备3条性质。这些性质称作:
1.故障即停(Halt on failure)——当一个处理器出错时,应当立即停止下来,而不是继续执行可能不正确的操作。
2.故障曝光性质(Failure status property)——当一个处理器发生故障时,系统中的其他处理器应该得到通知,故障的原因必须交代清楚。
3.持久存储性质(Stable storage property)——处理器的存储器应当分为持久存储器(stable storage,处理器崩掉时依然存在)和临时存储器(volatilestorage,处理器崩掉就没了)。
具备这些性质的处理器称为错即停处理器(fail-stop processor)。其思想就是一旦错误发生,就没有必要继续运行了。出错的处理应该停下来,以免继续执行会引起更大的破坏。在一个错即停处理器中,状态存储在临时或持久处理器里。当处理器崩溃时,临时存储器中的所有数据将丢失掉,而持久存储器中的所有数据在崩溃后仍然可以使用。
用进程的方法达到故障隔离的思想提倡每个进程都是速错的,要么它就正确地运行着,要么它就应该检测到错误,报告错误并停止运行。
进程以防护性编程(defensive programming)的方式达到“速错”。它们对其所有的输入参数、中间结果和数据结构进行例行检查。一旦检测到错误,就立即报告该错误并停止运行。即速错软件具有很短的检测潜伏期(detection latency)。
这两个思想本质是一样的;只不过一个说的是硬件,一个说的是软件,但是其根本原则如出一辙。当进程发生不可校正的错误时应该尽快停下来这一点非常重要:
一个软件系统中的一个错误可能会引起一个或更多其他错误。从故障发生到其被检测到的间隔时间——即潜伏时间——越长,代价就会越大,因为这样会增加对故障进行回退分析的复杂性……为了有效地处理错误,我们应该尽早地检测到错误并停下来。
综合以上这些意见和我们的原始需求,我们规划了本系统应该具备如下一些性质:
1.以进程作为错误封装的单位——即一个进程中发生的错误不会影响到系统中其他的进程。我们称这一性质为强隔离(strong isolation)。
2.进程要么就规规矩矩地运行,要么就痛痛快快地停掉。
3.故障和故障原因应该可以被其他进程检测到。
4.进程之间没有共享状态,唯以消息传递的方式通信。
要想一个编程语言或平台具有如上这些性质,并可以用来构建可容错的软件系统,还需要具备一些必要的前提条件。我们将看到这些性质是如何在SODBS及其编程库中被满足的。
尽管编译器检查和由编程语言提供的异常处理确实有用,但是从历史上看,人们似乎更偏向于用运行时检查加进程的方式来达到故障封闭的目标。因为这种方式具有简单性这一优势——一旦一个进程或它的处理器出错,只管停下它!这种方式中进程就充当了一种干净的模块单位、服务单位、容错单位、出错单位的角色。
故障被限制在速错的软件模块之内。
进程因为与其他进程没有任何共享状态所以具有容错性;进程与其他进程联系的唯一方式就是通过内核消息系统发送的消息。
如果我们将这些观点与我们现在的SODBS系统比较我们会发现许多惊人的相似。当然也有些不同之处——在SODBS中并不建议使用“防护性编程”的风格,因为编译器增加了一些必要的检测,使得这种编程风格并无必要。“事务机制”由sodbs_db数据库来提供。而错误限制和处理则由SODBS库中的“监督树”behaviour来完成。
“‘速错’模块”的思想对应于我们的编程指导方针,在我们的编程指导方针里,我们说进程应该严格按照我们期望的方式运行,否则就应该停掉。我们的系统中的监督层次结构对应于模块的层次结构。应当允许软件部件崩掉然后重启它,这样会简化故障模型,并有利于保证代码的可靠性。
现代面向对象系统方面的工作也越来越认识到使软件部件彼此隔离的重要性。
3 SODBS
这里简单介绍一下SODBS设计思想。SODBS的开发工具属于面向消息的语言(message-oriented language)一类——面向消息的语言都是通过并行进程的方式提供并发性的。在面向消息的语言中,没有任何共享的对象,取而代之的是进程之间以收发消息来达到交互。
3.1概览
SODBS的世界观可以归纳为如下的一些观念:
一切皆进程。
进程强隔离。
进程的生成和销毁都是轻量的操作。
消息传递是进程交互的唯一方式。
每个进程有其独有的名字。
你若知道进程的名字,你就可以给它发消息。
进程之间不共享资源。
错误处理非本地化。
进程要么好好跑,要么迅速停止。
把进程作为抽象的基本单位,缘于期望设计出一种适合编写大型的可容错的软件系统的语言。编写这类软件要解决的一个基本问题就是要限制错误的传播——进程的抽象正好提供了一种阻止错误传播的抽象边界。
例如,Java就对于限制错误传播无能为力的,所以Java不适合用来编写“安全的”应用程序。
如果进程真正是隔离的(必须做到对错误传播的限制),那么进程的其他性质——例如只能以消息传递的方式进行交互——就顺理成章地成为这种隔离性的结果。关于错误处理的观点似乎并不明显。当我们构建一个可容错系统时,我们需要至少两台物理上独立的计算机。只用一台计算机是不成的,一旦它崩溃了,就什么东西都没有了。我们能够想象的最简单的可容错系统也由两台计算机组成,
如果一台崩溃了,另外一台就可以接过第一台的所有工作。在这种最简单的情形下,也要求故障恢复软件做到非本地化;故障发生在第一台计算机上,而由运行在第二台计算机上的软件来纠正该错误。
SODBS的世界观就是“万物皆进程”,当我们把真实的计算机也模拟成进程时,我们就得到了错误处理应该非本地化这一思想。其实,这是一个修正后的事实,远程错误处理只有在本地尝试修复错误失败的情况下才会发生。如果有异常发生,一个本地进程应该可以检测到它并纠正它所造成的故障,在这种情况下对于系统中所有其他进程来说,根本感觉不到异常的发生。
如果把SODBS看作是一种并发语言,它是非常简单的。因为没有共享数据结构,没有监视(monitor)或同步方法,所以需要学习的东西很少。语言的主体部分,或许也是最平淡无奇的部分,就是这种语言的顺序化(sequential)子集。这个顺序化子集可以用一种动态类型、严格的函数式编程来刻画,而函数式编程是完全没有副作用的。在这个顺序化子集里,有少数操作是有一些副作用的,但是事实上这些操作不是必需的。
3.2并发(concurrent)编程
在SODBS中,可以通过调用spawn原语来创建并行进程,表达式如:
Pid=spawn(F)
这里F是一个参数个数为0的函数,该表达式创建了一个对F求值的并行进程。spawn返回一个进程标识符(Pid),通过Pid我们可以访问该进程。
语句“Pid!Msg”表示将一个消息Msg发送给进程Pid。消息可以用receive原语来接收,语法形式如下:
receive
Msg1[when Guard1]->
Expr_seq1;
Msg2[when Guard2]->
Expr_seq2;
...
MsgN[when GuardN]->
Expr_seqN;
...
[;after TimeOutTime->
Timeout_Expr_seq]
end
Msg1…MsgN都是模式,模式也可能带有保护式。当向一个进程发送一个消息时,该消息就被放进一个属于该进程的邮箱(mailbox)中。下次进程对receive语句进行求值的时候,系统就会查看一下邮箱,并且试图拿邮箱中的第1条消息与当前receive语句中的所有模式进行匹配。如果邮箱中收到的消息没有与任何模式匹配成功,则该消息就被转移到一个临时的“保管”队列中,进程被挂起,等待下一条消息。如果消息匹配成功,且与之匹配的模式所带的保护式也取真的话,该模式后面的语句系列就会依次被求值。同时,所有被临时保管的消息也被放回到进程的邮箱中。
receive语句可以有一个可选择的超时值。如果在超时期限内没有收到可配的消息的话,超时条件下面的表达式就会被求值。
3.4注册进程名
当我们想向一个进程发送消息的时候,我们需要知道该进程的名字。这是很安全的,但是当我们要向一个给定的进程发送消息时必须设法获取该进程的名字,这一点某种程度上会带来一些不便。
如下表达式:
register(Name,Pid)
会创建一个全局进程,并把原子Name与继承标识符Pid关联起来。这样就可以通过调用“Name!Msg”来给进程Pid发送消息。
3.5错误处理
在SODBS里求取一个函数的值一定只有两种结果:要么函数就返回一个值,要么它就产生一个异常。
异常可以隐式地产生(即由SODBS运行时系统产生),也可以通过调用exit(X)原语来显式地产生。下面是一个隐式地产生异常的一个例子,假设我们写一个函数如:
factorial(0)->1;
factorial(N)->N*factorial(N-1).
求值factorial(10)将返回一个值3628800,但是如果求取factorial(abc)的值,则将产生一个异常{‘EXIT’,{badarith,…}。异常会引起程序停下正在执行的操作转而去做其他的事情——这就是它们被称作异常的原因。
3.5.1异常
异常是为SODBS运行时系统所检测到的一种非正常状态。SODBS程序是被编译成虚拟机指令并且由一个虚拟机仿真器来执行的。而虚拟机仿真器是SODBS运行时系统的一部分。一旦仿真器检测到某种不知所措的状态,它就会产生一个异常。一共有6种类型的异常:
1.值错误(value error)——就是诸如“被0除”之类的错误。这种情况下传给函数的参数的类型是正确的,但是值错了。
2.类型错误(type error)——这类错误是指调用SODBS的内置函数的时候所填的参数类型不正确。例如,有一个内置函数为atom_to_list(A),是将原子A转换成其ASCII码的一个整数列表。如果变量A并不是一个原子,运行时系统就会产生一个异常。
3.模式匹配错误(pattern-matching error)——这类错误是指试图将一个数据结构与一些模式进行匹配,却找不到匹配成功的模式的错误。这种错误会在函数头匹配时产生,或者在诸如case,receive或if语句中进行匹配时产生。
4.显式调用exit(explicit exits)——这类错误是在显式调用表达式exit(Why)时产生的,该调用会产生一个Why异常。
5.错误传播(error propagation)——如果一个进程收到一个exit信号,它可以选择停掉自己并把该exit信号传播给所有它连接着的进程。
6.系统异常(system exception)——运行时系统也许会因为内存耗尽或检测到一个内部表不一致时终结掉一个进程。这类错误不在程序员的控制范围之内。
3.5.2进程连接与监视者
一旦一个进程死掉,我们希望其他的进程得到通知。回想一下我们说过我们需要这一点来编写一个可容错系统。有两种方式可以做到这一点,我们可以用进程连接或进程监视者。
进程连接是将一组进程聚合在一起的一种方式,在进程连接中,任意一个进程中发生了错误,其他所有的进程都将连带被停掉。
进程监视者是用一个单独的进程来监视系统中的所有其他的进程。
进程连接
catch原语用于截获一个进程中发生的错误。那我们现在来问一问,如果程序的顶层catch都不设法修正一个它所检测到的错误的话,会发生什么事情呢?
答案是该进程将终止。
出错的原因只是异常的一个参数。当一个进程出错时,出错的原因将被广播给它所归属的一个所谓“连接集”(link set)的所有其他进程。进程A可以通过调用内置函数link(B)将B加入到它的连接集中。进程之间的连接是对称的,也就是说,如果A连接到了B,那么B也连接到了A。
连接也可以在进程被创建的时候创建。如果A通过下面的调用方式来创建进程B:
B=spawn_link(fun()->...end),
那么进程B在创建的时候就连接到了进程A。这种调用方法在语义上等价于先调用spawn紧接着调用link,只不过这两个表达式是一起执行的,不是分步的。spawn_link原语的引入,是为了规避进程在创建的过程中还没有来得及执行link语句就死掉这种罕见的编程错误9。
如果进程P死掉的时候产生了一个{‘EXIT’,Why}的未捕获异常,那么退出信号{‘EXIT’,P,Why}就会被发送给进程P的连接集中的所有进程。我刚刚提到“信号”。信号是进程终止的时候在进程之间传递的一种东西。
信号是一个{‘EXIT’,P,Why}形式的元组,这里P是终止的进程的Pid,而Why是一个描述终止原因的项式。
任何收到Why不为normal(正常)的退出信号的进程都将死掉。对于这一规则有一个例外:如果接收进程是一个系统进程,那么该进程不会死掉,而是将退出信号转换成一个正常的进程间消息,并被添加到该进程的邮箱中。可以调用内置函数process_flag(trap_exit,true)来将一个一般进程变成一个系统进程。
系统进程处理其他进程的故障的典型代码片断如下:
start()->spawn(fun go/0).
go()->
process_flag(trap_exit,true),
loop().
loop()->
receive
{’EXIT’,P,Why}->
...handle the error...
end
另外一个原语exit/2将完成这个拼图。exit(Pid,Why)将给进程Pid发送一个原因为Why的退出信号。调用exit/2的进程本身不会终止,因此这种消息能够用来“伪装”一个进程的死亡。
不过对于“系统进程将会把所有信号都转换成消息”这一点来说,也存在一个例外:如果调用exit(P,kill),将向P发送一个不可阻挡的退出信号(unstoppable exit),收到该信号后进程P将不顾一切后果地终结掉。exit/2的这种用法在客客气气地请求一个进程自觉终结而遭到拒绝的时候就有用。
进程连接对于建立进程群组(group)是有用的,进程群组中的一个进程出错,所有进程都将死掉。通常我们把属于一个应用的进程连接起来,并且让其中的一个进程充当“监视者”的角色。监视者被设定来捕获退出信号。如果进程群组中有任何一个进程出错了,群组中除了监视者以外的其它所有进程都将死掉,而由监视者来接收群组中的进程的出错消息,这些出错消息描述了故障原因。
进程监视者
进程连接对于整个进程群组来说是有用的,但是对于非对称的进程对的监视来说没什么用。在典型的客户-服务器模型中,客户与服务器的关系在考虑到错误处理的时候就是非对称的。假设一个服务器处理着大量不同客户的大量长时间会话(long-lived session),那么当服务器崩掉的时候我们可能会杀死所有的客户,但是当某一个客户崩掉的时候我们并不希望杀掉服务器。
SODBS:monitor/2原语就是用来设置一个监视者的。如果进程A有求值:
Ref=SODBS:monitor(process,B)
那么当B因为原因Why死掉的时候,就会向A发送一条如下格式的消息:
{’DOWN’,Ref,process,B,Why}
监视消息的发送者A和接收者B都不必是系统进程。
3.6分布式(distributed)处理
SODBS程序能够很轻易地从一个单处理器平台移植到多处理器平台。每一个完整的自包含的(self-contained)SODBS系统被称为一个节点(node)。一个宿主操作系统上面可以跑一个或多个SODBS节点。多个SODBS节点可以运行在同一个操作系统上这一点简化了分布式应用的测试。可以通过让所有的节点运行在同一个处理器上,来进行一个分布式应用程序的开发和测试。当应用投入使用时,可以将在同一处理器上工作的不同节点变成分布式网络处理器上的不同节点。除了定时操作(timing)以外,所有操作的工作方式都应该与在同一个节点严格相同。
分布式处理需要如下两个原语:
spawn(Node,Fun)——在一个远端节点Node上产生一个处理函数是Fun的进程。
monitor(Node)——用来监视整个节点的行为。
这里的monitor类似于link,不同之处在于被控制的对象是一整个节点而不是某个进程的行为。
3.7端口(ports)
端口给SODBS程序与外界的通信提供了一种机制。端口可以通过调用内置函数open_port/2来创建。每个端口都有一个与之相关联的“控制进程”(controlling-process)。我们称控制进程拥有(own)该端口。从该端口收到的所有的消息都被发送给其控制进程,且只有其控制进程才可以向该端口发送消息。
端口的控制进程被初始化为创建该端口的进程,但是这个进程可以被改变。如果P是一个端口,而Con是其控制进程的pid,那么可用调用如下的表达式来让端口做某些事情:
P!{Con,Command}
这里的Command变量可以取如下三种可能的值:
{command,Data}——把数据Data通过端口发送给外部对象。Data必须要是一个io表。io表是扁平化的,表中所有的数据元素都被发送给外部的应用程序。
close——关闭一个端口。被关闭的端口必须向控制进程回复一个{P,closed}的消息。
{connect,Pid1}——将端口的控制进程变为Pid1。该端口必须要给原来的控制进程回应一个{Port,connected}的消息,此后该端口收到的所有新消息都经发送给新的控制进程。
通过端口收到的所有外部应用程序的数据都将以{Port,{data,D}}的消息格式发送给其控制进程。
消息的确切格式以及该消息是如何组帧的,则取决于端口是如何被创建的。
3.8动态代码替换
SODBS支持一种简单的动态代码替换机制。在一个运行时的SODBS节点上,所有的进程都共享同一份代码。因此我们必须要考虑如果我们替换了一个运行时系统的代码,会发生什么事情?
在顺序化编程语言里只有一个控制线(thread of control),所以如果我们期望动态替换代码,我们只需要考虑对该唯一控制线的影响。在一个顺序化系统里,如果我们期望改变代码,我们实际上通常的做法是停止该系统,替换代码,然后重新启动程序。然而在一个实时控制系统中,我们通常并不希望停下该系统来替换代码。在某些特定的实时控制系统中,我们也决不允许关掉系统来替换代码,所以这些系统需要被设计成不停止系统而支持代码替换。这种系统的一个例子就是NASA设计的X2000卫星控制系统。
SODBS系统的每个模块的代码允许存在两个版本。如果一个模块的代码被加载进来,那么调用该模块代码的所有新启动的进程就会动态地连接到该模块的最新版本上。如果一个模块后来被替换了,那么原来执行该模块代码的进程就既可以选择继续执行老的代码,也可以选择执行新加载的代码。这种选择决定于该代码是如何被调用的。
如果代码是通过全修饰名被调用的,即以“ModuleName:FuncName”的方式调用的,那么就总是调用该模块的最新版本,否则就调用该模块的当前版本。举个例子,假设我们写了下面的一个服务循环:
-module(m).
...
loop(Data,F)->
receive
{From,Q}->
{Reply,Datal}=F(Q,Data),
m:loop(datal,F)
end.
在模块m第一次被调用到的时候,该模块就被加载了进来,譬如从外部调用m:loop函数的时候。因为这时候m模块只有一个版本,所以调用的是当前模块的loop函数。
假设我们现在修改了模块m的代码,重新编译并加载了该模块。那么当我们在最后的receive语句中调用m:loop函数时,新版本的m模块中的代码就会被调用。注意,所调用的新代码与老代码的兼容性由程序员来保障。强烈建议把所有的代码替换调用都做成尾调用的,这样的话一个尾调用就不必返回到老代码中,因此在一个尾调用以后,一个模块的所有的老代码就可以被安全地删除了。
如果我们希望继续执行当前模块(老版本)的代码,而不切换到新模块的代码中,那么我们就可以用非全修饰名调用的方式写该loop循环,即:
-module(m).
...
loop(Data,F)->
receive
{From,Q}->
{Reply,Datal}=F(Q,Data),
loop(datal,F)
end.
在这种情况下,模块的新版本的代码就不会被调用。
灵活地运用这种机制使得进程可以同时执行不同模块的新、老代码版本。
需要注意,代码存在两个版本有一个局限性。如果第三次试着重新载入一个模块,则正在执行第一个模块的所有进程将被全部杀掉。
除了以上调用约定以外,还有许多内置函数用来达到代码替换的目的。
3.9一种类型符号(type notation)
我们在构建一个软件模块的时候,是怎么描述该模块的用法的呢?通常,我们都会说通过对一组API(Application Programming Interface)的调用来使用它。
这组API就是模块提供可供外部调用的一组函数,以及这些函数的输入值的类型的要求和返回值的类型的描述。
下面的例子说明了如何用SODBS的类型符号来指定一些函数的类型:
+type file:open(fileName(),read |write)->
{ok,fileHandle()}|{error,string()}.
+type file:read_line(fileHandle())->
{ok,string()}|eof.
+type file:close(fileHandle())->
true.
+deftype fileName()=[int()]
+deftype string()=[int()].
+deftype fi leHandle()=pid().
每一种SODBS的原始数据类型都有它的类型。这些原始类型是:
int()——是整数类型。
atom()——是原子类型。
pid()——是Pid类型。
ref()——是引用类型。
float()——是SODBS的浮点数类型。
port()——是端口类型。
bin()——是二进制类型。
列表类型、元组类型以及选择(alternation)类型是如下递归式地定义的:
如果T1,T2,…,Tn都是类型的话,那么{T1,T2,…,Tn}就是元组类型(tuple type)。此时如果{X1,X2,…,Xn}中的X1是T1类型,X2是T2类型,……Xn是Tn类型,我们就说{X1,X2,…,Xn}是{T1,T2,…,Tn}类型。
如果T是一个类型,那么[T]就是一个列表类型(list type)。如果[X1,X2,…,Xn]中的所有Xi都是T类型的话,那么我们就说[X1,X2,…,Xn]是[T]类型。注意,空表[]的类型也是[T],其中T是任意类型。
如果T1和T2都是类型,则T1|T2就是选择类型(alternation type)。如果X的类型可能是T1或者T2,我们就说X的类型是T1|T2。
可以通过如下的符号来引入新的类型:
+deftype name1()=name2()=...=Type.
这里name1、name2……等名字应遵循SODBS的原子(atom)的语法。Type是类型变量,需按照SODBS的变量的语法来书写。例如我们可以定义:
+deftype bool()=true|false.
+deftype weekday()=monday|tuesday|wednesday|
thursday|friday.
+deftype weekend()=saturday()|sunday().
+deftype day()=weekday()|weekend().
函数类型按如下书写:
+type functionName(T1,T2,…,Tn)->T.
这里所有的Ti都是类型。如果在一个类型的定义中某个类型变量出现了不止一次,那么该类型的实例中与其定义对应的位置的所有变量都必须具有相同的类型。
下面是一些例子:
+deftype string()=[int()].
+deftype day()=number()=int().
+deftype town()=street()=string().
+type factorial(int())->int().
+type day2int(day())->int().
+type address(person())->{town(),street(),number()}.
最后,还有匿名函数的类型如下书写:
+type fun(T1,T2,...,Tn)->T end
因此,map/2的类型就应该如下书写:
+type map(fun(X)->Y end,[X])->[Y].
这里的类型符号是Wadler&Marlow[49]所开发的类型符号的一种极其简化的版本。
3.10讨论
这里介绍了SODBS很重要的一个子集,至少足以用来理解此文档中的所有例子。但是我还没有回答“SODBS是可容错系统吗?”我们确信答案是“正是。”我们此前曾经说过,可容错系统的软件系统一定要满足某些特征。我现在就来印证一下,SODBS确实是满足了这些特征的,理由如下:
进程是SODBS的基础,所以R1满足。
因为SODBS中的进程就是错误封装单元,所以R2满足。如果一个进程因为软件原因终止的话,同一个SODBS节点中的其它进程将不会受到影响(当然,除非有进程被连接到了将会终止的进程上,这种情况下进程间的影响是有意的)。
如果进程中的函数用了错误的参数来调用,或者系统的BIF用了错误的参数来调用,那么该进程就立即终止。即刻终止符合速错进程(fail-fast process)的概念,也符合Schneider的错即停处理器(fail-stop processor)的概念,还符合Renzel关于我们必须检测错误,并尽量早地停下来的观点。
当一个进程出错时,出错的原因会被广播给该进程的当前连接集,因此满足R3和R4。
R5由一种代码升级机制来满足。
R6在SODBS里没有被满足,但是在SODBS的库里得到了满足。持久存储可以用dets或sodbs_db来实现。dets是一个单机的基于磁盘的存储系统。如果一个进程或者一个节点崩溃了,存储在dets中的数据却得以幸存。为了达到更好地保护数据的目的,数据应该被存储在物理上独立的两个节点上,这时候可以用sodbs_db数据库,它是SODBS的一个应用程序。
我还要指出,Schneider的“出错即停止”(halt on failure)、“错误状态属性”(Failure status property)、“稳定存储属性”(Stable storage property)等观点,也由SODBS自己或SODBS的库直接或间接地满足了。
4实现技术
抽象出并发——某种意义上讲,并发程序比顺序化程序要难得多。为了避免在同一个模块里既有并发的代码又有顺序化的代码,我们展示了如何将代码组织到两个模块里,其中一个全部是并发代码,另一个则只有纯的顺序化代码。
抱持SODBS的世界观——在SODBS的世界里,万事万物都是进程。为了帮助我们抱持这种观点,我介绍了一种协议转换器(protocol converter)的思想,它有助于程序员建立任何事物都是SODBS进程这一观念。
SODBS的错误观——SODBS的错误处理方式与其他语言有本质的区别。我将展示在SODBS中该如何编写出错情况下的程序。
显意编程——这是一种程序员能够轻易就从源代码中看出编程者的意图的编程风格,而不是通过对代码进行表面的分析来猜测编程者的意图。
4.1.1一个可容错的客户-服务器模型
我现在来扩展我们的服务器程序,增加错误恢复的代码,一旦函数F/2发生错误,原先的服务器程序就会崩溃掉。“容错”一词通常是说硬件的,但是在这里我们的意思是包容用以参数化服务器的函数F/2中的错误。
函数F/2在一个catch语句内进行求值,如果一个RPC请求会导致服务器崩溃,就将发起该RPC的客户杀死掉。
比较一下新的服务器代码,我们发现跟老的代码相比有两点小小的变化:rpc代码被改成了:
rpc(Name,Query)->
Name!{self(),Query},
receive
{Name,crash}->exi t(rpc);
{Name,ok,Reply}->Reply
end.
并且loop/3内receive语句的一段改成了:
case(catch F(Query,State))of
{’EXIT’,Why}->
log_error(Name,Query,Why),
From!{Name,crash},
loop(Name,F,State);
{Reply,Statel}->
From!{Name,ok,Reply},
loop(Name,F,Statel)
end
让我们再仔细看看这些变化的细节,我们会发现,如果在服务器的loop函数里对F/2的求值发生了异常,则会发生三件事:
1.会报告该异常——在我们的程序中,我们只是将该异常打印了出来,但要是在更成熟的系统中,我们会将该异常记录到一个稳定存储器中。
2.向客户发送一个crash消息——当客户收到该crash消息时,会在客户代码中产生一个异常。因为这时候客户程序再运行下去很可能已经没有意义了,所以这正是期望的结果。
3.服务器继续对老的状态变量进行操作。因此说RPC遵循了“事务语义”(transactionsemantics),也就是说,它要么操作完全成功,服务器的状态被更新,要么操作失败,服务器的状态保持原样不动。
注意server2.sodbs只能保护发生在将服务器参数化的特征函数中的错误。如果服务器本身死掉了(这是可能的,例如被系统中的其他进程故意杀死),那么客户的RPC桩就被无限挂起了,一直等待着一个永远不会到来的回应消息。如果我们还想保护这种可能性,那么我们可以像这样来写RPC函数:
rpc(Name,Query)->
Name!{self(),Query},
receive
{Name,crash}->exi t(rpc);
{Name,ok,Reply}->Reply
after 10000->
exit(timeout)
end.
这种解决方法解决了一个问题,但是却带来了另一个问题:我们应该把超时设置多长时间?一个更好的解决办法是用到监督树,这个我在这里不展开讲。服务器发生了故障,不应该由客户软件来检测它,而应该由专门负责纠正服务器故障的特殊的监督者进程来检测它。
现在,我们可以来运行这个用含有故意错误的VSHLR版本(vshlr2)作为参数的服务器程序。
该程序的一个执行片断如下所示:
>vshlr2:start().
true
2>vshlr2:find(″joe″).
error
3>vshlr2:i_am_at(″joe″,″sics″).
ok
4>vshlr2:find(″joe″).
{ok,″sics″}
5>vshlr2:find(″robert″).
Server vshlr query{find,″robert″}
caused exception{badarith,[{vshlr2,handle_event,2}]}
**exited:rpc**
6>vshlr2:find(″joe″).
{ok,″sics″}
异常中的信息足以用来帮助我们调试程序。
我们最后对服务器程序所做的改进就是让我们可以“在系统运行中”(on-the-fly)对服务器的程序进行修改。
我可以对程序用vshlr3进行参数化,vshlr3没有在这里贴出来,它跟vshlr2基本上是一样的,只有一点不同:第3行的server2改成server3。
下面的执行片断展示了如何在“在系统运行中”修改服务器程序的代码。第1-3行显示服务器程序工作正常,server3可以处理1除以0这种错误而不会崩溃,例如第5行显示运行正常。第6行,我们发送一条命令,将服务器程序的代码改回到vshlr1中的版本。这条命令执行完后,服务器程序会如第7行所示地正常工作。
1>vshlr3:start().
true
2>vshlr3:i_am_at(″joe″,″sics″).
ok
3>vshlr3:i_am_at(″robert″,″FMV″).
ok
4>vshlr3:find(″robert″).
Server vshlr query{find,″robert″}
caused exception{badarith,[{vshlr3,handle_event,2}]}
**exited:rpc**
5>vshlr3:find(″joe″).
{ok,″sics″}
6>server3:swap_code(vshlr,
fun(I,J)->vshlr1:handle_event(I,J)end).
ok
7>vshlr3:find(″robert″).
{ok,″FMV″}
编写vshlr3的程序员完全不必知道server3的任何实现细节,也不必知道服务器代码可以在服务不停止的情况下被动态修改。
在不停止服务器的情况下替换服务器程序代码的能力部分满足了第2.2节中的“需求8”——即不停止系统而升级系统的软件。
如果我们回顾一下server2的代码和应用程序中的vshlr2的代码,我们会发现:
1.服务器程序中的代码可以重复用来构建许多不同的客户-服务器模型的应用程序。
2.应用程序的代码比服务器程序的代码要简单很多。
3.要理解服务器程序的代码,程序员就必须要理解SODBS并发模型的所有细节。这就涉及到名字注册、进程产生、向进程发送不可捕获exit异常、
发送和接收消息。对上报异常来说,程序员还必须理解异常的概念,对SODBS的异常处理机制相当熟悉。
4.要编写应用程序的代码,程序员就只需要理解一份简单的顺序化程序——他们不需要了解关于并发和错误处理的任何事情。
5.我们可以想象,同一份应用程序的代码可以与越来越成熟的一系列服务器程序配合运行。我已经展示过三个版本的服务器程序,并且我们还可以往服务器程序中添加越来越多的功能,而保持服务器程序/应用程序(server/application)的接口不变。
6.不同的服务器程序(server1、server2等等)渗透给应用程序的是不同的非功能特性(non-functional characteristics)。而所有服务器程序的功能特性(functionalcharacteristics)都是一样的(即,输入正确的参数程序最终产生的都是同一个结果);但是非功能特性却不一样。
7.实现系统的非功能性需求(我们所说的非功能性需求是指在系统出现故障的时候系统的行为,函数求值需要多长时间等等)的部分代码被限制在服务器程序之内,对编写应用程序的程序员来说是不可见的。
8.远程过程调用(remote procedure call)如何实现的细节被隐藏在服务器程序模块内。这就意味着对于要使今后服务器程序的修改而不会影响到客户程序,这一点是必须的。例如,我们可以修改rpc/2的实现细节,而不必修改调用server2中的函数的客户程序。将整个服务器的功能划分成一个非功能性部件(none-functional part)和一个功能性部件(functionalpart)是一种好的编程实践,可以给系统带来许多可观的好处,就如:
1.并发编程通常认为是比较难的。在一个大型的编程团队中,程序员的技能层次往往不同,那么专家程序员应该编写专用服务器部分代码,而经验尚浅的程序员应该去编写应用部分代码。
2.形式化方法(formal method)可以应用于(简单一些的)应用部分代码之上。在对SODBS代码进行形式化证明(formal verification)的时候,或设计类型系统进行类型推断的时候,一遇到并发编程往往就会有问题。如果假设专用服务器程序已经正确无误这一假设成立,那么证明系统的性质的问题就简化为证明顺序化程序的性质的问题。
3.在一个充满大量客户-服务器的系统中,所有的服务器程序就可以利用同一份专用服务器程序来编写。这就使得程序员理解和维护起许多服务器程序来更简单。
4.专用服务器程序和应用部分程序可以分别独立进行测试。如果长时期内接口保持恒定不变,那么这两者可以独立进行改进。
5.应用部分代码能够“插入到”许多不同的专用服务器程序中,不同的专用服务器具有不同的非功能特性。在具有相同的接口的情况下,有的服务器可以提供加强的调试环境,而有的服务器可以提供集群化、热切换等特性。这一点已经在很多项目中实行过,例如Eddie服务器程序提供了集群化能力,Blue-tail邮件加固器提供了一个具有热切换功能的服务器。
4.2抱持SODBS的世界观
SODBS的世界观就是一切皆进程,进程之间只能通过交换消息来进行交互。
当我们的SODBS程序需要跟外界软件交互时,一般都是写一个接口程序来完成交互,这个接口程序体现出“一切皆进程”的精神,而且很便利。
举个例子:我们考虑一下如何实现一个电子支付服务器的web服务端?电子支付服务器是通过RFC2616建议中定义的HTTP协议与客户通信的。
从一个SODBS程序员的角度来看,电子支付服务器内部的循环会给每一个连接产生一个进程,接受来自客户的请求,并作出适当的响应。程序代码可能如下:
serve(Client)->
receive
{Client,Request}->
Response=generate_response(Request)
Client!{self(),Response}
end.
这里Request和Response是SODBS的项式(term),表示HTTP协议的请求和HTTP协议的响应。
上面的服务器程序非常简单,它期望来一个单独的请求,作出一个单独的响应,然后就终止了连接。
一个更成熟的服务器程序,还要支持HTTP/1.1规定的持久连接,支持这种持久连接的代码也是非常简单的:
serve(Client)->
receive
{Client,close}->
true;
{Client,Request}->
Response=generate_response(Request)
Client!{self(),Response},
server(Client);
after 10000->
Client!{self(),close}
end.
这个11行的函数就从本质上完成了一个简单的支持持续连接的web服务端的功能。
web服务端并不直接跟产生HTTP请求的客户交互,因为那样的话一些无关的细节将会严重地干扰web-server的实现,并且使得程序结构难以理解。
这里我们用了一个“中间人”进程。“中间人”进程(一个HTTP驱动器)完成HTTP请求、应答和表示这些请求、应答的对应SODBS项式之间的互换。
HTTP驱动器程序的全部代码如下:
relay(Socket,Server,State)->
receive
{tcp,Socket,Data}->
case parse_request(State,Data)of
{completed,Request,Statel}->
Server!{self(),{request,Request}},
relay(Socket,Server,Statel);
{more,Statel}->
relay(Socket,Server,Statel)
end;
{tcp_closed,Socket}->
Server!{self(),close};
{Server,close}->
sodbs_tcp:close(Socket);
{Server,Response }->
Data=format_response(Response),
sodbs tcp:send(Socket,Data),
relay(Socket,Server,State);
{’EXIT’,Server,_}->
sodbs_tcp:close(Socket)
end.
如果通过一个TCP socket从客户收到一个包,这个包就通过调用parse_request/2进行解析。当响应已经完成,一个表示该请求的SODBS项式就被发送给服务器。如果收到一个服务器的响应,则该响应被转换格式(reformat)并被发送给客户。如果有任何一端终止连接,或者服务器发生一个错误,这个连接就会被关掉。如果该进程因任何原因终止,则所有的连接也会被自动关掉。
变量State是一个状态变量,用来表示可重入解析器的状态,该解析器解析收到的HTTP请求的。
4.3错误处理哲学
SODBS的错误处理与其他大多数编程语言中的错误处理有着根本的不同。
SODBS关于错误处理的哲学可以用如下几条标语来表达:
让其它进程来修复错误。
工作者不成功,便成仁。
任它崩溃。
杜绝防御式编程。
4.3.1让其它进程来修复错误
在分布式系统中,我们如何来处理错误呢?为了处理硬件错误,我们需要备份;而为了处理整台计算机的错误,我们需要两台计算机。
如果计算机1发生故障,那么计算机2会发现故障并改正错误
如果第1台计算机崩溃了,第2台计算机会检测到该故障,并试图修复该故障引起的错误。在SODBS中,我们就是使用的这种办法,只不过我们把计算机与进程等价起来。
如果进程1发生故障,那么进程2会发现故障并改正错误
如果Pid1出错且Pid1和Pid2是连接在一起的,且Pid2被设置为捕获(trap)错误,那么当Pid1出错时,一条{‘EXIT’,Pid1,Why}格式的消息就被发送给Pid2。
Why描述了出错的原因。
注意,如果运行Pid1的计算机死掉了,也会有一条退出消息{‘EXIT’,Pid1,machine_died}发送给Pid2。该消息貌似是来自Pid1,但是实际上来自运行着Pid2的节点的实时系统。
非要使一个硬件错误看起来像一个软件错误的原因是,我们不想用两种方法来处理错误,一种处理软件错误而另一种处理硬件错误。为了概念上的完整性,我们期望用统一的机制。再综合考虑硬件错误的极端情形——即整个处理器发生故障,就产生了我们的错误处理思想:即不在出错的地方,而在系统的其他地方来进行处理。
因此在任何情况下,包括硬件发生故障,都是由Pid2来纠正错误。这就是为什么我说“让其他进程修复错误”。
这种哲学与顺序化编程语言是完全不同的,在顺序化编程语言中,除了试图在发生错误的控制线程中处理所有的错误,没有其他选择。在提供有异常处理的顺序化编程语言中,程序员将任何可能发生故障的代码用一个异常处理结构包含起来,试图包住该结构中所有可能发生的错误。
远程错误处理有许多好处:
1.错误处理代码和出错代码运行在不同的控制线程中。
2.解决问题的代码不会被处理异常的代码扰乱。
3.该方法可以用于分布式系统,所以一个单节点系统的代码移植到一个分布式系统中只需对错误处理代码做很少的修改。
4.系统可以在单一节点系统上构建与测试,然后无需进行大的修改就可以部署到多节点系统上。
4.3.2工作者与监督者
为了将执行正常工作的进程与处理错误的进程更清楚地区别开来,我们经常会谈到工作者(worker)和监督者(supervisor)。
一个进程,即工作者进程,负责执行正常的工作。另一个进程,即监督者进程,来检测工作者。如果工作者中发生了一个错误,监督者会采取措施来纠正该错误。这种方式的妙处在于:
1.职责划分很清晰。负责做事的进程(工作者)不用担心错误处理。
2.我们可以用特别的进程专门来负责错误处理。
3.我们可以在物理上独立的计算机上运行工作者进程和监督者进程。
4.往往会发现错误纠正代码是有通用性的(generic),即对许多应用程序都普遍适用,而工作者进程则更多因应用而异。
第三点是至关重要的——使得SODBS满足了R3和R4,从而可以把工作者和监督者运行在物理上独立的计算机上,因此可以构建可以包容引起所有进程出错的硬件错误的系统。
4.4任它崩溃
我们的错误处理哲学如何适用于我们的编程实际呢?当程序员发现一个错误的时候,他该编写什么代码?我们的哲学是让其它进程来修复错误,但是这对编码者来说,意味着什么?答案是任它崩溃。我的意思是,当发生一个错误的时候,就让程序崩溃好了。什么算是错误?就编程而言,我所说的错误即:
那些运行时系统也不知道该如何处理的异常。
那些程序员也不知道如何处理的错误。
如果一个异常是由运行时系统产生的,但是程序员之前就预见到了该异常,并知道如何纠正引起异常的条件,那么这就不是一个错误。例如,打开一个不存在的文件会产生一个异常,但是程序员可以不把它当作错误。程序员可以写代码报告这个异常,并进行必要的纠正。
有些错误发生时连程序员也不知道该如何处理。程序员应该遵照规格说明书来编程,但是往往规格说明书也没有说该怎么办,所以程序员也就不知道该怎么办。这里有一个例子:
假设我们现在写一个程序来为一个微处理器生成操作码,规格说明书说:一个load操作应该返回操作码1,一个store操作应该返回操作码2。程序员就把该规格说明写成如下代码了:
asm(load)->1;
asm(store)->2.
现在假设系统试图求值asm(jump)——该怎么处理呢?假设你是该程序员,并且你已经习惯于编写防御式(defensive)代码,那么你可能会写:
asm(load)->1;
asm(store)->2;
asm(X)->??????
但是??????该是什么呢?你会在该处写什么样的代码?你现在碰到的情形就如同运行时系统遇到被0除一样的情形,你写不出有意义的代码,你所能做的只有终止程序,于是你写道:
asm(load)->1;
asm(store)->2;
asm(X)->exit({oops,i,did,it,again,in,asm,X}).
在SODBS编译器编译
asm(load)->1;
asm(store)->2.
的时候,就如同你已经写了:
asm(load)->1;
asm(store)->2;
asm(X)->exit({bad_arg,asm,X}).
防御式编码会破坏了代码的纯净性,使代码的阅读者容易产生混淆。而且防御式编码的诊断信息也未必比编译器自动提供的诊断信息好。
4.5显意(intentional)代码
“显意代码”是我们给一种编程风格所起的名字,这种编程风格使得程序的阅读者能够很轻易地看到程序员编写一段代码的意图。
代码的意图应该从它所调用的函数的名字上显而易见,而不应该需要通过对代码
的结构分析来推断。下面的例子很好地说明了这一点:
在早期的SODBS的库模块dict中,导出了一个lookup/2的函数,接口如下:
lookup(Key,Dict)->{ok,Value}|notfound
在这种定义下lookup被用在了三种不同的上下文中:
1.用于数据获取(data retrieval)——程序员可能会写:
lookup(Key,Dict)->{ok,Value}|notfound
这里lookup用一个已知的键(key)从字典(dictionary)中提取一个条目。Key应该在字典中,否则就是一个编程错误,所以如果键没有找到将会产生一个异常。
2.用于搜索(searching)——如下代码段:
case lookup(Key,Dict)of
{ok,Val}->
...do something with Val...
not_found->
...do something else...
end.
是搜索一个字典,我们并不知道Key是否存在——如果键不在字典中,将不会是一个编程错误。
3.用于测试一个键的存在性——代码段:
case lookup(Key,Dict)of
{ok,_}->
...do something...
not_found->
...do something else...
end.
是测试一个指定的键Key是否在字典中。
在读过数千行这种代码后,我们开始担心代码的意图了——我们问我们自己一个问题“程序员编写这一行代码的意图到底是什么?”——在分析了上述三种用法后,我们的答案即数据获取,搜索和测试。
在很多不同的上下文中,我们都需要在一个字典里查找键。在某种情况下,程序员知道一个指定的键应该存在于字典中,如果该键不在该字典中,则应该是一个编程错误,程序应该终止。另一种情况下,程序员不知道该键对应的条目是否在字典中,他们的程序应该能处理键在字典中和不在字典中两种情况。
抛弃对程序员的意图的猜测,分析一下代码,一组更好的库函数是:
dict:fetch(Key,Dict)=Val|‘EXIT’
dict:search(Key,Dict)={found,Val}|not_found.
dict:is_key(Key,Dict)=Boolean
这就简洁地表达了程序员的意图——不需要对程序进行分析和猜测,我们清晰地看到了程序的意图。
显然大家都知道fetch可以用search来实现,search也可以用fetch来实现。
但是如果fetch是原子性的,则我们也可以写:
search(Key,Dict)->
case(catch fetch(Key,Dict))of
{’EXIT’,_}->
not_found;
Value->
{found,Value}
end.
不过这并不是什么好代码,因为我们先是产生了一个异常(该异常本应说明程序已错),后来却修改了错误。
更好的用法应当如:
find(Key,Dict)->
case search(Key,Dict)of
{ok,Value}->
Value;
not found->
exit({find,Key})
end.
这样正好产生一个异常代表发生了一个错误。
4.6讨论
软件系统设计是一项严格的活动。编写结构清晰、意图明显的代码是困难的。困难一部分源于要选择正确的抽象。为了对付复杂的情况,我们使用了“分而治之”(divide andconquer)的方法,我们把复杂的问题分解成简单一些的子问题,然后解决这些子问题。
本章阐述了如何把许多复杂的问题分解成更简单的子问题。在谈到错误处理的时候,我阐释了如何“抽象出”错误,并表明了程序应该将“纯净”的代码与“修复错误”的代码划分开的观点。
在编写一个服务器程序的时候,我展示了如何抽象出服务器程序的两个非功能特性。我展示了如何编写一个当特征函数(特征函数定义了服务器的行为)中发生一个错误时不会导致服务器崩溃的服务器程序,我还展示了在不停下服务器的情况下如何修改服务器的行为。错误恢复、运行时修改系统的代码是许多真实系统需要的两项典型的非功能特性。通常的编程语言和系统对编写已经定义好的功能行为的代码提供了强力的支持,但是对程序的非功能性部分的支持却很贫乏。
在大多数的编程语言中,编写纯的函数(其值确定地依赖于函数的输入)是容易的,但是要做到修改运行时系统的代码,或以一种通用的方式处理错误,或保护我们的代码不受系统部分发生的故障的影响这一类事情,却要困难得多,有时甚至是不可能的。因此,程序员运用了操作系统提供的服务——操作系统通常以进程的面貌提供了保护区域、并发机制等等。
从某种意义上讲,操作系统提供了“被编程语言设计者遗忘了的东西”。但是在SODBS这样的软件平台中,操作系统是几乎不需要的。OS真正提供给SODBS的只是一些设备驱动程序,而OS提供的诸如进程、消息传递、调度、内存管理等等机制都不需要。用OS的机制来弥补编程语言的不足所带来的问题是,操作系统的低层机制不能够轻易地被改变。例如操作系统中关于什么是进程的概念以及进程间调度的策略都不能修改。
通过给程序员提供轻量级的进程和关于错误检测和处理的基本机制,应用程序的编写者就很容易地设计和实现他们自己的应用操作系统,这种应用操作系统是专为他们的特定的问题的特征而特别设计的。SODBS系统——用C编写的一个应用程序组——便是此中一例。
5可容错系统
金融设备的设计者们在软件设计中花了一半的精力在错误的检测和纠正上。
什么是可容错系统?如何编写可容错系统?这个问题是此文档的重点所在,也是我们理解如何构建可容错系统的关键。在本章中,我们定义了我们所说的“容错”的含义,并提出了用来编写可容错系统的一种特殊方法。我们以两条引述来开始本章:
如果一个系统的程序在出现逻辑错误的时候仍然能够正确地执行,我们就说该系统是可容错的。
要想设计并构造一个可容错系统,你必须要明白系统在什么情况下应该正常工作,在什么情况下该失效,可能会发生什么类型的错误。错误检测是容错系统的一个基本部件。也就是说,如果你知道发生了一个错误,你可能用替换掉出错部件的方法、采用另一种计算方式的方法或上报一个异常的方法来达到包容该错误的目的。然而,你希望避免为了达到可容错性而给系统增添不必要的复杂性,因为这些复杂性可能会导致系统可靠性的降低。
我们将说明当检测到一个反常情况发生时会发生什么事情,以及来建造一个软件机制来检测和纠正错误。
此处余下部分我们讲述:
一种可容错编程的策略——该策略简而言之就是当你不能纠正一个错误的时候,马上放弃,只去做你可以做到的简单一些的事情。
监督层级(supervision hierarchies)——就是对任务的层次化组织。
乖函数(well-behaved function)——就是那些应该正确地工作的函数。乖函数产生的异常我们把它解释成故障。
5.1可容错系统简述
为了使得系统可容错,我们把软件组织成一系列层次化的待执行的任务(task)。最高层的任务按照某个规格说明来执行着应用逻辑。如果这个任务不能够执行,系统将会试图执行某个更简单的任务。如果这个更简单的任务仍然无法执行,则系统将会尝试执行一个更更简单的任务,如此类推。如果系统中最底层的任务都无法执行,那么就当系统发生了故障。这个方法直观上感觉是很有吸引力的。它的意思是,如果我们不能做到我们想做的,那就做一些更容易做到的。我们还试图组织一下我们的软件,使得更简单的任务由更简单的软件来执行,这样的话当任务变得更简单时,成功的可能性就越高。
当任务变得更简单时,操作的侧重点也发生了变化——相比提供完全的服务,我们变得更关注于保护系统免受摧毁。虽然随着任务层次的降低我们变得更保守,但是在所有的层次,我们的目标都是要提供一个可接受级的服务。
当故障发生的时候,我们更关注于保护系统,并且报告故障的确切原因——以便我们接下来可以对故障做点什么。这就意味着我们需要某种可以不受系统崩溃影响的持久化错误日志。在异常环境下,我们的系统会发生故障,但是当发生故障的时候我们绝不应当丢失有关系统为什么会发生故障的信息。
为了实现我们的任务层级,我们需要对“故障”(failure)这个词有一个准确的认识。在SODBS中,对一个函数进行求值可能会导致异常(exception)。但是异常不等于错误(error),而且不是所有的错误都将造成故障(failure)。所以我们需要讨论一下异常、错误和故障之间的区别。
异常、错误和故障之间最大的区别在于是在系统的哪个部分检测到的非正常事件,该事件被如何处理,被如何解释。我们来跟踪一下当我们的系统中发生了一次异常情况时会发生什么事情——这里的描述是“自底向上”的,即从最初检测到错误发生的点开始。
在系统的最底层,SODBS虚拟机检测到了一个内部错误——它检测到了一个被0除的情况,或者一个模式匹配错误或其他的情况。重要的是在检测到这些情况时,进程对发生错误的地方后续的求值已经变得没有意义了。所以虚拟机仿真器无法继续,它做了唯一能做的事情,就是抛出一个异常。
在它的相邻层,该异常可能会也可能不会被捕获。捕获异常的程序段可能能够也可能不能够纠正异常所引起的错误。如果错误能够成功被纠正,那么就不会造成什么伤害,进程会恢复到正常。如果该错误被捕获了,但是纠正不了,那么可能会产生另一个异常,产生该异常的进程可以捕获也可以不捕获该异常。
如果一个异常产生了但是没有“捕捉处理者”(catch handler),那么该进程将会发生故障。故障的原因将会被传播给当前与之相连的所有进程。
收到这种故障信号的所有进程像对待正常的进程间消息一样,可能会也可能不会截取并处理这些信号。
现在我们看到了当虚拟机仿真器中发生的一个非正常情况,是如何在系统中从下往上传播的。在错误向上传播的过程中,在每个点上都将尝试着去纠正它。
这种尝试可能成功或失败,所以我们就可以自如地决定在哪里、如何处理该错误。一个“被纠正了的”错误不会再被看作是一个故障,但是这要求该错误情形要能够事先被预见到,并且针对该错误的纠正代码要成功地执行。
至此,我么已经看到了一个非正常情形是如何产生的,如何导致异常,该异常是如何被捕获的,未被被捕获的异常如何导致进程故障,进程故障如何被系统的其他进程检测到。这些正是我们赖以实现我们的“任务层级”的一些可用的机制。
5.2监督层级
回想一下,我们在本章的开始说到过“任务层级”的思想,其基本思想是:
1.尽力执行一个任务。
2.如果你不能够执行一个任务,则去执行一个简单一些的任务。
我们将每个任务关联上一个监督者进程(supervisor process)——监督者将会被赋予一个工作者(worker)来试图达到该任务规定的目标。如果该工作者进程失败并发出一个非正常的退出信号,则监督者就会假定该任务已经失败,并发起某种错误恢复程序。错误恢复程序可能会重启工作者,或者如果重启失败则转而去做一些更简单的事情。
监督者和工作者被按照如下规则安排成层次化的树型关系:
1.监督树是监督者组成的树。
2.监督者监视工作者和监督者。
3.工作者是behaviour的实例。
4.behaviour用乖函数(well-behaved function)来参数化。
5.乖函数在发生错误时会产生异常。
在这里:
监督树是监督者形成的层次化树。树中的每一个节点负责监视它的子节点中发生的错误。
监督者是系统中监视其他进程的进程。被监督的对象是监督者或工作者。监督者必须能够检测到被监视对象所产生的异常,能够启动、停止或重启被监督对象。
工作者是执行任务的进程。
如果一个工作者进程以一个非正常退出信号(参见3.5.6节)而终结,那么监督者就会认为已经发生了一个错误,就会采取措施来修复该错误。
在我们的模型中,工作者并不是任意的进程,而是为数不多的专用进程的实例(称之为behaviour)。
behaviour是其操作被一些回调函数完全特征化的专用进程。这些回调函数一定要是乖函数。
一个关于behaviour的例子是sodbs_server,该behaviour用于编写分布式的、可容错的客户-服务器程序。behaviour需要用一些WBF来进行参数化。
所有程序员都应该理解如何编写WBF,才能编写出可容错的分布式客户-服务器程序。sodbs_server这种behaviour为并发和分布式特性提供了一个可容错的框架。程序员只需要关心的是编写WBF来参数化该behaviour。
为了简便起见,我们这里考虑两种监督层次结构,分别为线性层次体系(linearhierarchies)和AND/OR层次树(AND/OR hierarchy trees)。在接下来的章节里,我将对它们进行图形化描述。
5.2.1图形表示法
监督者和工作者可以用图2所示的符号来简便地表示。
监督者记作方角矩形。在矩形的右上角用一个符号T来标明监督者的类型。
T的值要么为“0”,代表“或(or)”型监督,要么为“A”,代表“与(and)”型监督。关于监督的类型稍后再详述。
监督者能够监督任意个数的工作者或监督者。对每一个被监督的实体,监督者都要知道如何来启动、停止和重启该实体。这种信息被保存在SSRS中,SSRS即“Start Stop and RestartSpecification”(启动停止重启说明)。每个监督者(监督层次体系中的顶层监督者除外)都有且仅有一个监督者直接在它的上方,我们称直接上层监督者为直接下层的监督者的父亲(parent)。相反地,在监督层次体系中某个监督者直接下方的监督者为该监督者的孩子(children)。
工作者被记作圆角矩形。工作者由乖函数来参数化。
5.2.2线性监督
我先说线性层次结构。图3显示了一个由三个监督者组成的线性层次结构。
每个监督者针对其每一个孩子都有一个SSRS,遵守下面的规则:
如果一个监督者被其父亲停止,那么该监督者将停止其所有的孩子。
如果一个监督者的任何一个孩子崩溃,那么该监督者将重启该孩子。
系统通过最顶层的监督者启动而启动。最顶层的监督者第一次启动时,需要用到SSRS1。顶层监督者有两个孩子,即一个工作者和一个监督者。顶层监督者启动一个工作者(为一个通过用乖函数WBF1进行参数化的behaviour),同时启动一个子监督者。层次体系中的下层监督者也是按照类似的方式启动起来,整个系统就跑起来了。
5.2.3与/或监督层级
我们可以把我们的简单的监督层次体系扩展成一个含有与节点或或节点的树型结构。带记号“A”表示一个“与”监督者,带记号“0”表示一个“或”监督者。在一个与/或树中的监督者应遵循如下规则:
如果一个监督者被其父亲停止,那么该监督者将停止其所有的孩子。
如果一个监督者的一个孩子崩溃了,而自己是一个“与”监督者,那么该监督者将停止所有的孩子,然后重启所有的孩子。
如果一个监督者的一个孩子崩溃了,而自己是一个“或”监督者,那么该监督者将重启该孩子。
“与”型监督用于依赖性(dependent)或关联性(co-ordinate)的进程。在“与”型树中,系统运行的成功依赖于所有孩子的成功——因此,当有任何一个孩子崩溃时,就应该停止所有孩子并重启它们。
“或”型监督可以用来协调独立进程(independent process)的行为。在“或”型树中,孩子们的行为被认为是彼此独立的,所以一个孩子不会影响到其它孩子,因此一个孩子出错只需将该孩子进程重启。
落实到具体,我们的“任务层次体系”就是用一个“监督层次体系”来表示的。
在我们的系统中,我们把所有的任务等价于一系列目标,这些目标都具有一个不变量(invariant)——如果与目标相关联的不变量为非假,我们就说达到了该目标。在大多数程序中,对不变量的取值的判断通常对应于一个特别指定的函数的求值语句是否产生了异常。
Candea和Fox之前已经做过相似的工作,他们曾做过一个基于“可递归重启的(recursively-restartable)Java组件”的系统。
请注意,我们将错误区分成了两类:可纠正的(correctable)错误和无法纠正的(uncorrectable)错误。可纠正的错误是指那些在部件中可以被检测到和纠正的错误。无法纠正的错误是指那些能够被检测到,但是没有指定其纠正程序的错误。
上面的讨论都是相当模糊的,因为我们还没有说到底什么算是错误,也没有说在实践中我们如何区分可纠正的错误与无法纠正的错误。
再加上实际情况是绝大多数规格说明书只说明了当系统中的每个部件都根据计划运转时该怎么做,而很少说明当某个特定的错误发生时该怎么做——这就使得情况更加复杂了。的确,如果一个规格说明书严格说明了当一个特定错误发生时该做什么,那或许就有很多人会说这种情况根本就不是错误,而是系统的一个预期特性。这就使得“错误”一词的含意更加模糊。
5.3什么是错误?
当我们的程序运行的时候,运行时系统根本不知道该把什么当作是错误——它只管按照代码来执行。判断运行出现错误的唯一迹象就是产生的异常。
当运行时系统不能够决定该怎么做时,就会自动产生一个异常。例如,当执行一个除法操作时,运行时系统就可能会去检测一种“被0除”的情况,当出现该情况时,就产生一个异常,因为运行时系统不知道该如何处理。一个异常并不总是对应于错误。例如,如果一个程序员已经编写了代码来正确地应对“被0除”这一异常,那么出现这个异常就不必再当成是错误了。
一个异常是否对应于一个错误完全有程序员来决定——在我们的系统中,程序员必须明确地说明系统中那些函数绝对不能产生异常。
一旦一个组件的行为与它的规格说明不再一致,就说该组件发生了故障。
为了我们特定的目的,我们将一个错误定义为观察到的系统行为与期望的系统行为之间的背离。这里期望的行为是指“规格说明中说明的系统应该具有的行为”。
程序员必须确保一旦系统的行为方式与规格说明发生背离,就能够启动某种错误恢复程序,并且这种情况的记录能够被某种持久的错误日志记录下来,以便日后改正。
在构建真实的系统时,情况会因为我们并没有一个完整的规格说明而变得复杂。在这种情况下,程序员应当对什么应该当成错误,什么不应当作错误有一些通用的概念。在缺少显式的规格说明的情况下,我们需要一个隐式的机制,来符合我们的直觉的想法,即一个错误是“导致程序崩溃的事件”。
在SODBS系统中,我们制作了若干乖函数(Well-behaved function,WBF)——乖函数是用来参数化SODBS的behaviour的。这些函数由SODBS的behaviour中的代码来调用。如果对参数化函数的调用产生了一个异常,那么这就被定义为一个错误,一条错误诊断就会被添加到错误日志中。
loop(Name,F,State)->
receive
{From,Query}->
case(catch F(Query,State))of
{’EXIT’,Why}->
log_error(Name,Query,Why),
From!{Name,crash},
loop(Name,F,State);
{Reply,Statel}->
From!{Name,ok,Reply},
loop(Name,F,Statel)
end
end.
回调函数F是在一个catch语句中被调用的。如果产生了一个异常Why,则该异常被当作是一个错误,一条错误消息就被添加到错误日志中。
这只是一个非常简单的例子,但是已经阐明了SODBS的behaviour中错误处理的基本原理。例如,在SODBS的sodbs_server这种behaviour中,我们编写了一个用来参数化服务器的回调模块M。这个模块M除了其他事情之外,还必须导出回调函数handl_call/2。
5.3.1乖函数(Well-behaved functions)
乖函数(WBF)是指正常情况下不应该发生异常的函数。如果一个WBF发生了一个异常,那么这个异常将被解释成一个错误。
如果在对一个WBF进行求值的时候产生了一个异常,那么该WBF应该尽力扭转产生异常的环境。如果WBF中产生了一个不能纠正的异常,那么程序员应该用一个显式的退出(exit)语句来结束该函数。
乖函数的编写应该遵循如下规则:
规则1——程序应该与规格说明同构(isomorphic)。
程序应该忠实地遵循规格说明。规格说明书让做什么,程序就应该做什么,哪怕是愚蠢的事情。程序必须忠实地再生规格说明书中的错误。
规则2——如果规格说明没有说明该做什么,就产生一个异常。
这一点是非常重要的。规格说明通常会说明当发生某种情况时该做什么,而忽略了如果其他情况时该做什么。那么答案就是“产生一个异常”。不幸的是许多程序员都在这时候充分发挥了他们的创造性的猜想力(guess-work),试图猜测设计者当时应该会是怎么样的意图。
如果按照这样来编写系统,那么观测到的异常就会反映出规格说明中的错误。
规则3——如果发生的异常没有包含足够的信息使得可以将该错误隔离,那么就在异常中加一些额外的有用信息。
在程序员编写代码的时候,他们应该自问一下,在一个错误发生时,应该往错误日志中写入些什么信息呢?如果出错信息对调试来说不充分,那么他们就应该往异常中添加足够的信息,以使得程序在下一步能够被调试。
规则4——把非功能性需求变成可在运行时进行检查的断言(assertion)(不变量)。如果断言失败,就产生一个异常。
这种情况的一个例子就是关于循环的终止——一个编程错误可能会致使一个函数进入一个无限循环,从而导致函数不能退出。像这样的错误应该通过请求某个函数在一个规定的时间内终结来检测到。通过时间检测,如果一个函数在规定的时间内没有终止,就产生一个异常,从而结束该函数。
6构建应用
前面各章分别介绍了编写可容错系统的一个一般模型,介绍了用以监视系统的行为的“监督树”的思想。本章将从一般的理论方面转移到监督者在SODBS系统中的特定实现。
为了阐明监督原理,我构建了一个简单的SODBS应用(application)。该应用包含有一个监督者进程,来管理三个工作者进程,这三个工作者进程是sodbs_server,sodbs_event和sodbs_fsm这三种behaviour的实例。
6.1behaviour库
使用了SODBS平台软件的应用都是由许多的“behaviour”构建的。behaviour是对一些公共编程模式的抽象,在用SODBS语言来实现一个系统时可以作为构建块(building blocks)来使用。本章的余下部分将要讨论的behaviour如下所列:
sodbs_server——这种behaviour用来构建在客户-服务器模型中使用的服务器程序。
sodbs_event——这种behaviour用来构建事件处理器程序。事件处理器程序是指像错误日志记录器一样之类的程序。一个事件处理器是响应一个事件流的程序,它不必对向事件处理器发送事件的进程作出应答。
sodbs_fsm——这种behaviour用来实现有限状态机。
supervisor——这种behaviour用来实现监督树。
application——这种behaviour用作打包整个应用程序的容器。
对于每种behaviour,我会介绍其一般原理,还会介绍它的编程API的一些特殊细节,并且会给出一个如何创建该behaviour的实例的一个完整的例子。
使用SODBS平台构建的系统遵循如下层次化的方式:
发布(releases)——发布处于层级的顶端。一个发布包含有构建和运行一个系统的所有必要信息。一个发布由一个软件档案(archive)(以某种形式打包)和一组安装该发布的规程组成。由于发布升级必须在不停止目标系统的情况下安装,因此安装一个发布的过程非常复杂。一个SODBS发布将这种复杂性打包到一个单独的抽象单元中。在一个发布内部,包含零个或多个应用。
应用(applications)——应用比发布要简单,它包含所有的代码和运行一个单独应用所需要的所有操作规程,但并不是整个系统。当一个发布包含多个应用时,系统就应该按照这种方式来组织:要么确保每个不同的应用之间充分独立,要么不同的应用都有着严格的层次化依赖关系。
监督者——SODBS的应用一般都是由一些监督者的实例构成。
工作者——SODBS的监督者监督工作者节点。工作者节点通常是sodbs_server、sodbs_event或sodbs_fsm等behaviour的实例。
我们要特别解释一下应用。应用是从工作者节点开始自底向上(bottom-up)构建的。我会创建三个工作者节点(sodbs_server、sodbs_event和sodbs_fsm的实例各一个)。工作者节点由一个简单的监督树来管理,监督树被打包成一个应用。
我就从工作者节点说起。
6.1.1behaviour库是怎么写成的
SODBS的behaviour都是用类似第4.1节的例子中的编程风格来编写的。只有一个主要的不同,我们不是通过任意的函数来参数化behaviour,而是通过模块的名字来参数化一个behaviour。该模块必须导出一些指定的预定义的(pre-defined)函数。具体哪些函数需要被导出,依赖于behaviour的定义方式。
每个behaviour的完整的API在其使用手册中有详细的文档。
举个例子,假设xyz是sodbs_server这种behaviour的一个实例,那么xyz.sodbs就必须包含如下代码:
-module(xyz).
-behaviour(sodbs_server).
-export([init/1,handle_call/3,handle_cast/2,
handle_info/2,terminate/2,change_code/3]).
...
xyz.sodbs必须导出如上所示的init/1…等六个函数。要创建一个sodbs_server的实例,我们就要调用:
sodbs_server:start(ServerName,Mod,Args,Options)
这里ServerName给服务器命名,Mod填写原子xyz,Args是传递给xyz:init/1的参数,Options是用来控制服务器自身的行为的参数。Options不会作为参数传递给模块xyz。
第4章中给出的例子中对behaviour的参数化的方法在某种程度上比SODBS所采用的方法要更通用。造成这种差异主要是由于历史原因,最初的behaviour是在fun这种方法增加到SODBS中之前编写的。
6.2专用服务器(Private Server)的原理
第4章中我们介绍了专用服务器的思想。专用服务器首先提供了一个“空的”服务器,即一个可以被实例化为服务器的框架。它清晰地阐明了制作一个专用服务器的相关原理。在SODBS系统中,SODBS模块sodbs_server用来构造客户-服务器的服务器模块。
sodbs_server可以通过许多不同的途径被参数化成许多不同类型的服务器。
6.2.1专用服务器的API
为了便于理解sodbs_server的API,我们来看看服务器程序与应用之间的控制流。我会描述一下sodbs_server的API中在本章的例子中将会用到的一个子集。
sodbs_server:start(Namel,Mod,Arg,Options)->Result
在此:
Name1=服务器的名字(见注解1)。
Mod=回调模块的名字(见注解3)。
Arg=传递给Mod:init/1的参数(见注解4)。
Options=控制服务器工作方式的一组选项。
Result=通过求值Mod:init/1而获得的值(见注解4)。
sodbs_server:call(Name2,Term)->Result
在此:
Name2=服务器的名字(见注解2)。
Term=传递给Mod:handle_call/3的参数(见注解4)。
Result=通过求值Mod:handle_call/1而获得的值(见注解4)。
sodbs_server:cast(Name2,Term)->ok
在此:
Name2=服务器的名字(见注解2)。
Term=传递给Mod:handle_cast/3的参数(见注解4)。
注解:
1.Namel应为如{local,Name2}或{global,Name2}般的项式。启动一个本地服务器会在一个单节点上创建一个服务器。启动一个全局服务器会在一个可为其它分布式SODBS节点透明地访问的节点上创建一个服务器。
2.Name2是一个原子。
3.Mod应当导出如下一些或全部函数:init/1,handle_call/3,handle_cast/3,terminate/2。这些函数将会被sodbs_server调用。
4.sodbs_server的某些函数的参数会原封不动地作为参数传递给Mod的某些函数。类似的,Mod的函数的返回值中包含的某些项式也会出现在sodbs_server的某些函数的返回值中。Mod所提供的回调函数应遵循如下规格:
Mod:init(Arg)->{ok,State}|{stop,Reason}
此函数试图启动服务器:
Arg是提供给sodbs_server:start/4的第3个参数。
{ok,State}意思是服务器成功启动了。服务器的内部状态变成了状态State,说明此时对sodbs_server:start的原始调用返回了{ok,Pid},这里Pid是服务器的标识符。{stop,Reason}意思是服务器启动失败了,这种情况下对sodbs_server:start的调用会返回{error,Reason}。
Mod:handle_call(Term,From,State)->{reply,R,S1}
此函数在用户调用sodbs_server:call(Name,Term)的时候被调用:
Term是任意的一个项式(译注:该项式为用户自定义,用于标识具体的调用请求)。
From标识客户。
State是服务器当前的状态。
{reply,R,S1}使sodbs_server:call/2的返回值为R,而服务器的新状态变为S1。
Mod:handle_cast(Term,State)->{noreply,S1}|{stop,R,S1}
此函数在用户调用sodbs_server:cast(Name,Term)的时候被调用:
Term是任意的一个项式。
State是服务器当前的状态。
{noreply,S1}使服务器的状态变为S1。
{stop,R,S1}使服务器停止。服务器停止时要调用Mod:terminate(R,S1)。
Mod:terminate(R,S)->void
此函数在服务器停止的时候被调用,返回值被忽略:
R是服务器终止的原因。
State是服务器当前的状态。
6.2.2专用服务器的例子
这里举一个用sodbs_server实现简单的键-值(Key-Value)服务器的例子。本键-值服务器用一个叫kv1的回调模块来实现的。
kv的第2行告诉编译器本模块时sodbs_server这种behaviour的回调模块。那么如果本模块没有导出sodbs_server所需要的正确的回调函数集,编译器就会产生告警。
客户函数可以在系统内部任何地方调用。回调函数只会在sodbs_server模块内部被调用。
kv:start()通过调用sodbs_server:start_link/4来启动服务器。传给sodbs_server:start_link/4的第1个参数为服务器的位置。在我们的例子中,位置为{local,kv},意思是服务器是一个本地注册的进程,名字为kv。关于位置的参数,还可以填写许多其他的值。包括{global,Name},这种值标明用一个全局名字(而不是本地名字)来注册服务器。用一个全局名字将允许服务器可以被一个分布式SODBS系统中的其他任何节点访问。sodbs_server:start_link/4的其余参数为:回调模块名字(kv)、初始化参数(argl)、和一组控制和调试选项参数([])。如果把控制和调试选项参数设置成[{debug,[trace,log]}]那么将会开启调试器,并把调试信息写入到一个日志记录(log)文件。
当调用sodbs_server:start_link/4时,sodbs_server会调用kv:init(Arg)来对其内部数据结构进行初始化,这里Arg为提供给sodbs_server:start_link/4的第3个参数。
一般来说,init/l应该返回一个{ok,State}式的元组。
kv导出的客户数:store/2和lookup/1通过调用sodbs_server:call/2来实现。
在内部,远程过程调用(remote procedure call)的实现是通过调用回调函数handle_call/2来实现的。第23-29行实现了服务器侧的远程过程调用所需要的回调函数。handle_call的第1个参数是一个模式,必须要与调用sodbs_server:call/2时使用的第2个参数匹配。第3个参数为服务器的状态。在一般情况下,handle_call应该返回一个{reply,R,Statel},这里R是远程过程调用的返回值(该值也会成为sodbs_server:call/2的返回值,最终返回给客户),Statel将变成服务器的新的状态值。在第12行stop/0中调用的sodbs_server:cast(kv,stop)用来停止服务器。sodbs_server:cast(kv,stop)的第2个参数stop作为了31行中handle_cast/2的第1个参数,handle_cast/2的第1个参数为服务器的状态。handle_cast返回的{stop,Reason,State}将迫使专用服务器去调用kv:terminate(Reason,State)。这种处理给了服务器一个机会去执行任何希望在退出之前执行的临终操作。当termintate/2返回时,专用服务器会停止下来,其所有已注册的名字也被移除。
在本例中,我们只是展示了一个使用专用服务器的简单的例子。sodbs_server的手册将给出传给sodbs_server的回调函数和控制函数的参数能够接受的值的所有选择。专用服务器可以用许多种不同的方式来参数化,以便简化作为本地服务器或分布式SODBS节点网络上的全局服务器的运行。
专用服务器还有许多内置的调试帮助手段,可以方便程序员使用。用sodbs_server构建的服务器的内部发生一个错误时,关于哪里发生了错误的一个完整的调用轨迹会被自动添加到系统的错误日志中。该信息对于服务器的死因调查通常是很有意义的。
6.3专用事件管理器(Event Manager)的原理
事件管理器behavioursodbs_event提供了构建特定于应用的事件处理函数的一种专用框架。事件管理器可以完成如下任务:
错误处理。
告警管理。
调试。
设备管理。
事件管理器可以提供命名对象,事件可以发送给这些命名对象。在1个事件管理器中,可以安装0个或多个事件处理器(event handler)。
当一个事件达到一个事件管理器时,它将会被该事件管理器内部安装的所有事件处理器进行处理。事件管理器可以在运行时被操纵,特别是我们可以在运行时安装一个事件处理器,去掉一个事件处理器或用另一个处理器来代替一个处理器。
我们先来看一些定义:
事件(Event)——发生的某件事情。
事件管理器(Event Manager)——一个对某一类事件的处理进行协调的程序。事件管理器提供一个命名对象,事件可以发送给它。
通知(Notification)——向一个事件管理器发送一个事件的动作。
事件处理器(Event Handler)——一个可以处理事件的函数。事件处理器必须是类型如下的函数:
State x Event->State’
事件管理器维护一个{M,S}形式的“模块×\u29366X态”二元组的列表。我们称这样的列表为模块-状态(MS)列表。
假设事件管理器的内部状态可以用如下MS列表来表示:
[{M1,S1},{M2,S2},…]
当事件管理器接收到一个事件E的时候,如上的列表将变为:
[{M1,S1New},{M2,S2New},…]。
这里应该有{ok,SiNew}=Mi:handle_event(E,Si)。
事件管理器可以被当作是一个一般的常规有限状态机,只不过不是维护一个状态,我们维护的是一“组”状态和一组状态迁移函数。
如同我们可能预期的那样,sodbs_event的API中也有许多接口函数,是用来操纵服务器中的{Module,State}对的。sodbs_event比我们在这里的一点简单介绍要强大得多。可以通过阅读SODBS文档中关于事件处理方面的手册来了解的所有的细节。
6.3.1专用事件管理器的API
事件管理器(sodbs_event)导出了下列函数:
sodbs_event:start(Namel)->{ok,Pid}|{error,Why}
创建一个事件管理器。
Namel是事件管理器的名字(见注解1)。
{ok,Pid}意味着事件管理器开启成功。Pid就是事件管理器的进程PID。
{error,Why}是在事件管理器开启失败时的返回值。
sodbs_event:add_handler(Name2,Mod,Args)->ok|Error
添加一个新的处理器到事件管理器中。如果事件管理器的原有状态是L,那么当此操作成功时,事件管理器的状态将变成[{Mod,S}|L],这里S是调用
Mod:init(Args)获得的值。
Name2是事件管理器的名字(见注解1)。
Mod是回调模块的名字(见注解2)。
Arg是传递给Mod:init/l的参数。
sodbs_event:notify(Name2,E)->ok
发送一个事件E给事件管理器。如果事件管理器的状态是一个{Mi,Si}的集合集且收到一个事件E,那么事件管理器的状态将编程{Mi,SiNew}的集合,而
{ok,SiNew}=Mi:handle_event(E,Si)。
sodbs_event:call(Name2,Mod,Args)->Reply
执行事件管理器中的某个事件处理器上的某个操作。如果事件管理器的状态列表包含一个元组{Mod,S},那么将会调用Mod:handle_call(Args,S)。Reply就是源自该调用的返回值。
sodbs_event:stop(Name2)->ok
停止事件管理器。
注解:
1.事件管理器遵循与专用服务器相同的命名约定。
2.一个事件处理器必须导出下列中的一些或全部函数:init/1,handle_event/2,handle_call/3,terminate/2。
一个事件处理器模块应该具有下列API:
Mod:init(Args)->{ok,State}
这里:
Args来自sodbs_event:add_handler/3的第3个参数。
State是本事件处理器的初始状态值。
Mod:handle_event(E,S)->{ok,S1}
这里:
E来自sodbs_event:notify/2的第2个参数。
S是本事件处理器的原有状态值。
S1本事件处理器的新的状态值。
Mod:handle_call(Args,State)->{ok,Reply,Statel}
这里:
Args来自sodbs_event:call/2的第2个参数。
State是本事件处理器的原有状态值。
Reply将成为sodbs_event:call/2的返回值。
Statel是本事件处理器的新的状态值。
一个简单的错误记录器
Mod:terminate(Reason,State)->void
这里:
Reason标明事件管理器为什么被停止。
State是本事件处理器的当前状态值。
6.3.2专用事件管理器的例子
我们用sodbs_event来构建了一个简单的错误记录器。该错误记录器会跟踪最近的5个错误消息,还可以在收到report事件时显示最近的5个错误消息。
注意,simple_logger.sodbs中的代码是纯顺序化的。在此我们会注意到传递给sodbs_server的参数的形式与传递给sodbs_event的参数的形式的相似之处。一般而言,传递给不同behaviour模块中诸如start,stop,handle_call等等函数的参数,我们会设计得尽量的相似。
6.4专用有限状态机(Finite State Machine)的原理
许多应用(例如协议栈)可以用有限状态机(FSM)来建模。FSM可以用有限状态机behaviour,即sodbs_fsm来编写。
一个FSM可以用如下形式的一组规则来描述:
State(S)x Event(E)->Actions(A)x State(S’)
...
这个规则的意思是:
如果我们处于状态S,发生了一个事件E,那么我们应该执行操作A,并把状态迁移到S’。
如果我们选择用sodbs_fsm这种behaviour来编写一个FSM,那么上面的状态
迁移规则就应该被写作一些遵循如下约定的SODBS函数:
StateName(Event,StateData)->
..code for actions here...
{next_state,StateName’,StateData’}
6.4.1专用有限状态机的API
有限状态机behaviour(sodbs_fsm)导出了下列函数:
sodbs_fsm:start(Namel,Mod,Arg,Options)->Result
该函数的功能跟先前讨论过的sodbs_server:start/4一样。
sodbs_fsm:send_event(Name 1,Event)->ok
发送一个事件给标识符为Name 1的FSM。
回调模块Mod必须导出下列函数:
Mod:init(Arg)->{ok,StateName,StateData}
当一个FSM启动的时候,它会调用init/1,Mod:init/l应该返回一个初始状态StateName,和一些该状态的相关数据StateData。接下来调用sodbs_fsm:send_event(…,Event)时,FSM会调用Mod:StateName(Event,StateData)。
Mod:StateName(Event,SData)->{nextstate,SNamel,SDatal}
在FSM运转时,StateName、Event和SData表示FSM的当前状态。而FSM的下一个状态应为SNamel,下一个状态相关的数据应该为SDatal。
6.4.2专用有限状态机的例子
为了描述一个典型FSM的应用,我们利用sodbs_fsm写了一个简单的包聚合器(packetassembler)的程序。该包聚合器有2个状态:waiting和collecting。当它处于waiting状态时,它期望收到包含有包长度的信息,此时它会进入collecting状态。当它处于collecting状态时,它期望收到许多小的数据包,这些小的数据包将会被聚合。当所有小数据包的长度等于总的包长度时,FSM会打印出聚合包,并重新进入waiting状态。
我们可以在SODBS的shell中下达一段命令来看看这个包聚合器的用法:
>packet_assembler:start().
{ok,<0.44.0>}
>packet_assembler:send_header(9).
ok
>packet_assembler:send_data(″Hello″).
ok
>packet_assembler:send_data(″″).
ok
>packet_assembler:send_data(″Joe″).
Got data:Hello Joe
ok
再次强调,sodbs_fsm比这里所描述的要有用得多。
6.5专用监督者(Supervisor)的原理
到目前为止,我们所着重讲到的都是为了解决典型应用问题的一些基本behaviour,而编写应用中大部分问题也都可以用基本的客户-服务器、事件处理、和FSM等behaviour来解决。这里要讲的sodbs_sup这种behaviour是第一个元行为(meta-behaviour),即用来将基本behaviour粘合成一个监督体系的behaviour。
6.5.1专用监督者的API
专用监督者的API是极其简单的:
supervisor:start_link(Namel,Mod,Arg)->Result
本函数开启一个监督者,其间调用Mod:init(Arg)函数。
回调模块Mod必须导出init/1函数,规格如:
Mod:init(Arg)->SupStrategy
SupStrategy是描述监督树的项式。
SupStrategy是一个描述监督树中的工作者们如何被启动、停止和重启的项式。我不在这里详细描述,接下来的一个简单的监督树的例子会有比较详尽的描述。关于专用监督者的完整细节可以参见用户手册的相关部分。
6.5.2专用监督者的例子
我们前面的例子是一个监督者监督了前面各节所介绍的三个工作者。我们现在来看看当运行时这些工作者发生错误时,会发生什么事情。
simple_sup.sodbs模块定义了该监督者的行为。开始在第7行调用了superivsor:start_link/3——这与系统中其它behaviour的调用习惯是一致的。?MODULE是一个宏,被展开为当前模块的名字simple_sup。最后一个参数被设置为nil。监督者开启的时候会用start_link/3的第3个参数作为参数去调用指定的回调模块中的init/l函数。
init/1返回一个定义了监督树的形状和所采用的策略的数据结构。项式{one_for_one,5,1000}(第11行)告诉监督者构建一个“或”型监督树(参见5.2.3小节)——这是因为它所监督的三个工作者是彼此没有关系的。数字5和1000指定了一个重启频率(restartfrequency)——如果监督者在1000秒钟内重启了被监督者超过5次,则监督者本身将会出错。
一个简单的监督者
这里我们的监督树中有三个被监督对象,但是我只描述包聚合器是如何添加到监督树中的。另外两个工作者的添加方法依次类推。
第13-15行指定了包聚合器这个工作者。
第13行开始,元组中的第一个元素描述了包聚合器如何被监督。原子packet是一个任意的名字(在是在本监督者实例的内部要保证是唯一的),可以用来指示监督树中的节点。
因为被监督者本身也是SODBS的behaviour的实例,所以把他们添加到监督树中会很容易。下一个参数(第14行)是一个3元组{M,F,A},被监督者用来启动指定的进程。如果监督者要启动一个被监督的进程,它会去调用apply(M,F,A)。
第15行的第一个参数permanent是说被监督的进程是一个所谓的“永恒”进程。一个永恒进程在它出错时将会被其监督者自动重启。
一个被监督进程不单要指明如何被启动,还需要按照一定的方式来编写。例如,它必须能够在监督者要求它终止时井然有序地终止。为了做到这一点,被监督进程必须遵守所谓的“停止协议”(shutdown protocol)。
监督者通过调用shutdown(P,How)来终止一个工作者进程,这里P是工作者的Pid,而How决定了工作者如何被停止。shutdown定义如下:
shutdown(Pid,brutal_kill)->
exit(Pid,kill);
shutdown(Pid,infinity)->
exit(Pid,shutdown),
receive
{’EXIT’,Pid,shutdown}->true
end;
shutdown(Pid,Time)->
exit(Pid,shutdown),
receive
{’EXIT’,Pid,shutdown}->
true
after Time->
exit(Pid,kill)
end.
如果How是brutal_kill,那么工作进程会被杀死(参见第3.5.6小节)。
如果How是infinity,那么一个shutdown的信号会被发送给工作者进程,而工作者进程应当回以一条{‘EXIT’,Pid,shutdown}消息。
如果How是一个整数T,那么工作者进程需要在给定的T毫秒事件内终止,如果在T毫秒之内没有收到{‘EXIT’,Pid,shutdown}的消息,那么该进程会被无条件杀死。
第15行的整数500是关停协议所需要的一个“关停时间”。着说明如果监督者想要停止一个被监督进程时,它被允许有最多500毫秒的时间来停止目前正在处理的事情。
参数worker表示被监督进程是一个工作者进程(在5.2节中我们说过一个被监督者进程可以为一个工作者或监督者进程),[packet_assembler]是本监督者使用的所有模块的列表(这个参数在同步代码变更操作时要用到)。
一旦所有的事情都定义好了,我们就可以编译运行该监督者了。在接下来的的演示脚本中,我启动了一个监督者,并触发了被监督者中的几个错误。被监督者会死掉并被监督者自动重启。
第一个例子是展示一下当包聚合器中发生一个错误时,会发生什么。我们启动监督者,并检查一下包聚合器的Pid。
1>simple_sup:start().
Packet assembler starting
Key-Value server start ing
Logger starting
{ok,<0.30.0>}
2>whereis(my_simple_packet_assembler).
<0.31.0>
打印输出显示,所有的服务器都起来了。
现在我们来发送一个指定聚合长度为3字节的命令,而接下来发送一条4字节长的数据:
3>packet_assembler:send_header(3).
ok
4>packet_assembler:send_data(″oops″).
packet assembler terminated:
{if_clause,
[{packet_assembler,collecting,2},
{sodbs_fsm,handle_msg,7},
{proc_lib,init_p,5}]}
ok
Packet assembler start ing
=ERROR REPORT====3-Jun-2007::12:38:07===
**State machine my_simple_packet_assembler terminating
**Last event in was″oops″
**When State==collecting
**Data=={3,0,[]}
**Reason for termination=
**{if_clause,[{packet_assembler,collecting,2},
{sodbs_fsm,handle_msg,7},
{proc_lib,init_p,5}]}
这个错误引起的打印相当多。首先是包聚合器崩溃了,看第一条错误输出就知道。紧接着,监督者检测到了包聚合器崩溃的情况并重启了它——该进程重启的时候会打印“Packetassembler starting”消息。最后,有一条长长的、含有所期望的有用信息的出错消息。
该出错消息包含了FSM在崩溃的时刻的状态信息。它告诉我们,FSM当时所处的状态是collecting,该状态关联的数据为一个3元组{3,0,[]},并且引起FSM崩溃的事件是“oops”。这些信息对于FSM的调试是相当有用的。
在这里,错误日志被直接定向到了标准输出。但是在实际产品系统中,错误日志被配置为定向到持久存储设备并触发报警装置,报警装置包括:RS485接口的市售JCJ601型智能声光报警器、标准市售串口GSM modem(内置中国移动sim卡),监控程序定期(每秒钟)检查一次所有设备和进程,一旦出现任何问题,即通过串口发出告警信息,通过声光报警器和GSMmodem发出声光报警以及手机短信报警(内含必要的诊断信息),在监控室的监控显示器上也有相应的出错信息提示。对于系统可自行修复的警告性错误,只报警一次。对于系统无法自行修复的错误(如无法重启报错硬件)则每分钟持续报警一次,直至技术人员排除故障为止。
我们可以确认一下,监督者已经正确地重启了包聚合器,求值一下whereis(my_simple_packet_assembler)就会返回新起来的包聚合器的Pid。
6>whereis(my_simple_packet_assembler).
<0.40.0>
7>packet_assembler:send_header(6).
ok
8>packet_assembler:send_header(″Oknow″).
Got data:Ok now
ok
用类似的方法,我们可以触发在Key-Value服务器中故意留下的那个错误:
12>kv:store(a,1).
ack
13>kv:lookup(a).
{ok,1}
14>spawn(fun()->kv:lookup(crash)end).
<0.49.0>
K-V server terminating
Key-Value server starting
15>
=ERROR REPORT====3-Jun-2007::12:54:10===
**Generic server kv terminat i ng
**Last message in was{lookup,crash }
**When Server state=={dict,1,
16,
16,
...many lines removed...
**Reason for termination==
**{badarith,[{kv,handle_call,3},{proc_lib,init_p,5}]}
15>kv:lookup(a).
error
请注意,kv:lookup(crash)必须通过一个没有连接到shell进程(query shell)的临时进程来调用。这是因为监督者是通过调用supervisor:start_link/4的方式来启动的,所以监督者被连接到了shell进程。在shell里直接调用kv:lookup(crash)会使监督者进程也崩溃掉,这很可能不是我们所期望的。还请注意专用监督者和预先定义的(pre-defined)behaviour是如何一起(together)工作的。专用监督者与基本behaviour不是设计成各自孤立的,而是设计成相互补充的。
还有,默认的做法是在错误日志中提供尽可能多的有用信息,并努力使系统处于一种安全的状态。
6.6专用应用(Application)的原理
我们迄今已经构建了三种基本behaviour,并把他们放进了一棵监督树中;
剩下的事情就是把所有的东西都塞到一个应用(application)里。
一个应用就是一个包含交付一个应用程序时需要的一切事物容器。
应用的编写方式跟先前讨论的behaviour的编写方式不一样。之前的behaviour都要用到回调模块,回调模块导出一些预定义函数。
应用不使用回调函数,而是表现为文件系统中的文件、目录、子目录的一种特殊的组织形式。一个应用的最重要的部分包含在应用描述子文件(applicationdescriptor file)(一个扩展名为.app的文件)中,该文件描述了一个应用所需要的所有资源。
6.6.1专用应用的API
应用是用一个应用描述子文件来描述的。一个应用描述子文件的扩展名是.app。在用户手册中,对于一个应用的.app文件的结构作了如下定义:
{application,Application,
[{description,
Description},
{vsn,
Vsn},
{id,
Id},
{modules,
[Modulel,..,ModuleN]},
{maxT,
MaxT},
{registered,
[Name1,..,NameN]},
{applications,
[Appl1,..,ApplN]},
{included_applications,[Appl1,..,ApplN]},
{env,
[{Parl,Val1},..,{ParN,ValN}]},
{mod,
{Module,StartArgs}},
{start_phases,
[{Phasel,PhaseArgsl},..,
{PhaseN,PhaseArgsN}]}]}.
应用联合清单(application association list)中的所有键(key)都是可选的,如果被忽略,就会采用一个合理的默认值。
6.6.2专用应用的例子
我们测试其中的一个服务器:
1>application:start(simple,temporary).
Packet assembler starting
Key-Value server starting
Logger starting
ok
2>packet_assembler:send_header(2).
ok
3>packet_assembler:send_data(″hi″).
ok
Got data:hi
现在我们可以停止该应用:
4>application:stop(simple).
=INFO REPORT====3-Jun-2007::14:33:26===
application:simple
exited:stopped
type:temporary
ok
在停止了应用以后,应用中运行着的所有进程都将依次关掉。
6.7系统与发布(release)
本章的铺陈是“自底向上”的。我以简单的东西开始,将它们组合成更大的更复杂的单元。我们是以几个基本behaviour如sodbs_server、sodbs_event和sodbs_fsm开始的,然后把这些基本专用模式组织到了一个监督层次体系中,然后把这个监督层次体系构建到了一个应用包中。
最后一步是将应用包构建到一个发布中。一个发布可以将多个不同的应用打包成一个单一概念单元。结果就是可以移植到目标环境的少数几个文件。
构建一个完整的发布是一个复杂的过程——一个发布不仅要描述系统的当前状态,而且还要知道系统的之前的版本。
发布不但要包含软件当前版本的信息,而且还要包含软件的之前的发布的信息。特别地,发布应该包含将系统从早先版本的软件升级到当前版本的软件的规程。这种升级通常需要在不停下系统的情况下进行。一个发布还必须能够处理新软件因某些原因出现安装失败的情况。如果一个新发布出错,系统还应该能够回退到之前的某个稳定状态。所有的这些都由SODBS系统的发布管理组件来处理。
6.7.1软件升级
我们做了一套自己的发布管理系统,是对SODBS系统中的专用发布包的一个扩展。这是一个天生的分布式系统。我们希望分布式系统的软件升级有一个“事务级”(transaction)的语义,即要么系统的所有节点整体进行软件升级,要么升级失败任何节点上的软件都没有改变。
在本系统中,整个系统的软件同时有两个版本共存,一个老版本和一个新版本。增加一个新版本软件的时候,当前版本就关成了老版本,而新添加的版本变成了新版本。
为了做到这一点,所有的BMR软件升级包都是以一种可逆(reversible)的方式编写的。即不但可以将老版本软件动态地升级到新版本,而且可以从一个新版本变回到老版本。
升级所有节点的软件是按四个步骤完成的。
1.在第一阶段,新版本的软件被分发到每个节点——这通常会成功。
2.在第二阶段,所有节点上的软件都从老版本变到新版本。如果有任何节点上的转换失败,则所有运行新版本软件的节点都退回到运行老版本的软件。
3.在第三阶段,系统中所有节点都运行着新版本的软件,但是如果发生任何错误,则所有节点都回退去运行老版本的软件。这时候系统尚未确认只运行新版本软件。
4.在新系统已经成功运行了一段很长时间以后,操作人员可以“确认”(commit)软件的改变。系统确认(即第四阶段)将改变系统的行为。在确认以后如果再发生错误,那么系统将用新版本软件重新启动,而不是回退到老版本。
该机制与NASA开发的深太空应用系统X2000上的机制相仿,他们的软件也需要在不停止系统运行的前提下实现升级。
需要补充一下的是,本系统考虑到了在软件正在进行升级的时候分布式系统中有节点处于“掉线”(out-of-service)状态的情况。在这种情况下,当该节点重新加入到系统中时,它将学习一下它离线期间系统的变化,之后进行任何必要的软件升级。
6.7.2硬件在线更替
与系统软件升级相仿,硬件节点的增减也是在线进行的,并且通过软件升级步骤来实现。每当系统操作人员向系统中增加或移除一个硬件节点时,都将提出一个新的软件配置,系统将此新配置以软件升级的形式发出,新的可用硬件资源将被所有软件节点知悉并开始加以运用,将移除的硬件节点资源将不再有软件去访问它。当新的软件配置生效后,系统将发出软件系统升级完毕的通知,操作人员可据此确认新增硬件已投入运转,或关闭已在逻辑上移除的硬件节点并搬走它。
6.8讨论
SODBS系统中实现behaviour的专用模块是有专家编写的。这些模块都是建立在多年的金融行业与技术经验的基础上的,代表了编写代码来解决某些特殊问题的“最佳实践”。
使用SODBS的behaviour来构建的系统拥有非常有规则的结构,例如,所有的客户-服务器和监督树都有着同样的结构。使用behaviour,就会迫使解决某一问题时采用公共的结构。应用程序员只需要提供定义他们的特殊问题的语义的代码,而所有的基础设施都由behaviour自动提供。
对于加入已经存在的团队的一个新程序员来说,基于behaviour的解决问题的方式更容易理解。只要他们熟悉了behaviour,他们就能够很轻易地识别出哪种情况下应该用哪种behaviour。
系统编程中大部分的“复杂问题”(tricky)都被隐蔽在了behaviour的实现中(这些复杂的问题实际上比我们这里描述的还要复杂得多)。如果你回头看看客户-服务器和事件处理器behaviour,你会发现所有处理并发、消息传递等等事务的代码都被隔离在了behaviour的“专用”部分,而“问题相关”的代码都是一些有着良好的类型定义的纯顺序化函数。
这正是编程中人高度期望的一种境界——“困难”的并发程序被隔离成了系统中的一些定义良好的小的部分。系统中绝大部分代码能够用有着良好类型定义的顺序化的程序来编写。
在我们的系统中,behaviour解决的都是正交的问题(orthogonal problems)——例如,客户-服务器与工作者-监督者没有任何关系。在构建真实系统的时候,我们会挑选并混合使用behaviour,并把他们用不同的方式组合起来解决问题。
为一个软件设计者提供一个小的、混合的behaviour集有如下诸多好处:
它关注于一小组久经考验的技术。我们事先都都知道单个技术可以在实现中工作很好。如果对设计完全不加限制并且活动绝对自由,那么设计者就可能会受到诱惑制造出一些有着不必要的复杂性的东西,或者制造一些根本不能实现的东西。
它允许设计者以一种精确的方式来构造和讨论设计。它提供了一个谈论时的共同词汇。
它完成了设计与实现之间的反馈环。这里所讲的所有behaviour都有实用。
7SODBS介绍
自组织分布式金融系统(Slef-Organized&Distributed Banking System)是为了构建和运行安全、稳健的金融系统而设计的一个软件系统。SODBS系统是设计来运行在通常的操作系统之上的一个所谓的“中间件平台”。
SODBS的发布包含有如下一些部件:
1.SODBS的编译器和开发工具。
2.适应多种不同目标环境的SODBS运行时系统。
3.覆盖广泛的公共应用的一些库。
4.实现公共行为模式的一组设计模式。
5.用来学习如何使用该系统的一些教学资料。
6.大量的文档。
SODBS已经被移植到了许多不同的操作系统上,包括所有的Unix类的系统(SCO openserver、Linux、FreeBSD、Solaris、OS-X…),大多数的Windows操作系统(Windows 2000、Windows2003…)。
SODBS运行时系统是一个用来运行由C的BEAM编译器产生的中间码(intermediate code)的虚拟机。它同时也为SODBS编译器产生的本地码(native code)提供运行时支撑服务。
SODBS运行时系统提供了许多传统上由操作系统提供的服务,所以,SODBS运行时系统远不仅仅提供纯序列化语言的运行时支撑,而比这要复杂得多。所有得SODBS进程都由SODBS运行时进程来管理——即使在一个SODBS运行时系统控制着数以万计的SODBS进程的时候,宿主操作系统也只会感到只有一个进程在运行,那就是SODBS运行时系统本身。
另一方面,与其他语言相比,SODBS的编译器又是相当简单的。编译通常只是一个从SODBS代码到一条合适的虚拟机原语的一个简单翻译。所以,例如SODBS中的spawn原语被翻译成虚拟机中的一条单独的操作码(opcode)(即spawn原语的实现),然后付出很大的努力使得操作码的实现尽量的高效。
7.1库
SODBS的发布包包含由一个很大的库集,为了发布的目的,其中所有的库都作为SODBS应用的实例。例如发布包V1.0就包含如下这些应用:
appmon——一个监控和操纵监督树的一个图形化工具。
asnl——一个按照ASN.1定义的一个编译器和运行时编/解码支持包。
compiler——SODBS的编译器。
crypto——一个用于加密/解密数据和计算消息摘要(message digests)的函数集。
debugger——一个SODBS源代码调试器。
sodbs_interface——一个用于与分布式SODBS节点通信的库文件集。
erts——SODBS运行时系统。
et——一个事件跟踪器和一些记录事件数据并进行图形化表示的工具。
eva——负责“事件与告警”处理的应用。
gs——一个图形系统,一组用于构建GUI的图形函数。
ic——SODBS的IDL编译器
inets——一个HTTP服务器和一个FTP客户。
jinterface——一个编写Java与SODBS的接口的工具。
kernel——系统得以运行所需要的两个基本库之一(另一个是stdlib)。
本库包含文件服务器、代码服务器的实现。
megaco——支持Megaco2/H248协议的库集。
mnemosyne——一种用在sodbs_db上的数据库查询语言。
sodbs db——一个具有SODBS的软实时特性的DBMS(数据库管理系统)。
observer——一个用于跟踪和观测分布式系统的行为的工具集。
odbc——一个用于SODBS访问SQL数据库的ODBC接口。
orber——一个CORBA对象请求代理的SODBS实现。注意:还有其它一些单独的应用,来提供对不同CORBA服务(如事件、通知、文件传输等)的访问。
os_mon——一个监控外部操作系统的资源使用情况的工具。
parsetool——解析SODBS的工具。包括yecc,即LALR(1)解析器生成器(parsergenerator)。
pman——一个查看系统状态的图形化工具。pman可以用来查看本地或远端的SODBS节点。
runtime_tools——运行时系统所需要的各种小函数。
sasl——“System Architecture Support Libraries”(系统结构支持库)的缩写。本应用包含对告警处理(alarm handling)和发布管理(managingreleases)的支持。
snmp——简单网络管理协议(Simple Network Management Protocol)的SODBS实现。本应用包含一个MIB编译器和一些MIB编写的工具。
ssl——一个SODBS的安全套接字层(secure sockets layer)接口。
stdlib——系统得以运行的“必备的”SODBS库集。另一个必备的库集是kernel。
toolbar——一个可以从中开启应用的图形化工具条。
tools——一个由各种用于分析和监测SODBS程序的独立应用组成的包。这些应用即一些性能评估(profiling)、覆盖率分析(coverage analysis)、交叉引用分析(cross referenceanalysis)的工具。
tv——一个“表浏览器”(table viewer)。本表浏览器是一个可以对sodbs_db数据库种的表进行图形化浏览的图形化应用。
webtool——一个用于管理基于网页的工具(如inets)的系统。
postool——一个用于管理Pos机的工具系统。
e-bank——一个用于电子支付的系统。
SODBS库集提供了一个高度成熟的工具集,然而,SODBS库集是相当庞杂的。
8案例研究
文档的本部分将展示一个系统的研究案例,这些系统均是用SODBS平台部署的。这个系统是北京资和信担保有限公司的SODBS-1系统——SODBS-1系统是一个大容量的金融支付系统。SODBS-1广泛应用了SODBS库集,因此它为SODBS库集的功能性提供了一个很好的证明。
8.1方法学
在案例研究中,我所关注的是如下方面:
问题领域——问题领域是什么?这个问题属于SODBS的设计所要解决的问题范围吗?
代码的量化特性——写了多少行代码?一共有多少个模块?这些代码是如何组织的?程序员都遵循了设计规范吗?定下设计规范有用处吗?哪些是好的?哪些是不好的?
可容错性的证据——系统可容错吗?SODBS的初衷就是为了构建可容错的系统。有证据证明确实发生了运行时错误并被成功地纠正了吗?一个编程错误发生的时候产生的信息对于后续的程序纠正是不是足够充分?
系统的可理解性——系统的可理解性如何?便于维护吗?
我不是问一些关于系统属性的笼统的问题,而是去寻找了一些明确的证据,来证明系统确实在按照我们期望的方式运行。特别是:
1.是否有证据证明系统确实曾因为编程错误而崩溃过,并且该错误被纠正了,并且系统能够从该错误中恢复过来,并且在错误被纠正后能以一种让人满意的方式运行?
2.是否有证据证明系统已经运行了很长时间,并且期间已经发生过软件错误而系统依然稳固?
3.是否有证据证明系统的代码曾经“在运行中”(on the fly)升级。
4.是否有证据证明像垃圾回收这些机制起了作用(也就是我们已经长时间运行了垃圾回收系统而没有发生垃圾回收的错误)?
5.是否有证据证明错误日志中的信息对于出错后的错误定位很有意义?
6.是否有证据证明系统的所有代码都以某种方式组织了起来,从而大多数的程序员不必关心系统中使用的并发模式的细节?
7.是否有证据证明监督树如期所望地工作着?
8.代码是否按照“净/脏”风格来组织?
上面的第1、2、5条的存在是因为我们希望检测我们关于编写可容错系统的思想在实践中起到了作用。
第4条检验的是对于必须长时间运行的系统来说,垃圾回收确实起了作用。
第6条是对SODBS的behaviour的抽象能力的一个衡量。有太多的原因使我们希望能够“抽象出”很多情形下普遍存在的并发处理的细节。SODBS的behaviour集就是尝试这么做。对于一个初涉我们程序的程序员来说,他在多大程度上忽略了并发处理是度量SODBS的behaviour是否适合开发系统软件的一个重要的指标。
我们可以通过观察程序员在他们的代码中多么频繁地使用显式消息传递和进程操作原语来评估并发处理可以被忽略的程度。
第7条检验了监督者策略是否如期望般地起效。
第8条检验了我们是否可以按照附录B中给出的编程规范来编程。特别是它的指导方针强调了按照“净/脏”方式来组织系统的重要性。这里我们说的“净”代码是指没有副作用的代码,这样的代码比“脏”代码更容易理解,而“脏”代码是指有副作用的代码。
我们的整个系统跟硬件操作是息息相关的,而这种硬件操作就会带来副作用。因此,我们关心的不是可否避免副作用,而是我们能够在多大程度上把副作用限制在尽量少的模块中。与其让有副作用的代码均匀地散布在整个系统中,不如希望能够把大量的副作用限制在少数“脏”模块中,而大多数模块都以无副作用的方式编写,与“脏”模块组合起来成为整个系统。对代码进行一下分析就可以揭示这种组织方式是否可行。
当然“反例”也很重要。我们想知道我们的范型不适用的所有情形,以及这种不适用是否是一个大问题。
8.2SODBS-1
SODBS-1系统是北京资和信担保有限公司试运行的一套应用实例。整个系统由许多可伸缩的模块组成——每个模块提供10000在线用户的支付操作容量,那么16个模块加起来联在一起就能形成一套支持160000人在线的金融支付系统。
SODBS-1是为支持“运营商级”不停机运转而设计的。该系统由重复的硬件来提供硬件冗余,并且硬件可以在不打断业务的情况下添加到系统中或从系统中移除。软件必须要能够应付硬件和软件故障。因为系统是为不停机运转而设计的,所以它必须能够在不干扰系统流量的前提下进行软件修改。
8.3软件的量化特性
下面展示了一个对SODBS软件的简单统计的分析结果。这份简单统计显示了该系统在2008年6月5日当时的状态。
这份分析报告只关注于系统的SODBS代码的一些量化特性。系统的总体量化特性如下:
总的SODBS模块的数量
2248
“净”模块数
1472
“脏”模块数
776
代码行数
1136150
总的SODBS函数个数
57412
“净”函数的个数
53322
“脏”函数的个数
4090
“脏”函数个数/代码行数之比率
0.359%
上表中只是粗浅地对每个模块或函数是“净”是“脏”进行区分而做的一个简单的分析。如果一个模块中有任何一个函数是“脏”的,我们就认为它是“脏”的,否则就认为它是“净”的。为了简化处理,如果一个函数进行了接收或发送数据,或者调用了如下一些SODBS的BIF,我们就说它是脏的:
apply,cancel_timer,check_process_code,delete_module,
demonitor,disconnect_node,erase,group_leader,halt,link,
load_module,monitor_node,open_port,port_close,port_command,
port control,process_flag,processes,purge_module,put,register,
registered,resume_process,send_nosuspend,spawn,spawn_link,
spawn_opt,suspend_process,system_flag,trace,trace_info,
trace_pattern,unlink,unregister,yield.
之所以这样区分,是因为调用了这些BIF的代码段都会有潜在的危险性。请注意我们在这里给“脏模块”下了一个特别简化的定义。直觉上似乎应该给“脏模块”一个递归地定义,即如果一个模块中有任何函数调用了“危险的”BIF或另一个模块中的“脏函数”,则该模块为“脏模块”。不幸的是,如果按照这个定义来判定,那么系统中几乎所有的模块都将被判为“脏模块”。
原因在于,如果你统计一下对某个模块导出的所有函数的调用的传递闭包(transitiveclosure),你就会发现这个传递闭包实际上囊括了系统的几乎所有模块。这个传递闭包之所以这么大,应归咎于C语言库中许多模块都发生了“泄漏”(leakage)。
我们简单地认为,所有的模块都写得很好且经过了测试,并且如果一个模块确实包含有副作用,那么在编写该模块时也会注意这种副作用不会泄漏出该模块,从而对调用该模块的代码造成影响。
按照这种定义,65%的模块就都是“净模块”。由于只要模块中含有一个“脏函数”该模块就被视为“脏模块”,那么看看“净函数”/“脏函数”的比率或许更有意思。只要函数中发生了一次对不清白的BIF的调用,该函数就被视为“脏函数”。在函数级,我们可以看到92%的函数都是以无副作用的方式编写的。
还应注意,在所有代码中,总共包含有3067个“脏函数”,也就是每1000行代码包含的脏函数个数不超过4个。
从结果看,脏函数的分布情况既有值得褒奖的地方,也有亟待改进的地方。好消息是95%的脏函数都出现在极少数的1%的模块中,坏消息是有大量的模块中都包含有极少量的脏函数。例如,有200个模块中只有1个脏函数,有156个模块中包含2个脏函数,等等。
这些数据中有一点很有意思,就是我们并没有系统性地为达到代码的“干净程度”而付出努力。因此这种编程的“原始风貌”正好迎合了一种编程风格,即少数模块包含大量的副作用,而大量模块包含极少的副作用。
脏函数的分布
SODBS编程规范也积极支持这种编程风格,意图就是要让更有经验的程序员编写和测试那些包含副作用的代码。基于对SODBS的代码的观察,我们有一个主意,那就是明确地定义哪些模块是允许包含副作用的,作为一种质量控制的强制要求。
如果我们再深入一点看看对会引入副作用的原语的使用的次数,我们会发现如下一个顺序:
put(1743),apply(1532),send(1345),receive(634),erase(235),
process_flag(262),spawn(304),unlink(132),register(172),
spawn_link(134),link(121),unregister(27),open_port(16),
demonitor(13),processes(11),yield(10),halt(8),registered(9),
spawn_opt(5),port_command(4),trace(3),cancel_timer(3),
monitor_node(2).
用得最广泛的原语就是put,一共被使用了1743次。
从这个使用统计数据我们可以看出,我们的SODBS原语“黑名单”上的许多原语从来就没有被用到过。使用得最广泛的会带来有副作用的原语就是put——而是否真的会产生副作用则取决于对它的使用方法。其中一种广泛的用法就是用put来断言一个用于调试目的的进程的一个全局特性,而这种用法基本上是安全的,虽然没有自动化的分析程序能够证明这一事实。
真正有危险的副作用是那些改变应用程序的并发结构的原语,因此那些使用了link、unlink、spawn、spawn_link等原语的模块要仔细地检查一下。
更危险的代码是调用了halt或processes原语的代码——我确信这样的代码一定被非常小心地检查过了。
8.3.1系统结构
SODBS的代码是用SODBS监督树来组织的,因此可以根据这些树的形状推断出SODBS的整个代码组织结构为一棵大的监督树。这棵监督树的内部节点本身是一些监督者节点,而叶子节点则都是SODBS的behaviour的实例或专门的应用相关的进程。
SODBS系统的监督树有141个节点,使用了SODBS的behaviour的191个实例。每种behaviour的实例的个数如下:
sodbs_server(162),sodbs_event(56),supervisor(30),sodbs_fsm(17),
application(8).
可见sodbs_server的使用最多,一共有162个专用服务器的实例,而sodbs_event的实例个数居于其次。有意思的一点是,实际上所需要的共同行为是相当少的。
客户-服务器抽象(即sodbs_server)是如此有用,以至于整个系统中的一般对象有63%是客户-服务器行为的实例。
在SODBS库集中,一个监督者通过调用它所监督的进程的所谓child_spec(子进程说明)信息中的一个函数来开启一个应用。“子进程说明”(child specification)中包含有许多信息,其中{Mod,Func,Args}元组就使用来标明如何开启一个被监督的进程的。开启一个被监督进程的方法是完全专用的,因为监督者可以根据任意一个函数开启一个被监督者。在SODBS的案例中,这种方法的专用性并没有完全体现出来,而是在所有的监督层次结构中只用了三种开启被监督进程的方法中的一种。在这三种方法中,有一种完全占据统治地位,应用到了除3棵监督树中的其他所有监督树中。
SODBS的架构师定义了一个主监督者,它可以用许多标准化的方式进行参数化。SODBS的所有监督者也被打包成为一个常见的SODBS应用,它们的行为在一个所谓的.app文件中进行描述。分析一下SODBS的所有app文件,可以给我们一个关于SODBS的软件静态结构的一个很好的总体认识。
SODBS的软件一共有172个.app文件。这172个文件展示了11棵独立的监督树。这些监督树中的大多数是非常平的,并没有很复杂的结构。
展示这种结构的一个简单的方法就是用简单的ASCII码显示来画出该结构的树型图。例如,这里就是上面所说的11棵顶层树中的处理“Standby”业务的一棵树的树型图。
|--chStandby Standby top application
||--stbAts Standby parts of ATS Subsystem
||--aini_sb Protocol termination of AINI,...
|||--cc_sb Call Control.Standby role
|||--iisp_sb Protocol termination of IISP...
|||--mdisp_sb Message Dispatcher,standby role
|||--pch_sb Permanent Connect ion Handl ing...
|||--pnni_sb Protocol termination of PNNI...
|||--reh_sb Standby application for REH
|||--saal_sb SAAL,standby application.
|||--sbm_sb Standby Manager,start standby role
|||--spvc_sb Soft Permanent Connection...
|||--uni_sb Protocol termination of UNI,...
||--stbSws Standby applications-SWS
|||--cecpSb Circuit Emulation for SODBS,...
|||--cnh_sb Connection Handling on standby node
|||--tecSb Tone&Echo for SODBS,SWS,...
正如我们看到的一样,这棵树的结构是相当简单的,平而浅。树中只有2个一级子节点,而它们下面的监督者的结构完全是扁平的。
需要注意的是,这样显示的数据只是显示了监督者节点的组织。作为树中叶子节点的子节点的实际的进程并没有显示出来,监督类型(即“与”型监督还是“或”型监督)也没有显示出来。
为什么把树组织得扁平而不是多层?其原因反映了从实践中获取的一条经验,简单来说,就是“多层的(cascading)重启经常失效”。
SODBS的首席软件架构师关羽发现,用同样的参数去重启一个出错的进程经常是可以的,但是当这个简单的重启过程失败后,多层的重启(即重启它的上一层)却往往不奏效。
很有意思,我们观察到了大多数的硬件故障都是瞬时的,可以通过重新初始化硬件的状态然后重试操作来纠正。我们于是猜测这一点对于软件来说也是一样:
我猜想软件中也存在类似的现象——很多产品故障都是很微妙的。如果程序状态被重新初始化一下,然后重试先前失败的操作,这个操作在第二次就往往不再失败了。
此文档中提出的出错处理模型的一般化方法——即在发生故障时采用一个尽量简单的策略——只是被部分采用了。之所以是部分采用,与其说是有意设计,不如说是偶然凑成——SODBS库集本身提供的与文件系统的接口和以及与socket这样系统级服务的接口在编写之初就十分注意当发生故障的时候要保护系统的完整性。所以,如果一个文件或socket的控制进程因任何原因而终止时,这个文件或socket就会被自动关闭掉。
由SODBS库服务提供的保护级别将自动提供“更简单的服务级别”(simpler level ofservice),这也正是我们的容错处理模型的用意。
8.4讨论
在分布式系统中,甚至在我们的谈论中,关于故障的理解是需要修正的。我们再谈论整个系统的故障已经没有什么意义了(因为这种事情极少发生了)——我们应该谈论的是对于服务质量降低的衡量。
我们的案例研究中的软件系统是如此可靠,以至于操作这些系统的人倾向于认为这些系统没有错误。但事实不是这样的,软件错误在运行时确实发生过,但是这些错误很快就被纠正过来了,所以没有人曾经注意到错误的发生。为了得到关于长时间稳定性的准确的统计数据,我们就必须记录下系统启动和停止的次数,以此来作为衡量系统的“健康度”的参数。如果在系统级没有收集到任何这样的统计数据,就说明系统的表现是完全“健康”。
绝大部分代码都是干净的,但是脏代码的分布不是一个真正的“阶梯”函数(stepfunction)(也就是没有清晰的分界线来区分“这部分代码不好,要小心对待”以及“这部分代码是好的”)而是一个发散的分布,
即有少数的模块有许多副作用(我们不担心这一部分),而更让人担忧的则是那些数量众多的仅含有一两个带副作用原语的模块。
由于对于代码没有一个更深入的理解,我在这里也无法断定这就是问题的根源,也不能说这些具有潜在副作用的调用给系统带来了问题,或者这些调用是有害的。
无论如何,我想表达的东西是清楚的。光靠编程规范并不足以让我们的系统都以一种特别的方式来编写。如果有人希望把代码强制性地分为干净的代码和肮脏的代码,那么就一定要辅以工具的支持,还要有一些强制执行的政策的支持。
是否真的要这么做是有争议的——也有可能最好的方式是允许程序员们违反编程规范,只是希望他们在违反编程规范的时候知道正在做什么。
9API与协议
在我们编写一个软件模块的时候,我们需要描述如何使用它。有一种做法就是为模块所有的导出函数定义一套编程语言API。为了做到这一点,我们可以用3.9节提到过的类型系统。
定义API的方法其实是很普遍的。不同的语言之间,类型符号的细节有所不同,不同的系统之间,底层的语言实现对于类型系统的要求的强制性程度也不一样。如果对类型系统有严格的强制要求,那么这种语言就被称为是“强类型的”(strongly typed),否则它就被称为“弱类型的”(untyped)——这一点经常会引起混淆,因为许多要求进行类型声明的语言它的类型系统是很容易被违反的。
SODBS不要求类型声明,但是是“类型安全”(type safe)的,意思是不能以一种会破坏系统的方式违反底层类型系统。
即使我们的语言不是强类型的,但是类型声明可以作为一种有价值的文档,而且可以作为一个动态类型检查器的输入,动态类型检查器能够用来进行运行时类型检查。
不幸的是,只按照惯常的方式写出API的对于理解程序的行为是不够的。
例如,看下面的代码片断:
silly()->
{ok,H}=file:open(″foo.dat″,read),
file:close(H),
file:read_line(H).
按照类型系统的要求和3.9节的例子中给出的API,这段程序是完全合法的。
但是它明显是完全没有意义的,因为我们不可能期望从一个已经关闭了的文件中读取东西。
为了改正上面的问题,我们可以添加一个额外的状态参数。辅以一种相当明了的符号,关于文件操作的API可以这样写:
+type start x file:open(fileName(),read|write)->
{ok,fileHandle()}x ready
|{error,string()}x stop.
+type ready x file:read_line(fileHandle())->
{ok,string()}x ready
|eof x atEof.
+type atEof|ready x file:close(fileHandle())->
true x stop.
+type atEof|ready x fi le:rewind(fi leHandle())->
true x ready
这种API模型用了四种状态变量:start,ready,atEof和stop。状态start表示文件还没有被打开。状态ready表示文件已经准备好被读取,atEof表示到了文件的结尾。文件操作总是以start状态开始,而以stop状态终止。
现在API就可以这么解释了,例如,当文件处于状态ready是,进行file:read_line的函数操作是合法的。它要么返回一个字符串,这时候它仍然处于ready状态;或者它返回eof,此时它处于atEof状态。
在atEof状态的时候,我们可以关闭文件或回倒(rewind)文件,所有其他的操作都是非法的。如果我们选择回倒文件,那么文件将重新回到ready状态,这时候read_line操作就又变得合法了。
为API增加了状态信息,就为我们提供了一种判定一系列操作是否与模块的的设计相吻合的方法。
9.1协议
可见我们可以标定一套API的使用顺序,其实同样的思想也可以应用到协议的定义上。
假设有两个部件使用纯消息传递的方式进行通信,我们要能够在某一个抽象层次说明一下这两个部件之间流动的消息的协议。
两个部件A和B之间的协议P可以用一个非确定的有限状态机(non-deterministic finite state machine)来描述。
假设进程B是一个文件服务器,而A是一个要使用这个文件服务器的客户
程序,进一步假设会话是面向连接的。那么文件服务器应当遵循的协议可以按如下方式来说明:
+state start x{open,fileName(),read|write}->
{ok,fileHandle()}x ready
|{error,string()}x stop.
+state ready x{read_line,fileHandle()}->
{ok,string()}x ready
|eof x atEof.
+state ready|atEof x{close,fileHandle()}->
true x stop.
+state ready|atEof x{rewind,fileHandle())->
true x ready
这个协议描述的意思是,如果文件服务器处于start状态,那么它就可以接收{open,filename(),read|write}这种类型的消息,文件服务器的响应要么是返回一个{ok,fileHandle()}类型的消息,并迁移到ready状态,要么是返回一个{error,string()}的消息,并迁移到stop状态。
如果一个协议用类似上面的方式来描述,那么就可能写一个简单的“协议检查”程序,置于进行协议通信的两个进程中间。图5就展示了在进程X和Y之间放一个协议检查器C的情形。
图5:两个进程和一个协议检查器当X向Y发送一个消息Q(Q是一个询问)时,Y会以一个响应R和一个新状态S作为回应。值对{R,S}就可以用协议描述中的规则进行类型检查了。协议检查器C位于X和Y之间,根据协议描述对X和Y之间来往的所有消息进行检查。
为了检查协议规则,检查器就需要访问服务器的状态,这是因为协议描述可能还有如下的条目:
+state Sn x T1->T2x S2|T2x S3
在这种情况下,只观察返回消息T2的类型并不足以区分服务器的下一个状态是S2还是S3。
如果我们回忆一下前述的简单的专用服务器的例子,我们程序的主循环就可以是这样的:
loop(State,Fun)->
receive
{ReplyTo,ReplyAs,Q}->
{Reply,State1}=Fun(State,Q),
Reply!{ReplyAs,Reply},
loop(State1,Fun)
end.
这个主循环又可以很容易地改成:
loop(State,S,Fun)->
receive
{ReplyTo,ReplyAs,Q}->
{Reply,State1,S1}=Fun(State,S,Q),
Reply!{ReplyAs,S1,Reply},
loop(State1,S1,Fun)
end.
这里S和S1代表协议描述中的状态变量。注意接口(即协议描述中使用的状态变量的值)的状态与服务器的状态State是不同的。
进行了上面的修改后,专用服务器就彻底变成了一种允许安装在客户和服务器之间的动态协议检查器了。
9.2API还是协议?
前面我们展示了如何用两种本质上相同的方式来做同样的事情。我们可以在我们的编程语言上强加一个类型系统,或者我们可以在用消息传递方式通信的两个部件之间强加一个契约检查机制。这两个方法中,我更喜欢契约检查器这种方法。
第一个方面的原因跟我们系统的组织方式有关系。在我们的编程模型中,我们采用了独立部件和纯消息传递的方式。每个部件被当作是“黑盒子”,从黑盒子的外面,完全看不到里面的计算是怎么进行的。唯一重要的事情就是黑盒子的行为是否遵循他的协议描述。
在黑盒子的内部,可能因为效率或其他编程方面的原因,我们需要使用一些晦涩的编程方法,甚至违背所有的常识规则和好的编程实践。但是只要系统的外部行为遵守了协议描述,就没有丝毫关系。
只要简单的扩展,协议说明就可以扩展成系统的非功能属性。例如,我们可以向我们的协议描述语言中添加一个时间的概念,那么我们就可以这样来表达:
+type Si x{operationl,Argl}->
valuel()x Sj within T1
|value2()x Sk after T2
意思是operationl应该在T1时间内返回一个valuel()类型的数据结构,或在T2时间后返回一个value2()类型的数据结构。
第二个方面的原因跟我们所做的工作在系统中的位置有关。在一个部件的外面放置一个契约检查器决不干涉到该部件本身的构造,并且还给我们的系统增加或删除各种自我测试手段提供了一种灵活的途径。使得我们的系统可以进行运行时检查,并以能以更多的方式进行配置。
9.3交互部件系统
SODBS系统如何与外界通信呢?——当我们想要构建一个分布式系统,而SODBS只是许多交互部件中的一个时,这个问题就变得很有意思了。我们看到:任何PLITS系统都是建立在模块(module)和消息(message)这两种基本构件之上的。模块是一种自包含(self-contained)的实体,就如同Simula或Smalltalk中的类、SAIL进程、CLU模块一样。模块本身用什么编程语言来编码并不重要,我们希望做到不同的机器上的不同模块完全可以用不同的语言来编写。
为了做一个交互部件系统,我们必须使得不同部件在许多方面达成一致,我们需要:
一种传输格式,和一种从语言实体到传输格式的映射方法。
一套类型系统,它建立在传输格式的实体之上。
一种基于类型系统的议描述语言。
关于这种传输格式,我们采用了一种叫做UBF(Universal Binary Format的缩写)的方法,这种方法方法是为快速解析而设计的。
9.4讨论
我还是想回归此文档的核心问题——我们如何在出现错误的时候构建一个可靠的系统?跳出这个系统,把它看作是一组相互通信的黑盒子,是一种有益的思路。
如果我们可以形式化地描述两个黑盒子之间通信通道所遵循的协议,那么我们就可以利用这一点,作为一种检测和识别错误的手段。我们还可以准确地说出哪个部件出了错。
这种架构恰好满足2.5节的需求R1-R4,因此按照我们的推理,这种架构是可以用在编写可容错系统上的。
这一点与第5.1节的“试图做较容易做到的事”的说法也是相符的。如果一个黑盒子函数的实现发生故障,那么我们可以切换到黑盒子函数的另一个较简单的实现。那么协议检查机制就可以用在元层(meta-level)来决定该使用哪一个实现,一旦发生错误,就选择一个较简单的实现。当部件处于物理上独立的不同机器上时,做到强隔离也就是自然的要求了。
10总结
此文档中所描述的工作,以及开发人员做的相关工作,已经证明了许多不同寻常的东西,即:SODBS及其相关技术是有效的,这是很有启迪意义的一个结果。曾有人认为,像SODBS这样的系统是不可能全面胜任企业级金融应用的。然而在北京资和信担保有限公司诸多产品上成功地展示了SODBS对于这类产品来说是一种很合适的系统。不但这些产品成功了,而且在它们各自的激烈竞争的市场中成为领头羊,这一事实非常有意义。
应用改进型Beowulf式(无固定的控制节点)服务器集群配合强隔离、轻量化进程和无共享内存的系统在实践中是确实可行的,这种系统自我检测、自我修复能力强,而且可以用来设计复杂的大容量企业级金融系统。构建在出现软件和硬件错误的时候作出合理的行为的系统目标已经达成了。