算法模型
并发执行算法的模型与顺序执行算法不一样。在设计顺序执行算法时,我们的出发点是如何减少执行的步数(时间开销)和内存的占用空间(空间开销),我们会很习惯地思考第一步做什么、第二步做什么。
但在面对并发执行算法时,其执行轨迹空间与进程数、步数的阶乘成正比,这种第一步做什么、第二步做什么的思维就会面临巨大的困难。
为分布式算法编写过代码的读者一定有这样的经历:在编写每行代码时,都要考虑有哪些进程的哪行代码可能会与本行代码并发执行?如果并发执行会造成什么后果?等等。
看似简单的十几行代码可能会耗费我们一整天的时间去思考它的正确性,就是因为这寥寥数行代码对应的执行轨迹空间太大了!若要保证每个执行轨迹都是正确的,需要大量的脑力。常见的情况是,即使我们对着十几行代码盯了一整天,可代码运行一阵子后还是出错了,于是我们只能继续修改。最无奈的情况是,我们即使绞尽脑汁,也只能让错误出现得更晚一点。所以,我们必须换一个思路去设计分布式算法。
I/O自动机
1987年,Nancy A.Lynch和Mark R.Tuttle在他们的论文中首次提出输入/输出自动机(Input/Output Automaton,I/O自动机)模型,目前它已经被广泛用在各种异步并行系统的建模中。作为一种异步并行系统,分布式系统也可以通过I/O自动机建模。
基本模型
我们可以把一个进程看成一个自动机,把一个分布式系统看成一个自动机集群,其中,每个自动机都执行相同的算法。因此,设计一个分布式算法,其实就是设计一套让所有自动机都遵照执行的算法。
顺序执行算法研究的是第一步做什么、第二步做什么,一连串动作下来,问题得以解决;而分布式算法研究的是在什么情况下应该执行什么动作。这里的“情况”就是事件,例如收到某个消息可以看作一个事件,自动机处于某个状态也可以看作一个事件;“动作”就是改变自动机的状态。设计分布式算法的目标,就是让系统中的自动机集群达到我们想要的状态。
接下来,我们依次介绍状态和事件。
每个自动机有自己的内存空间,可以维持自己的状态。所谓状态,可以理解为自动机内部用于表示某些状态的变量值。例如,有一个选主自动机,它用一个内部变量leader表示当前主进程,那么变量leader的值就是这个选主自动机的状态。我们希望看到的是,选主自动机集群中的每个自动机的变量leader都指向同一个正常工作的进程,这也是我们设计分布式选主算法的目标。一个自动机可以有无限多个状态,这样可以用来描述一些无容量上限的数据结构,例如计数器或不限长度的队列等。
自动机的行为是由事件驱动的。所谓事件,就是需要自动机执行某些动作的那些“情况”。我们可以为自动机a定义一个签名(Signature),记为acts(
a)。签名实际上是一个事件集合,自动机a的签名就是自动机a可能接收和发送的全体事件集合。
为了更好地理解签名,我们用C语言作类比。在C语言中,函数的签名是指这个函数的输入参数和返回值的类型,它描述了函数与外界交互的数据的类型。
也可以这么理解:函数的签名就是该函数能够接收的所有输入参数和产生的所有返回值的全体集合。同理,自动机的签名描述了该自动机可以接受的事件的类型,即符合该类型的全体事件集合。
如果一个事件的类型不属于某个自动机的签名,那么该自动机会忽略该事件。若无特殊说明,下面提到的事件都是签名中的事件。
自动机a的签名可以分为输入事件、输出事件和内部事件这三个两两不相交的三个事件子集,分别记为in(a)、out(a)和int(a),且acts(a)=in(a)∪out(a)∪int(a)。
输入事件和输出事件可用于自动机与外部环境的通信,所以被统称为外部事件或者接口事件,这个外部环境被称为运行时环境。内部事件是自动机自己触发、自己处理的事件,仅对自动机自身可见。输出事件和内部事件是自动机触发的,受自动机的控制,所以被统称为本地事件。输入事件的触发不受自动机的控制,自动机随时都有可能收到输入事件。
例如,自动机从运行时环境收到消息是输入事件,自动机发送一个消息给运行时环境是输出事件,自动机的内部状态满足某个条件而触发的事件是内部事件。
事件可以驱动自动机状态的变化,这个变化被称为状态转移(State Transition)。一个自动机可以有一个或多个初始状态,这是自动机进行状态转移的起点。如果自动机通过事件e从状态s转移到状态s1,就可以表示为“s-e-s1”,并且说“事件e在状态s下是激活(enable)的”。由于自动机在任何时候都有可能收到输入事件,因此输入事件在任何状态下都是激活的。
一次状态转移被称为一步(Step)。步是不可再分的,一个自动机必须执行完这一步,才能开始执行下一步,不能同时执行两个步。这一点非常重要,后续所有的算法都是基于这一点而设计的。
图 2-1 是一个双向通信链路的自动机,它的状态由内部的发送队列和接收队列组成。在初始状态下,两个队列均为空。它的签名由两个事件组成,其中一个是输入事件<Send|m>,当自动机收到该输入事件时,会把消息m放入发送队列尾部,使发送队列的长度从0变成1,实现状态转移。当接收队列不为空时,自动机会把接收队列头部的消息取出,并触发一个输出事件<Receive|m>,表示它收到了一个消息 m。自动机随时可能收到输入事件<Send|m>,所以它永远都是激活的。至于消息m是如何从一个进程的发送队列进入另一个进程的接收队列的,这取决于通信链路的实现方案。
添加图片注释,不超过 140 字(可选)
图 2-1 双向通信链路的自动机
组合模型
在设计顺序执行算法时,往往会用模块化的思想实现代码复用。例如,在 C 语言中,我们可以在函数func1 的实现中调用函数func2,从而避免在函数func1 中重写函数func2的逻辑。同理,在I/O自动机模型中,我们也可以让自动机a“调用”自动机b,从而避免在自动机a中重写自动机b的逻辑。
只不过在函数调用中,函数func1必须等待函数func2返回,才能执行后续逻辑;而在自动机的调用中,自动机a在调用了自动机b的输入事件后无须等待,即可继续执行后续逻辑。
通过对一些基础的自动机进行组合,可以构成更大、更复杂的自动机。
在组合的过程中,这些基础的自动机被称为部件自动机(Component Automaton),所构成的自动机被称为合成自动机(Composed Automaton)。
令集合I表示系统中所有自动机的编号的全集,编号为i的部件自动机用ai表示,合成自动机用a表示。若要将多个部件自动机组合为一个合成自动机,则对于任何两个不同的部件自动机ai和aj,必须满足如下条件。
(1)int(ai)∩acts(aj)=∅,即部件自动机的内部事件仅作用于该部件自动机内部。
(2)out(ai)∩out(aj)=∅,即不同部件自动机的输出事件是不同的。
上述条件被称为兼容条件。将满足兼容条件的部件自动机组成合成自动机,规则如下。
(1)合成自动机的状态是各个部件自动机的状态的集合。
(2)每个部件自动机的输出事件都是合成自动机的输出事件,即out(a)=∪{out(ai), i∈I}。
(3)每个部件自动机的内部事件都是合成自动机的内部事件,即int(a)=∪{int(ai), i∈I}。
(4)部件自动机的输入事件若不是其他部件自动机的输出事件,那就是合成自动机的输入事件,即in(a)=∪{in(ai), i∈I}-out(a)。
(5)共享同一个事件的所有部件自动机都同时进行状态转移,而其他部件自动机不进行状态转移。
例如,部件自动机a、b、c、d的状态分别为sa、sb、sc、sd,事件e既是自动机a的输出事件,又是自动机b和c的输入事件,但不是自动机d的任何事件。那么当事件e发生时,自动机a、b、c同时进行状态转移,而自动机d不发生状态转移。对于合成自动机而言,它的状态转移可表示为“(sa,sb,sc,sd)-e-(sa',sb',sc',sd)”(注意:sd未发生变化),且只能算一步,而不是多步。
同时,根据条件1和规则3可知,该合成自动机的内部事件必然只属于某一个部件自动机,因此当合成自动机因内部事件进行状态转移时,只有其中一个部件自动机会发生状态转移。
隐藏操作
模块化的好处是不仅可以提升代码的复用率,还可以隐藏一部分内部细节。例如在C语言编程中,对那些无须外部可见的函数,可以加上static关键字,使之变成一个静态函数,这就是一种隐藏实现细节的做法。
在 I/O 自动机模型中,为了让合成自动机在后续进一步合成时减少暴露内部事件,可以对合成自动机采取隐藏操作(Hiding Operation)。将这部分需要被隐藏的接口事件记为集合 Φ,那么隐藏操作后所得到的自动机 A′的签名与隐藏操作前的合成自动机A的关系如下。
in(A')=in(A)out(A')=out(A)\ Φ
int(A')=int(A) ∪ Φ
也就是说,隐藏操作并不改变合成自动机的输入事件,只是把一部分需要隐藏的接口事件转成了合成自动机的内部事件。
图 2-2所示为两个进程x和y通过加密链路进行单向通信的合成自动机。
进程x有一个合成自动机p,它由加密自动机a和发送链路自动机c组成;进程y有一个合成自动机q,它由解密自动机b和接收链路自动机d组成。
添加图片注释,不超过 140 字(可选)
图2-2 通过加密链路进行单向通信的合成自动机
加密自动机a在收到输入事件<Read|m>后,对消息m进行加密,生成消息m',并将m'放入加密缓存。
当加密缓存不为空时,自动机 a 从加密缓存中取出消息 m',然后调用发送链路自动机c的输入事件<Send|m'>,完成状态转移。自动机c收到输入事件<Send|m'>后,将消息 m'放入发送队列,完成状态转移。对于合成自动机 p 而言,这两个状态转移在一步内完成。
当接收链路自动机d的接收队列不为空时,自动机d从接收队列中取出消息m',然后触发输出事件<Receive|m'>,完成状态转移。当解密自动机b收到自动机d发出的输出事件<Receive|m'>后,将消息m'解密为消息m,然后放入解密缓存,并触发输出事件<Write|m>,完成状态转移。对于合成自动机q 而言,这两个状态转移在一步内完成。这样,进程x和y就完成了单向的加密通信。至于消息m'是如何从自动机c的发送队列进入自动机d的接收队列的,这取决于通信链路的实现方案。
组合模型不仅使合成自动机p和q分别复用了发送链路自动机c和接收链路自动机d,同时还分别隐藏了<Send|m'>和<Receive|m'>事件,使自动机p对外仅有一个输入事件<Read|m>,自动机q对外仅有一个输出事件<Write|m>。
与业务逻辑的关系
在真实的应用中,业务逻辑和分布式逻辑是共存的,但本书中的I/O自动机模型仅针对分布式逻辑,不针对业务逻辑。也就是说,业务逻辑使用什么模型,与分布式逻辑无关。下面举例说明。
假如有一个分布式在线购物应用,该应用部署在n台服务器上,服务器之间没有任何共享数据,仅通过网络相互通信。该应用的业务逻辑中的一个环节是把商品放到购物车中暂存,在一段时间内,消费者(客户端)可以通过任何一台服务器访问购物车,且能够看到完全相同的商品列表。而过了一段时间后,该消费者的购物车就被清空了。
业务逻辑与分布式逻辑分层示意图如图 2-3所示。从纵向来看,该购物应用由n个进程组成,它们之间没有共享数据,通过网络(图中未画)进行通信;从横向来看,该购物应用由业务逻辑(虚线部分)和分布式逻辑(实线部分)组成。在业务逻辑中有一个购物车模块,它以异步的方式与分布式逻辑中的缓存自动机进行交互。在分布式逻辑中,缓存自动机与广播自动机、链路自动机之间也以异步的方式进行交互。本书所描述的算法仅针对分布式逻辑,不涉及任何业务逻辑。
需要特别指出的是,有些人认为,为了提升性能,采取紧耦合的算法比模块化的算法要好。对于设计分布式算法来说,这种观点是非常危险的。如1.3.1节所述,一个分布式系统可能的执行轨迹的数量是O(n!),其中n与进程的步数成线性正比,因此每增加一个步骤,就会导致执行轨迹的数量大幅增加,进而导致编写正确代码的难度大幅增加。
一些软件开发人员为了提升性能,把原本可以分开的分布式逻辑揉在一起,甚至把业务逻辑与分布式逻辑也揉在一起,最后发现可能的执行轨迹太多了,几乎无法保证代码实现的正确性。因此,分布式算法比顺序执行算法更需要模块化的思想,要尽可能地让每个模块的任务简单,才能更容易编写正确的代码。
添加图片注释,不超过 140 字(可选)
图2-3 业务逻辑与分布式逻辑分层示意图
小结
设计分布式算法就是在设计 I/O 自动机的签名及其内部实现状态和状态转移逻辑,使各个自动机相互协同。例如,对于一个分布式选主算法,每个进程都有一个用来表示主进程的状态变量leader,该算法的目标是让每个进程的leader变量都指向同一个活着的进程。
尽管一个允许多进程并发运行的分布式系统十分复杂,但通过I/O自动机模型,我们仍然有机会像设计顺序执行算法那样,对并发执行的分布式算法进行模块化,抽象出部件自动机,并通过组合实现更复杂的合成自动机,实现丰富的功能。
编程模型
在了解了I/O自动机和分布式系统模型之后,为了将I/O自动机模型变成算法,我们还需要定义一套描述I/O自动机模型的语言,这就是本节所要介绍的编程模型。
我们仍然用C语言来对比。在C语言中,一个函数可以分为定义、实现和执行三个部分。对于函数的定义,我们只关心非静态函数的签名和功能,因为静态函数对外是不可见的。在函数的实现中,我们才考虑算法逻辑。
同理,我们把自动机的定义称为抽象(Abstraction)。例如,一个链路自动机被称为链路抽象,一个广播自动机被称为广播抽象,等等。对于抽象,我们只关心它的接口事件(内部事件对外不可见)和功能,而不关心它的内部事件或实现细节。
自动机的实现方法被称为算法,它实现了抽象。一个抽象可能有多种不同的实现算法。与顺序执行算法类似,不同的算法有不同的性能特征,例如,有的算法的时间复杂度高,有的算法的空间复杂度高。但与顺序执行算法不同的是,同一个抽象的不同实现算法可能有不同的适用条件,例如进程失败模型、时间假设及韧性等,只有满足这些条件,算法才是正确的。这也是顺序执行算法和分布式算法的一个不同之处。
正在运行的自动机被称为实例(Instance)。例如,一个正在运行的链路自动机被称为链路实例,一个正在运行的广播自动机被称为广播实例,等等。
实例可以被运行时环境创建或销毁,也可以自己销毁自己,但不能自己创建自己。一个分布式系统由多个进程组成,每个进程pi都运行了自动机A的实例ai,这些实例的集合{ai|i∈I}被称为自动机A的实例组(Instance Group)。
在不产生歧义且不需要强调的情况下,我们有时会省略“抽象”“实现”“算法”“实例”“实例组”这些特指的表达方式,而直接用自动机的名字。例如广播抽象、广播实现、广播算法、广播实例、广播实例组等,可直接用“广播”两字简化。
同理,在不产生歧义且不需要强调的情况下,我们有时也会把“进程的某某实例如何如何”简述为“进程如何如何”。例如,“进程p内的链路实例a发送了消息m”可被简述为“进程p发送了消息m”,“进程p内的链路实例a接收了消息m”可被简述为“进程p接收了消息m”。
调用关系
在描述顺序执行算法时,函数间通过参数和返回值进行通信。但实际上,参数和返回值是人为分类的,两个函数完全可以利用一个共享变量进行通信。当把 C 语言编译成汇编语言后,汇编语言中的两个过程其实就是靠共享的寄存器或内存进行通信的。区分参数和返回值的好处是有利于层次化,更清晰地区分谁是请求者、谁是实现者,因此被广泛使用。
同理,作为分布式算法中实例间通信的接口,事件本不需要分类,但为了更好地将实例层次化,更清晰地区分谁是请求者、谁是实现者,我们可以把输入事件看成参数,把输出事件看成返回值。
实例间的调用关系如图 2-4所示,对于实例a而言,实例a请求实例b的过程被称为调用(Invoke),即“实例a调用了实例b的一个输入事件”;对于实例b而言,实例a请求实例b的过程被称为受理(Accept),即“实例b受理了一个输入事件”。接下来,实例b继续调用实例c的一个输入事件。
当实例 c 处理完输入事件后,将“宣告(Declare)一个输出事件”;因为实例 c的输出事件属于实例b的签名,所以“实例b将感知(Perceive)实例c的输出事件”。接下来,实例b将继续宣告一个输出事件,因为实例b的输出事件属于实例a的签名,所以实例a将感知实例b的输出事件。
一个实例调用另一个实例的输入事件,或宣告本实例的输出事件,统称为触发(Trigger)事件;一个实例受理本实例的输入事件,或感知另一个实例的输出事件,统称为收获(Obtain)事件。我们假设在同一个进程内部,触发一个事件和收获这个事件是同时发生的。这意味着,实例a调用实例b的输入事件e1,与实例b受理了输入事件e1,两者是同时发生的;实例c宣告一个输出事件e2,与实例b感知输出事件e2,两者也是同时发生的。
如果把实例b和实例c看成一个合成自动机实例,那么实例b和实例c之间的输入和输出事件就变成合成自动机的内部事件了。
添加图片注释,不超过 140 字(可选)
图2-4 实例间的调用关系示意图
注意,当实例 a 调用实例 b 的输入事件时,我们会明确说“……调用了实例 b的……”;而当实例b受理了这个输入事件时,我们不会说“……受理了实例a调用的……”。这是因为,实例 b 不是从实例 a,而是从运行时环境中受理了一个输入事件。同理,当实例b宣告一个输出事件时,我们不会说“……向实例a宣告了……”,这是因为实例b不是向实例a,而是运行时环境宣告了这个输出事件。实际上,实例b并不知道哪个实例会感知到这个输出事件,因为可以有多个自动机实例感知到这个输出事件,只要这些自动机的签名包含了这个输出事件。总之,只有调用(或感知)方关心它调用(或感知)的是谁,而受理(或宣告)方不关心谁在调用(或感知),只是“埋头苦干”地处理事件。
在约定了上述调用关系后,合成自动机内部的层次关系就变得更加明确和清晰了,这有助于我们在简单的部件自动机的基础上构建功能更强大、更复杂的合成自动机,实现更复杂的分布式算法。同时,开发者可以继续以顺序执行的思维模式开发上层应用:当需要调用底层分布式算法时,首先调用底层实例的输入事件,然后等待底层实例宣告一个输出事件。在此过程中,上层应用处于阻塞状态。这样就把一个异步过程转化成了同步过程,大大降低了上层应用的开发难度。
事件和事件处理器
事件由类型和属性组成,我们将其记为<Event|Attr,…>。
不同的自动机抽象可能会使用相同的事件类型来定义事件,例如链路抽象和广播抽象都定义了输入事件<Send|m>,但显然两者的语义是不同的。此外,同一个自动机抽象可以对应多个不同的自动机实例。为了更清晰地指出这是哪个实例的哪个事件,我们把自动机实例a的事件记为<a,Event|Attr,…>。但是在不产生歧义且不需要强调的情况下,我们有时将其简写为<Event|attr,…>、<a,Event>或<Event>。
当需要调用实例a的输入事件<Event|…>时,可以用“invoke<a,Event|…>”这样的语言。当实例a需要宣告输出事件<Event|…>时,可以用“declare<a,Event|…>”这样的语言。当需要收获实例a的事件<Event|…>,则可以用“when event<a,Event|…>do”这样的语言,后面再接上事件处理过程,这个事件处理过程被称为事件处理器。
下面是某个自动机实例a的算法片段,在注释中有每行语句的语义。当实例a受理一个输入事件<a,Event>时,就调用实例a1的输入事件<a1,Event1>;当感知实例a1宣告的输出事件<a1,Event2>后,就宣告实例a的输出事件<a,Event3>。其中,输入事件<a1,Event1>和输出事件<a1,Event2>是整个合成自动机实例的内部事件,输入事件<a,Event>和输出事件<a,Event3>是整个合成自动机实例的接口事件。
通常,我们通过上下文就能判断这个事件到底是输入事件还是输出事件。为了简化表达,在不产生歧义且不需要强调的情况下,我们不会每次都说“输入事件<Event>”或者“输出事件<Event>”,而是直接说“事件<Event>”,甚至直接说“<Event>”。因为“<>”是事件的独有表示方式,因此一个完整的事件表达“输入事件<a,Event|attr…>”可以被简化为“<Event>”。
添加图片注释,不超过 140 字(可选)
添加图片注释,不超过 140 字(可选)
自动机实例在被运行时环境初始化之后,就开始等待签名中的事件。对于不属于签名中的事件,即使自动机实例所处的进程的底层收到了,例如进程从网络中收到了包含事件的报文,自动机实例也会忽略它。对于属于签名中的事件,实例将进行处理。
所谓处理事件,其实就是做两件事情:第一,进行状态转移,例如将变量leader从“未知”改为1,表示“将标识为1的进程选为主进程”;第二,触发相应的事件,例如宣告一个输出事件,并在该事件的属性中指名主进程的标识。
如果一个实例a的输出事件恰好是另一个实例b的输入事件,那么实例a在执行完事件处理器后,实例b会立即执行相应的事件处理器,且这两个事件的处理过程是不可分割的,对于整个合成自动机而言,这是其中的一步。
如果实例在处理某个事件的同时收获了新的事件,实例不会立即处理新事件,而是继续处理原事件,直到完成完整的一步。这一点非常重要,它确保了实例的状态不会被两个事件处理器并发地修改。当第一个事件处理完成之后,实例会依据最新的而非原来的状态继续处理新事件。
签名中的每个事件被选中的概率大于0,不会出现某个事件因系统性的阻塞而永远得不到处理的情况,这被称为公平调度。
若某个实例将要收获的事件是来自本进程的,那么该事件迟早会被该实例处理。如果某个实例收获了多个来自本进程的事件,那么这些事件的处理顺序与它们的触发顺序是相同的。
有时,事件处理器的执行并非因为收获了某个事件,而是因为实例的状态达到了某个条件。这种事件处理器由when开头,然后是条件。这种事件处理器的形式如下:
添加图片注释,不超过 140 字(可选)
有时,事件处理器的执行不能仅凭是否收获了某个事件,实例内部的状态还必须达到某个条件,两者缺一不可。这种事件处理器的形式如下:
添加图片注释,不超过 140 字(可选)
有时,such that后面的condition先被满足,而尚未收获事件,此时事件处理器不会被执行。有时,事件已经收获了,但是such that后面的condition尚未得到满足,此时事件将会被暂存在实例的存储空间中,等到such that后面的condition被满足,事件处理器才会执行。然而在真正实现时,一个实例能够暂存的事件数量是有限的。
如果无法暂存更多的新到来的事件,要么就拒绝感知新的事件,从而造成某种进程失败(3.4.2 节会提到,这属于遗漏式失败);要么就将缓存的事件写入持久化存储中,待condition被满足后再从本地存储中删除。本书假设的是后一种情形。
抽象和实现
抽象是自动机中的定义。抽象由一个名字(Name)来标识,并由一些特性(Properties)加以区分。所谓特性,是指实现这个抽象的实例必须满足的一些性质,例如,一个选主实例必须满足“最终有一个正确的进程被选为主进程”的性质。
抽象还定义了接口事件,即输入和(或)输出事件。上层应用可以通过接口事件与自动机实例进行通信。例如,一个选主抽象定义了一个输出事件<Leader|p>,当上层应用感知到选主实例宣告的输出事件<Leader|p>后,就知道进程p已经被选为主进程了。
抽象 2-1中定义了一个简单的Task处理器抽象,这个抽象的名字是TaskHandler。该抽象定义了输入事件<Submit|task>,上层应用可以调用该输入事件提交task。该抽象还定义了一个输出事件<Confirm|task>,当 TaskHandler 实例受理了输入事件<Submit|task>后,就会宣告输出事件<Confirm|task>,确认task已被提交。
该抽象定义了TH1响应保证特性,该特性要求每个提交的task都会被确认,但该特性并未保证task被确认时task已经被处理了。
添加图片注释,不超过 140 字(可选)
添加图片注释,不超过 140 字(可选)
算法2-1是抽象2-1的一个简单的同步实现。
“Instance:th implements TaskHandler”表示实例th实现了抽象TaskHandler。该算法比较简单。当实例th受理了输入事件<Submit|task>后,就开始处理task。当task处理完成后,实例th就宣告一个输出事件<Confirm|task>。在这个算法实现中,当实例th宣告输出事件<Confirm|task>时,task已经被处理完了,但上层应用并不应该做此假设,因为TaskHandler抽象的特性并没有做此承诺。
添加图片注释,不超过 140 字(可选)
算法 2-2实现了抽象 2-1定义的TaskHandler实例th。它使用了一个特殊的输入事件<Init>,这是一个初始化事件。初始化事件比较特殊,实例在被初始化时,它会被运行时环境自动调用,往往用于初始化实例内部的一些状态。在本算法中,集合buffer就是该实例的状态。当实例处理初始化事件时,将集合buffer设置为空集(用∅表示)。
此外,该异步实现还针对内部状态设置了事件处理器,即当集合buffer不是空集时,则从集合buffer中挑出一个task进行处理,处理完成后,将该task从buffer中去除。显然,这是一个异步的实现,因为当实例宣告输出事件<Confirm|task>时,仅仅表示确认这个task被提交了,并不表示这个task被处理了。
添加图片注释,不超过 140 字(可选)
添加图片注释,不超过 140 字(可选)
抽象2-2定义了一个有缓存上限的Task处理器抽象,名字叫BufferedTaskHandler。如果上层应用调用输入事件<Submit|task>的速度太快,那么BufferedTaskHandler的实例将宣告输出事件<Error|task>给上层应用,以示拒绝。
添加图片注释,不超过 140 字(可选)
算法2-3实现了抽象2-2定义的BufferedTaskHandler实例bth。“Uses:TaskHandler,instance th”表示实例bth需要使用(Use)一个TaskHandler实例,这个TaskHandler实例用变量th表示。
对于同一个抽象,在不同的假设下可以有不同的算法,因此每个算法都有其适用的假设。例如,“Assumes:Fail-Silent,f<N”表示该算法适用于静音型失败模型,且韧性f小于进程总数N。这也是算法未指明适用假设时默认适用的假设。至于失败模型、韧性的含义,会在第3章中介绍,读者可以暂时忽略这些看不懂的名词。
添加图片注释,不超过 140 字(可选)
添加图片注释,不超过 140 字(可选)
为了更好地展示模块化的设计思想,实例bth只负责检查集合buffer是否溢出,而把处理task的工作交给了底层的实例th,使整个算法层次更清晰、更容易理解。BufferedTaskHandler模块化示意图如图 2-5所示,当上层应用需要提交一个task时,就调用实例bth的输入事件<Submit|task>。实例bth会检查buffer是否溢出,如果是,则实例bth将宣告输出事件<Error|task>给上层应用,表示拒绝task;否则,先将task放入buffer,然后宣告输出事件<Confirm|task>。
添加图片注释,不超过 140 字(可选)
图2-5 BufferedTaskHandler模块化示意图
同时,当buffer不为空且handling为FALSE(表示没有task待处理)时,那么从buffer中取出一个task,然后调用实例th的输入事件<Submit|task>,让底层的实例th去处理task,并将handling设为TRUE,表示实例th正在处理某个task。当实例bth感知到实例th宣告的输出事件<Confirm|task>后,就知道实例th已经完成处理了,并将handling设为FALSE。
如果实例bth需要同时提交多个task给底层的TaskHandler实例,则可以相应地使用多个TaskHandler实例,并用更多的实例变量来表示,例如th1、th2等。
本文给大家讲解的内容是算法模型:I/O自动机、编程模型
-
下文给大家讲解的内容是系统模型
资源获取:
大家 点赞、收藏、关注、评论啦 、 查看👇🏻👇🏻👇🏻 微信公众号获取联系方式👇🏻👇🏻👇🏻
精彩专栏推荐订阅:在 下方专栏👇🏻👇🏻👇🏻👇🏻