分布式系统:远程调用
远程调用
在请求-应答协议中描述了一个基于消息传递的范型,该协议支持在客户/服务器的消息双向传输,此类协议为远程操作的执行请求提供了相关的底层支持。
远程过程调用(RPC)将传统的过程调用模型扩展到分布式系统,允许客户程序透明地调用在服务器程序中的过程。这些服务器程序运行在不同的进程中,通常位于不同于客户端的计算机中。
基于对象的编程模型被扩展以后,允许不同进程运行的对象通过远程方法调用(Remote Method Invocation, RMI)彼此通信。RMI 是对本地方法调用的扩展,它允许一个进程对象调用另外一个进程对象的方法。相比远程过程调用而言,RMI 的优势是把对象引用扩展到全局分布式环境中,因此可以把对象引用作为参数。
请求-应答协议
基于 UDP 数据报的实现
请求-应答协议用于支持典型客户/服务器交互中角色和信息的转换,通常情况下请求-应答通信是同步、可靠的,在来自服务器端的应答到达之前客户端进程是阻塞,同时从服务器端的应答是对客户端进程的一个有效的确认。尽管目前很多客户-服务器交换的实现采用的是 TCP 流的形式,但也可以通过在 UDP 数据报中的发送(send)和接收(receive)操作来描述。建立在数据报上的协议避免了像 TCP 流那样不必要的开销,主要原因有:
- 应答紧跟在请求之后,所以确认信息是多余的;
- 一个 TCP 连接的建立除了需要一对请求和应答之外,还涉及两对额外的消息;
- 对于大部分只有少数的参数和结果的调用来说,流控制是多余的。
通信原语
请求-应答协议将请求和应答进行匹配,用于提供传输保证,如果使用 UDP 数据报就必须通过请求-应答协议提供传输保证。它基于三个通信原语:doOperation、getRequest、sendReply,他们的工作流程如下图所示。
- 客户使用 doOperation 方法来调用远程操作,方法参数指定远程服务器、待调用的操作以及操作请求的附加信息,其结果是包含应答内容的字节数组;
- 在发送请求消息之后,doOperation 方法通过调用 receive 方法接收应答消息,并从应答信息中提取结果返回给调用者,调用者在服务器执行所请求的操作和传输应答信息给客户端进程之前是阻塞的;
- 服务器进程通过 getRequest 方法获得请求消息,当服务器调用了指定的操作时,它会通过 sendReply 方法向客户发送应答消息;
- 当客户端接收到应答消息时,原来的 doOpration 方法就会解除阻塞,客户端进程继续执行。
调用 doOperation 方法的客户将参数编码(marshal)进一个字节数组,并从返回的字符数组中解码(unmarshal)出结果。doOperation 方法的第一个参数是类 RemoteRef 的一个实例,该实例描述了远程服务器的一个引用,提供了获取相关服务器的互联网地址和端口号的方法。doOperation 方法向某个服务器发送请求信息,该服务器的网络 IP 地址、端口号在以参数形式出现的远程引用中指定。
// sends a request message to the remote server and returns the reply.
// The arguments specify the remote server, the operation to be invoked and the arguments of that operation.
public byte[] doOperation (RemoteRef s, int operationId, byte[] arguments);
// acquires a client request via the server port.
public byte[] getRequest();
// sends the reply message to the client at its Internet address and port.
public void sendReply (byte[] reply, InetAddress clientHost, int clientPort);
协议消息结构
请求应答协议消息结构如下表所示,如果需要提供类似于可靠消息传递或一些额外特性,就需要每一个消息必须有唯一的消息标识符。通过消息标识符才可以引用消息,它由 requestld 和发送进程的标识符(如 IP 地址和 port),第一部分使该标识符对于发送者来说是唯一的,第二部分则使其在分布式系统中是唯一的。
参数 | 数据类型 | 含义 |
---|---|---|
messageType | int(0=Request, 1= Reply) | 消息的类型 |
requestId | int | 信息标识符,用于检测是否为当前请求消息的结果 |
remoteReference | RemoteRef | 远程对象引用 |
operationId | int or Operation | 被调用操作的标识符 |
arguments | array of bytes | 操作参数 |
请求-应答协议的故障模型
如果这三个通信原语操作基于 UDP 数据报实现,则它们会遇到存在遗漏故障、没有保证消息按照其发送顺序进行传输的故障。考虑到服务器故障或请求、应答消息被丢弃的情况,doOperation 方法在等待获取服务器应答消息时使用超时(timeout)机制,当出现超时所采取的方案依赖于所能提供传输保证。
超时的原因可能是请求或应答消息丢失,doOperation 方法会重复地发送请求消息直到它收到应答。或在已有理由相信延迟是因为服务器未作应答而不是丢失了请求消息时,doOperation 方法返回时会以未接收到结果的异常告诉客户。
重复丢弃请求消息
当请求消息重复传输时,服务器可能不止一次地接收到该消息,例如服务器可能接收第一个请求消息但执行时产生超时事件,导致服务器为同样的请求而不止一次地执行某个操作。为了避免这种情况,该协议设计能识别来自同一客户的带有相同请求标识符的连续消息,并过滤掉重发的消息。如果服务器还没有发送应答消息,它就无须采取特殊行动。
丢失应答消息
幂等操作(idempotent operation)指的是:它重复执行的效果与它仅执行一次的效果相同,例如向集合中添加一个元素的操作是幂等操作,而给一个序列添加一个项就不是幂等操作。当服务器收到一个重复的请求消息时若已经发送了应答消息,除非它保存了原先执行的结果,否则它需要再次执行这个操作来获得该结果。一些服务器会不止一次地执行它们的操作并每次都获得相同的结果,而如果一个服务器上的操作都是幂等操作,就没有必要去采取特殊措施避免操作的多次执行。
历史
对于要求重新传输应答而不需要重新执行操作的服务器来说,可以使用历史,术语“历史”通常指的是包含已发送的(应答)消息记录的结构。历史的内容包含请求标识符、消息和消息被发送到的客户的标识符,当客户进程请求服务器时让服务器重新传输应答消息。如果服务器不能确定何时不再需要重新传输消息,则历史的内存开销将会变得很大。
由于客户每次只能发送一个请求,服务器可以将每个请求解释成客户对上一次应答消息的确认,因此历史中只需要包含发送给每个客户的最晚的应答消息。然而当服务器有大量的客户时,客户进程终止时不会为它所接到的最晚的应答消息发送应答确认,因此历史中的消息在一个有限的时间段以后会被丢弃。
交互协议的类型
为了实现多种类型的请求行为,可以使用三种协议:请求(R)协议、请求-应答(RR)协议、请求-应答-确认应答(RRA)协议,这些协议能够在出现通信故障时产生不同的行为。
交互协议 | 说明 |
---|---|
R 协议 | 客户端向服务器端发送一个单独的请求消息,之后客户端可以立即继续执行而无须等待应答消息,该协议基于 UDP 数据报实现。 |
RR 协议 | RR 协议不要求特殊的确认消息,服务器的应答消息看成是客户端请求消息的一个确认,反之亦然。通过带有重新过滤的请求重复传输和在重新传输的历史中保存应答消息的方式,可以屏蔽 UDP 带来的通信故障。 |
RRA 协议 | RRA 的应答消息中包含了来自于被确认的应答消息的 requestld,使服务器能从历史中删除相应的条目,requestld 被视为在所有的 requestld 中比其更小的应答消息的确认。 |
基于 TCP 流的实现
数据报有长度的限制,但是消息中的参数或结果可能是任意长度的,所以数据报不适应于透明 RMI 或 RPC 系统的使用。TCP 流可以传输任意长度的参数和结果,因此基于 TCP 流的实现可以避免多包协议。使用 TCP 协议就能保证可靠的传输请求消息和应答消息,而且流控制机制可以传递大量的参数和结果,不需要采用特殊措施来避免大规模的接收。
远程过程调用 RPC
远程过程调用 RPC 实现了高级的分布透明性,实现了调用远程机器上的程序就像这些程序在本地的地址空间中一样的效果。底层 RPC 系统隐藏了分布式环境重要的部分,包括对参数和结果的编码和解码、消息传递以及保留过程调用要求的语义。
接口编程
模块之间的通信可以依靠模块间的过程调用,为了控制模块之间可能的交互,必须为每一个模块定义显式的接口,来指定可供其他模块访问的过程和变量。实现后的模块就隐藏了除接口以外的所有信息,只要模块的接口保持相同,模块的实现就可以随意改变而不影响到模块的使用者。在分布式程序中模块能够运行在不同的进程中,使用接口可以和具体的实现之间实现分离:
- 程序员只需要关心服务接口提供的抽象而不需要去关注它们的实现细节;
- 程序员无需知道编程语言或者实现服务的底层平台;
- 只要接口(外部视图)保持不变,实现可以改变。
服务接口的定义受分布式底层的基础设施的影响,对于运行在某个进程中的客户模块去访问另一个进程中模块的变量是不可能的,因此服务接口不能直接指定变量进行访问。在本地过程调用中使用的参数传递机制,不适用于调用者和过程在不同的进程中的情况:
- 不支持传递引用;
- 一个过程的地址对于一个远程过程是无效的。
只要编程语言包含适当的定义接口的表示法,并允许将输入和输出参数映射成该语言中正常使用的参数,RPC 机制可以集成到某种编程语言中。接口定义语言(Interface definition languages, IDL)提供了一种定义接口的表示法,允许以不同语言实现过程以便相互调用。
RPC 调用语义
可以通过不同的方式实现 doOperation 以提供不同的传输保证,主要的选择有:
doOperation 实现方式 | 说明 |
---|---|
重发请求消息 | 是否要重传直到接收到应答或者认定服务器已经出现故障为止 |
过滤重复请求 | 是否要在服务器过滤掉重复的请求 |
重传结果 | 是否要在服务器上保存结果消息的历史 |
将这些选择组合使用导致了调用者所见到的 RPC 可靠性的各种可能语义,如下表所示。对于本地方法调用的语义是恰好一次,意味着每个方法都恰好执行一次。
调用语义 | 重传 | 过滤重复请求 | 重新执行过程或重传应答 |
---|---|---|---|
或许 | 否 | 不适用 | 不适用 |
至少一次 | 是 | 否 | 重新执行过程 |
至多一次 | 是 | 是 | 重传应答 |
在或许调用语义中,远程方法可能执行一次或者根本不执行。或许语义对应了没有使用任何容错措施的时候,此时被调用的过程的执行情况无法被确定,它可能会遇到以下的故障类型:
或许调用语义故障 | 说明 |
---|---|
遗漏故障 | 如果调用或结果消息丢失 |
系统崩溃 | 由于包含远程对象的服务器出现故障 |
在至少一次调用语义中,调用者可能收到返回的结果,也可能收到一个异常。在收到返回结果的情况下,调用者知道该方法至少执行过一次,而异常信息则通知它没有接收到执行结果。该语义可以通过重发请求消息来实现,这样可以屏蔽调用或结果消息的遗漏故障。此时可能会遇到以下两种类型的故障,如果能设计服务器中的接口中所有的方法都是幂等操作的话,则至少一次调用语义是可以接受的。
- 由于包含远程对象的服务器故障而引起的系统崩溃;
- 随机故障,重发调用消息时远程对象可能会接收到这一消息并多次执行某一方法,结果导致存储或返回了错误的值。
在至多一次调用语义中,调用者可以接收返回的结果,也可以接收一个异常。在接收返回结果的情况下,调用者知道该方法恰好执行过一次,而异常信息则通知调用者没有收到执行结果。此时方法要么执行过一次,要么根本没有执行。至多一次调用语义可以通过使用所有的容错措施来实现,重传可以屏蔽所有调用或结果消息的遗漏故障。
透明性
透明性的要求指导使远程过程调用与本地过程调用尽可能相似,使得二者在语法上没有差别,所有对编码和消息传递过程的必要调用都对编写调用的程序员面隐藏起来。RPC 致力于提供最少的位置透明性和访问透明性,由于涉及网络、另一台计算机和另一个进程,它比本地调用更容易失败。不论选择上述哪种调用语义,总有可能接收不到结果,而且 RPC 延迟要比本地调用的延迟大好几个数量级。在出现故障的情况下,不可能判别故障是源于网络的失效还是远程服务器进程的故障。远程过程调用也要求另外的参数传递类型,IDL 的设计者也会面临远程调用是否应该透明的抉择。当前比较一致的意见是,从语法一致的角度看 RPC 应该是透明的,它和本地调用的不同应该表现在它们的接口上。
RPC 的实现
RPC 通常通过请求-应答协议实现,通常选择至少一次或至多一次调用语义。对于服务接口中的每个方法,访问服务的客户端包含了一个存根过程(stub procedure)。存根过程的行为对客户端来说就像一个本地过程,它把过程标识符和参数编码成一个请求消息,通过它的通信模块发送给服务器。当应答消息返回时,存根过程将对结果进行解码。
服务器端包含分发器程序、服务器存根过程和服务过程,它们的功能如下表所示,客户和服务器的存根过程及分发器程序可以通过接口编译器从服务的接口定义中自动生成。
服务器端组件 | 说明 |
---|---|
分发器程序 | 根据请求消息中的过程标识符选择一个服务器存根过程 |
服务器存根过程 | 对请求消息中的参数解码,然后调用相应的服务过程,并把返回值编码成应答消息 |
服务过程 | 服务接口中过程的具体实现 |
远程方法调用 RMI
远程方法调用(Remote Method Invocation, RMI) 将远程调用扩展到了分布式对象,在 RMI 中访问对象能够调用位于潜在的远程对象上的方法。RMI 和 RPC 的共性如下:
- 都支持接口编程;
- 都是基于请求-应答协议构造的,并能提供一系列如最少一次、最多一次调用语义;
- 都提供相似程度的透明性,本地调用和远程调用采用相同的语法。
RMI 在复杂的分布式应用和服务的编程中带来一些额外的功能:
- 程序员能够在分布式系统软件开发中使用所有的面向对象编程的功能,以及相关面向对象的设计方法和相关的工具的使用;
- 基于 RMI 系统中的所有对象都有唯一的对象引用,对象引用可以当做参数进行传递。
RMI 的设计
对象模型
RMI 的关键设计问题涉及对象模型,尤其是实现从对象到分布式对象的转变。一个面向对象程序由相互交互的对象的集合组成,每个对象又由一组数据和一组方法组成。一个对象与其他对象通信是通过调用其他对象的方法、传递参数和接收结果进行的,但在一个分布式对象系统中,对象的数据仅通过它的方法被访问。
对象模型 | 说明 |
---|---|
对象引用 | 通过对象引用访问对象,为了调用对象的一个方法,需要给出对象引用和方法名和必要的参数 |
接口 | 接口在无须指定其实现的情况下提供了一系列方法基调的定义(即参数的类型、返回值和异常) |
动作 | 动作由调用另一个对象的方法的对象启动,可以包含执行方法所需的附加信息(参数) |
异常 | 该模块在不使代码复杂化的情况下清晰处理错误条件,每个方法都清楚地列出产生异常的错误条件 |
无用单元收集 | 当不再需要对象时,提供一种手段释放其占用的空间 |
接收者执行适当的方法,然后将控制返回给调用对象,方法的调用会产生三个结果:
- 接收者的状态会发生改变;
- 可以实例化一个新的对象;
- 可能会在其他对象中发生其他方法调用。
分布式对象
程序的状态被划分为几个单独的部分,每个部分都与一个对象关联,所以在分布式系统中可以很自然地将对象物理地分布在不同的进程或计算机中。分布式对象系统可以采用客户-服务器体系结构,对象由服务器管理,客户通过远程方法调用来调用它们的方法。分布式对象的调用流程如下,此时可能会有一连串的相关调用,因此服务器中的对象也可以成为其他服务器中对象的客户。
- 客户调用一个对象方法的请求,以消息的形式传送到管理该对象的服务器;
- 在服务器端执行对象的方法来完成该调用,并将处理的结果通过另一个消息返回给客户。
将客户和服务器对象分布在不同的进程中,可提高封装性。此时一个对象的状态只能被该对象的方法访问,这意味着不可能让未经授权的方法作用于该对象状态。将分布式程序的共享状态视为一个对象集的另一个好处是,对象可以通过 RMI 来访问,其中有些对象既可以接收远程调用又可以接收本地调用。不管是否在同一台计算机内,不同进程中的对象之间的方法调用都被认为是远程方法调用,在同一进程中的称为本地方法调用。
以下是一些关于分布式对象模型的组成部分:
分布式对象模型 | 说明 |
---|---|
远程对象 | 能够接收远程调用的对象 |
远程对象引用 | 它是一个可以用于整个分布式系统的标识符,指向某个唯一的远程对象,访问远程对象的远程对象引用就可以调用其拥有的方法 |
远程接口 | 每个远程对象都有一个远程接口,指定哪些方法可以被远程调用 |
动作 | 一个动作是由方法调用启动的,涉及一连串相关调用的对象可能处于不同的进程或不同的计算机中,当调用跨越了进程或计算机边界的时候就要使用 RMI |
无用单元收集 | 通常通过已有的本地无用单元收集器和一个执行分布式无用单元收集的附加模块的协作来实现 |
异常 | 任何远程调用都可能会因为被调用对象的种种原因而失败,因此远程方法调用应该能够引起异常 |
远程对象引用与本地对象引用主要在以下两方面类似:
- 调用者通过远程对象引用指定接收远程方法调用的远程对象;
- 远程对象引用可以作为远程方法调用的参数和结果传递。
RMI 实现
RMI 模块
完成远程方法调用涉及几个独立的对象和模块,如下表所示:
RMI 模块 | 说明 |
---|---|
通信模块 | 两个相互协作的通信模块执行请求-应答协议,在客户和服务器之间传递请求和应答消息 |
远程引用模块 | 负责在本地对象引用和远程对象引用之间进行翻译,并负责创建远程对象引用 |
伺服器 | 是一个提供了远程对象主体的类的实例,处理由相应的骨架传递的远程请求 |
其中通信模块只使用消息类型、requestld 和被调用对象的远程引用作为消息的内容。服务器端通信模块为被调用的对象类选择分发器,传输其本地引用。伺服器存活于服务器端的进程中,当远程对象被实例化时,就会生成一个伺服器,它们可以一直使用到不再需要远程对象为止。
每个进程中的远程引用模块都有一个远程对象表,记录着该进程的本地对象引用和远程对象引用的对应关系,包括该进程拥有的所有远程对象和每个本地代理。远程引用模块的动作如下:
- 当远程对象第一次作为参数或者结果传递时,远程引用模块创建一个远程对象引用,并把它添加到表中;
- 当远程对象引用随请求或应答消息到达时,远程引用模块要提供对应的本地对象引用。若远程对象引用不在表中,则 RMI 软件就创建一个新的代理并要求远程引用模块将它添加到表中。
RMI 软件
RMI 软件由位于应用层对象和通信模块、远程引用模块之间的软件层组成,涉及的中间件对象有如下几种角色:
中间件对象 | 说明 |
---|---|
代理 | 对调用者表现得像调用本地对象一样,从而使远程方法调用对客户透明。它不执行调用,而是将调用放在消息里传递给远程对象。 |
分发器 | 分发器接收来自通信模块的请求消息,并传递请求消息,并使用 methodld 选择骨架中恰当的方法。 |
骨架 | 远程对象类有一个骨架,用于实现远程接口中的方法一个骨架方法将请求消息中的参数解码,并调用伺服器中的相应方法。它等待调用完成,然后将应答消息传送给发送方代理的方法。 |
RMI 使用的代理类、分发器类和骨架类由接口编译器自动创建,静态的代理类是通过接口定义生成的,并且被编译到客户端的代码中。但是一个远程引用指向了客户端程序中的对象的不确定的远程接口,就需要采用动态调用方法来调用该远程对象。服务器有时需要驻留那些接口在编译时尚不能确定的远程对象,使用动态骨架的服务器便能够解决这种问题。
服务器和客户程序
服务器和客户主要包含以下一些程序:
服务器和客户程序 | 说明 |
---|---|
服务器程序 | 包含分发器类和骨架类,以及它支持的所有伺服器类的实现。服务器程序的初始化部分负责创建并初始化至少一个驻留在服务器上的伺服器,其余的伺服器可以应客户发出的请求而创建。 |
客户程序 | 包含它将调用的所有远程对象的代理类,它用一个绑定程序查找远程对象引用,通常要使用绑定程序来获得服务器端至少一个远程对象的远程对象引用。 |
绑定程序 | 绑定程序维护着一张表,表中包含从文本名字到远程对象引用的映射。服务器用该表来按名字注册远程对象,客户用它来查找这些远程对象。 |
为了避免一个远程调用的执行延误另一个调用的执行,服务器一般为每个远程调用的执行分配一个独立的线程。有些应用要求信息能长时间地保留,但是让该信息对象无限期地保留在运行的进程中是不切实际的。为了避免因为在全部时间里运行管理这些远程对象的服务器造成潜在的资源浪费,服务器应该在客户需要它们的任何时候启动。一个远程对象有两种状态:
远程对象状态 | 说明 |
---|---|
主动对象 | 在一个运行的进程中可供调用的对象 |
被动对象 | 现在不是主动的但是可以激活为主动的,一个被动对象包括它的方法的实现、编码格式的状态 |
激活是指根据相应的被动对象创建一个主动对象,具体方法是创建被动对象类的一个新实例并根据存储的状态初始化它的实例变量。激活器负责注册可以被激活的被动对象,启动已命名的服务器进程并激活进程中的远程对象,以及跟踪已经激活的远程对象所在的服务器位置。
那些在进程两次激活之间仍然保证存活的对象称为持久对象,持久对象一般由持久对象存储来管理,它在磁盘上以编码格式存储持久对象的状态。当这些持久对象的方法被其他对象调用的时候,它们就会被激活。激活一般设计为透明的,调用者应该不能判断一个对象是已经在主存中还是被调用之前已经被激活。有两种方法可以判断一个对象是否是持久的:
- 持久对象存储维护一些持久根,任何可以通过持久根访问到的对象都被定义为持久的;
- 持久对象存储提供一些持久类——持久对象属于它们的子类。
有些远程对象在其整个生命周期里会存在于一系列不同的进程中,可能这些进程存在于不同的计算机中,此时远程对象引用不能当做地址用。定位服务帮助客户根据远程对象引用定位远程对象,它使用了一个数据库将远程对象引用映射到它们当前的大概位置。如果一个本地对象引用或者远程对象引用在没有任何对象引用它时,该对象将被收集并且它使用的内存将被回收。Java 的分布式无用单元收集算法基于引用计数工作,一旦一个远程对象引用进入一个进程,进程就会创建一个代理,只要需要这个代理它就一直存在。对象生存的进程应该告知给客户上的新代理,随后当客户不再有代理也应告知服务器。Jini 分布式系统包括一个租借规约,为了避免用复杂的协议判断资源用户是否还有兴趣,资源只提供一段有限长的时间。提供资源的对象会负责维护它直到租期结束,资源的用户负责在过期的时候请求延续它们的租约。
参考资料
《分布式系统概念与设计》[英]George Coulouris, Jean Dollimore, Tim Kindberg,Gordon Blair,金蓓弘,马应龙 译,机械工业出版社