RPC(三)《Implementing Remote Procedure Calls》译文

1 介绍

        远程过程调用似乎是一种有用的范式,用于在以高级语言编写的程序之间提供跨网络的通信。本文描述一个提供了远程调用工具的软件包,面对这样一个软件包时一个设计者拥有的选项,以及我们做出的选择。我们描述了我们的RPC机制的整体结构,用于绑定RPC客户端的工具,传输通信层协议,以及一些性能测量。包括用于实现高性能和最小化集群间负载的一些优化的描述。

1.1 背景

        远程过程调用(以下称RPC)的概念是非常简单的。它是基于这一观察:过程调用是一个众所周知且易于理解的机制,用于在单个计算机上运行的程序内的控制和数据的传输。因此提出扩展这种机制以提供跨网络的控制和数据的传输。但一个远程程序被调用时,该调用环境会被挂起,参数通过网络传输到该程序被调用的环境中(我们将之称为被调用者),并在那里执行相应的程序。当程序执行结束并产生结果时,结果再被传回调用环境中。此时调用环境中的执行恢复,仿佛是在单机上执行调用一样。当调用环境被挂起时,该机器上的其他进程可能仍然在执行——这取决于该环境的并行性和RPC的实现。

        这个想法还有很多吸引人的方面。一个是其干净和简洁的方言(语义):这使得正确地构建分布式计算变得更容易。另外一个就是效率:过程调用对于快速的通信来说也足够简单。第三个就是通用性:在单机计算中,过程通常是算法各部分间通信的最终要的机制。

        RPC这一概念已存在多年了。至少从1976年以来,它已经在公共文学中被多次讨论过。Nelson的博士论文是对RPC系统设计可能性的广泛研究,而且参考了大量的以前关于RPC的研究。但是,RPC的完整实现比论文设计更少见。最近值得注意和关注的努力包括Courier在Xerox NS协议族中的研究和目前麻省理工学院的工作。

        本文是为Cedar项目构建RPC工具的结晶。我们感觉到,由于之前的工作(尤其是Nelson的论文和与之相关的研究),我们理解了一个RPC工具设计者必须做出的选择。我们的任务是根据我们特定的目标和环境做出选择。事实上,我们发现有很多地方难以理解,我们制作了一个在某些方面有些新颖的系统。一个RPC工具设计者面对的主要问题包括:当发生机器故障和通信失败时精确的调用语义;包含地址的参数在(可能)缺少共享地址空间时的语义;将远程调用集成到现有(或将来)的编程系统中;绑定(调用者如何确定被调用者的位置和身份);适合调用者和被调用者之间传输控制和数据的协议;以及在一个开放的通信网络中,如何确保数据的完备性和安全性(如果需要的话)。在构建我们的RPC包时,我们解决了这些问题,但是在一份简单的paper中以合适的深度描述所有这些问题是不可能的。这篇paper包括我们关于这些问题的讨论和决策,以及我们对其解决方案的整体架构。我们也描述了一些关于我们的绑定机制和传输层通信协议的细节。我们计划在以后的论文中描述我们基于加密的安全设施,提供更多关于制作stub模块(负责解释RPC调用参数和结果)和我们实际使用该工具的经验。

1.2 环境

        我们构建的远程过程调用包主要用于Cedar编程环境中,通过Xerox研究的内部网络进行通信。在构建这样的包时,环境的某些特征不可避免的会对设计造成影响,所以环境总结如下。

        Cedar是一个专注于开发功能强大且便于构建的实验性编码和系统环境的大型项目。这里强调的是它的统一性,高交互性的用户接口,易于构建和调试程序。Cedar的设计主要用于单用户的工作环境,虽然它也用于服务器的构建(共享电脑提供公共服务,通过通信网络访问)。

        使用Cedar最多的电脑是Dorados。Dorado是一个非常强大的机器(比如,一个简单的Algol风格的调用返回花费的时间小于10微秒)。它装备了一个24bit的虚拟地址空间(16bit字符)和一个80MB的磁盘。对于单个用户而言,可以将Dorado看成拥有像IBM370/168处理器能力的机器。

        这些电脑之间的通信是很典型的每秒3MB的以太网(有一些电脑间能达到10MB每秒)。大多数运行Cedar的电脑都在同一个以太网上,但是有一些是在我们的研究网络中的不同的以太网上。互联网络包括大量3MB和10MB的以太网(目前大约160个),它们通过出租电话和卫星链路连接。我们设想我们的RPC通信将遵循我们在别的协议中见到过的模式:大多数通信都在本地以太网上(这样对用户来说,互联网链路很低的数据速率就不会对用户造成不方便),而且以太网也不会过载(我们几乎没有见过负载超过以太网承载容量的40%,最常见的是10%)。

        PUP协议族提供了在互联网间访问任何主机的统一的访问形式。先前的PUP协议包括简单不可靠(但是高概率)的数据报服务和简单可靠的流控制字节流。在统一以太网中的不同主机间,可以使用较低级别的以太网数据包格式。

        基本上使用的所有编程语言都是高级语言,主要用的是Messa(根据Cedar进行了修改),尽管也使用了Smalltalk和InterLisp。Dorados没有汇编语言。

1.3 目标

        我们的RPC项目最主要的目的是使得分布式计算更容易。在我们之前的团体研究中可以发现,仅仅由一组选定的通信专家去保证通信系统的构造是一项很困难的任务。甚至那些有大量深厚系统经验的研究者也发现,用已有的工具去获取特定的用来构造分布式系统的专业知识是一件很困难的事情。这是我们不希望看到的。我们拥有非常广泛的,功能强大的通信网络,大量的性能强大的电脑,以及使得构造程序相对容易的环境。现有的通信机制似乎成为了约束进一步开发分布式计算的主要因素。我们的愿景是通过提供几乎和本地调用程序一样简单轻松的通信机制,人们将因此被鼓舞去构建并实验分布式的应用。我们希望,RPC将消除构造分布式系统的一切不必要的困难,只保留最基本的问题:时序,组件间的故障独立,独立执行环境的共存。

        我们还有两个希望能够支持我们主要目标的次要目标。我们想要RPC通信尽可能的高效(比如说,超出网络必要传输时间的5倍)。这似乎很重要,以免通信成本变得如此昂贵甚至让应用设计者极力想避免它。否则原本被开发的应用可能会因避免昂贵通信的渴望而导致扭曲。另外,我们发现让RPC包的语义尽可能的强大时很重要的,而且不能丢失简洁性和有效性。否则,通过要求应用程序员在RPC包之上构建额外的机制,单个统一通信范式的收益将会丢失。在设计中的一个重要的问题就是解决强大的语义和效率之间的矛盾。

        我们最终最主要的目标就是通过RPC提供安全的通信。以前实现的协议没有任何保护我们在网络上传输数据的规范。这是真实的,即使密码以明文传输。我们的信念是,对通过开放网络进行安全通信的协议和机制的研究已经到达一个阶段——对我们而言把这种保护包含进我们的包中是合理而且符合期望的。另外,几乎没有分布式系统已经提供了安全的端到端通信,而且从来没被运用到RPC中,所以我们的设计将提供非常有用的研究见解。

1.4 基本设计

        我们应该使用程序调用作为一种表达控制和数据传输的范式——这不是我们目标的直接结果。例如,消息传递是一种似是而非的选择。我们相信在这些可替代方案中做出选择不会对这种设计所面临的问题以及被采用的解决方案产生重大影响。可靠和有效传输以及其可能的回复的问题和远程过程调用遇到的问题非常类似。传输参数和结果以及网络安全的问题本质上没变。使得我们选择远程过程调用的最重要的考虑是它们是嵌入在我们的主要的编程语言Mesa中的主要的控制和数据传输机制。

        人们也可能考虑使用一种更加并行的模式来进行通信,比如某种形式的远程分支。因为我们的语言已经包含了用于分支并行计算的构造,我们本可以选择这个作为添加通信语义的点。同样,这并不会改变主要的设计问题。

        我们放弃了在电脑之间模拟某种共享地址的可能性。以前的工作已经表明,在充分注意的情况下,可以通过这种方式达到中等效率。我们不知道采用共享地址的方法是否可行,但是两个潜在的主要困难已经浮现在脑海:首先,远程地址的表示是否可以集成到我们的编程语言(可能还有底层的机器架构)中而不会导致剧烈的动荡;其次,效率是否可以接受。例如,在PUP网络中的主机被一个16位的地址表示,所以一个简单的共享地址空间的实现将会把语言地址空间的宽度扩展16位。另一方面,小心地使用我们的虚拟内存硬件的地址映射机制可以允许共享地址空间而无需改变地址宽度。即使在我们的10MB以太网上,数据包交换最小的平均往返时间也是120微妙。因此最有可能实现这个的方式就是去使用某种传呼系统。总之,在RPC参与者间的共享地址空间可能是可行的,但是因为我们没有意愿去进行该项研究,因此我们随后的设计中假设没有共享空间。我们的直觉是,在我们的硬件设备上,使用共享地址空间花费的代价要超出使用它带来的益处。

        在做设计选择的时候我们多次用到的一个原则就是远程过程调用的语义要尽可能地接近那些本地(单机)过程调用的语义。作为一种保证RPC工具易用性的方式,这个原则似乎非常有吸引力,尤其对于那些熟悉使用单机语言和语言包的程序员而言。违反这个原则似乎可能会把我们引向一种困境,即使得从前的通信包和协议变得难以使用。这个原则可能偶尔会导致我们偏离那种对有丰富分布式计算经验的人而言很有吸引力的设计。比如说,我们没有设计超时机制来限制远程调用持续的时间(在没有机器和通信故障的情况下),然而大多数通信包认为认为这是一个有价值的功能。我们的观点是本地过程调用没有超时机制,而且我们的语言包含了一种机制能够去终止那种作为并行处理的一部分的活动。仅仅为RPC设计一个超时管理机制将毫无必要地将程序员的世界变得更复杂。同样地,我们选择了下面描述的构造语义(基于现有的Cedar机制)而不是在Nelson的论文中提出的那些。

1.5 结构


RPC(三)《Implementing Remote Procedure Calls》译文_第1张图片
图1. 系统组成以及一个简单调用的交互过程

        我们用于RPC的程序结构和Nelson的论文中提出的类似。它基于stub的概念。当发起远程调用时,涉及到五部分程序:user,user-stub,RPC通信包(称为RPCRuntime),setver-stub,server。它们的关系如图一所示。user,user-stub和其中一个RPCRuntime的实例在调用者机器上执行;server,server-stub和另外一个RPCRuntime实例在被调用者机器上执行。当使用者希望去发起一个远程调用时,它其实是执行了一个完全正常的本地调用,而这个调用会去调用user-stub中相应的程序。user-stub负责将目标程序的规范和参数放置在一个或多个包中,并请求RPCRuntime将这些包可靠地传输给被调用者机器。一旦接收到这些包,被调用者机器上的RPCRuntime就把它们传送给server-stub。Server-stub将它们解包,像是执行一个完全正常的本地调用一样,该本地调用会调用server中相对应的程序。与此同时,调用者机器上的调用进程将被挂起并等待结果包的返回。当server中的调用完成时,它将结果返回给server-stub打包,然后结果包将被传送回给调用者机器上挂起的进程。它们将被user-stub解包并返回给user。RCPCRuntime负责重传,确认,数据包路由和加密。除去多机器间机器绑定或者通信失败的影响,调用就仿佛user直接在server上调用程序一样。确实是这样,如果user和server的代码被放在一个机器上,并被直接绑定在一起(而无需stub),程序将仍能工作。

        RPCRuntime是Cedar系统的标准部分。User和Server是需要作为分布式应用的一部分被编写的。但是user-stub和server-stub却是被一个叫Lupine的程序自动生成的。这个生成是通过使用Mesa接口模块指定的。这些是Mesa(和Cedar)分别编译和绑定机制的基础。接口模块最主要是一系列程序的名称,以及它们的参数类型和结果。对于调用者和被调用者来说,这已经提供了足够的信息以供它们独立地执行编译时的类型检查并生成合适的调用序列。实现了接口过程的程序模块被称为导出接口。调用接口过程的程序模块被称为导入接口。当一个程序员想要编写一个分布式应用时,他首先应该编写一组接口模块。然后再去编写导入该接口的user code和导出该接口的server code。他也应该将该接口暴露给Lupine,Lupine将生成user-stub(导出接口)和server-stub(导入接口)。当把程序绑定到调用者机器上时,user即被绑定到user-stub。在被调用者机器上,server-stub也与server绑定。

        因此,程序员不需要去详细地构建那些与通信相关的细节代码。在设计好接口后,只需要去编写user code和server code即可。Lupine负责生成打包和解包参数以及结果(还有别的关于参数和结果语义的细节)的代码,以及为server-stub中接收到的请求调度正确的程序。程序员必须避免指定与缺少共享地址空间不兼容的参数和结果(Lupine将做这种检查)。程序员还必须采取措施去调用第2部分描述的intermachine绑定,还要去处理被报告的机器和通信故障。

2 绑定

        我们依次考虑绑定的两个方面。首先,一个绑定机制的客户端如何指定他被绑定的绑定机制?其次,调用者如何确定被调用者机器的地址并指定被调用者调用哪些程序(过程)?第一个主要是命名的问题,第二个是定位的问题。

2.1 命名

        由我们的RPC包提供的绑定操作是将一个接口的导入器绑定到接口的导出器。绑定之后,导入器发出的调用将会调用被(远程)导出器实现的过程。接口名称分为两部分:类型和实例。类型是在某种抽象的层次上指定调用者期望被调用者去实现哪个接口。实例是指定所需抽象接口的特定实现。比如,接口的类型可能对应于“邮件服务器”的抽象,实例可能对应于从很多邮箱服务器中选出的一些特定的服务器。接口类型的合理默认值可能是一个从Mesa接口模块派生出的名字。从根本上说,一个接口名字的语义不是由RPC包所决定的——它们是导出器和导入器之间的协定,并不是RPC包可完全强制的。然而,导出器使用接口名称去定位导出器却是由RPC包指定的,我们现在将描述它。

2.2 定位合适的导出器

        我们使用Grapevine分布式数据库进行RPC绑定。使用Grapevine的主要吸引力是它的广泛性和可靠性。Grapevine策略性地分布于位于我们网络拓补中的多台服务器上,而且它被配置去保存至少三个数据库entry的副本。由于Grapevine服务本身就极其可靠而且数据有多个副本,我们几乎不会遇到丢失数据entry的情况。有使用这种数据库的替代方案,但它们并不能令我们满意。比如,我们可以以它们希望的方式在我们应用的程序中包含将要与之进行通信的机器的网络地址:对于大多数应用来说,这将会过早地绑定一个特定的机器。除此以外,我们可以使用某种广播协议去定位期望的机器:通常这是可以接受的,但是作为一种笼统性的机制将会对无辜的旁观者造成太多干扰,而且绑定到那些非本地的网络中的机器时也不够方便。

        Grapevine的数据库由一系列的entry组成,每一个entry都对应一个被称为Rname的键。Entries有两种:individuals(单个的)和groups(分组的)。Grapevine对每一个数据库entry都维持了很多元信息,但是RPC包只关心两个:对于每一个individual来说都有一个连接地址——这个地址是一个网络地址,对于每一个group来说则有一个成员列表——是一系列的Rname。RPC包在Grapevine数据库中为每个接口名称维持两个entries:每个类型一个,每个实例一个;所以类型和实例都是Grapevine Rnames。实例的数据库entry是一个Grapevine individual,它的连接地址是一个网络地址,具体来说就是那个实例最后被导出到的机器的网络地址。类型的数据库entry是一个Grapevine group,它的成员是那些已被导出的类型的实例的Grapevine Rnames。例如,如果一个运行在网络地址为3#22#的服务导出了一个类型为FileAccess.Alpine实例为Ebbets.Alpine的远程接口,而一个运行在网络地址为3#276#上的服务导出了一个类型为FileAccess.Alpine实例为Luther.Alpine的远程接口,那么Grapevine group FileAccess.Alpine将包括两个成员:Ebbets.Alpine和Luther.Alpine。The Grapevine individual Ebbets. Alpine将把3#22#作为它的连接地址,Luther.Alpine的连接地址则为3#276#。

        当希望将一个导出器的接口对远程客户端可见时,服务端代码将调用server-stub,随之server-stub将调用RPCRuntime中的一个过程,即导出接口。导出接口被赋予接口名称(类型和实例)和一个过程(被称为调度器),该调度器在server-stub中实现,用来处理接口的传入调用。导出接口调用Grapvine并确保该实例是Grapevine group(该类型)的成员之一,以及该实例的连接地址是导出机器的网络地址。这可能导致更新数据库。一种优化是如果它包含了正确的信息,那么数据库将不会被更新——这通常是正确的:尤其当来自同一网络地址的同一接口已被导出的情况。例如,为了从网络地址3#22#导出类型为FileAccess.Alpine和实例为Ebbets.Alpine的接口,RPCRuntime将确保在Grapevine数据库中的Ebbets.Alpine含有连接地址为3#22#的信息,以及Ebbets.Alpine是FileAccess.Alpine的成员之一。随后,RPCRuntime在位于导出的机器上的一张表中记录关于这个导出的信息,对每个当前被导出的接口而言,这张表包含了接口名称,server-stub中的调度过程,以及一个永久的独一无二的用于标识这个导出的32位的值。该表是一个由小整数索引的数组实现的。通过使用32位计数器的连续值,保证标识符的永久唯一性;在启动时,这个计数器初始化为值是一秒的实时时钟,随后该计数器被约束为小于当前这个时钟的值。这种限制使得对单机上导出接口的调用速率平均小于每秒1次,这是自导出机器重启以来的平均值。这种调用的突发速率可以超过每秒一次(见图2)。

RPC(三)《Implementing Remote Procedure Calls》译文_第2张图片
图2. 绑定事件以及随后调用的序列。被调用者机器导出类型为A实例为B的远程接口。调用者机器随后导入该接口。然后我们暴露给初始化调用程序F的调用者,这是那个接口的的第三个步骤。结果不会展示。

        当一个导入器希望绑定到一个导出器时,user code会调用它的uset-stub,随后user-stub会调用RPCRuntime中的一个过程,即导入接口,为其提供所需的接口类型和实例。

        RPCRuntime通过向Grapevine请求网络地址(即接口实例的连接地址)来确定的导出器(如果有的话)的网络地址。随后,RPCRuntime将发起一个远程调用去请求远程机器上的RPCRuntime包以获取有关该接口的类型和实例的绑定信息。如果指定的机器当前未能导出那个接口,该事实将被返回给导入机器,则绑定失败。如果被指定的机器当前正导出该接口,则其RPCRuntime维护的当前导出表将生成相应的唯一标识符;该标识符和该表的索引将会被返回给导入机器,绑定成功。导出器的网络地址,唯一标识符和表索引将被user-stub记住并在远程调用时使用。

        接下来,当该user-stub在导入的远程接口上发起调用时,它产生的调用包包含了所需接口的唯一标识符和表索引,以及该接口所需过程的entry point编号。当在被调用者机器上的RPCRuntime接收到这个调用包时,它将使用该包中的索引去搜寻该当前导出表(很快),并验证包中的唯一标识符与表中的唯一标识符是否一致,然后将该调用包传递给包中指定的调度过程。

        我们的客户可以使用这种绑定方案的几种变体。如果调用导入接口的导入器仅仅指定了接口类型却没指定实例,RPCRuntime将从Grapevine获得由该类型命名的Grapevine group成员。RPCRuntime随后获取到该组中的每个成员的网络地址,并依次尝试这些地址以找到那些将接收绑定请求的实例:这很快就会被完成,以一种趋向于定位最近的(相应最快的)正在运行的导出器的顺序。这允许导入器绑定到最靠近的运行中的服务副本的实例,但是导入器并不关心是哪个实例。当然,导入器可以通过枚举由该类型命名的组的成员来自由枚举实例。

        实例可能是网络地址常量而不是一个Grapevine名称。这将允许导入器绑定到导出器而不与Grapevine进行任何交互,代价就是在应用程序中要包含一个明确的网络地址。

2.3 讨论

        这个方案由一些重要的作用。请注意,导入一个接口对导出机器中的数据结构没有影响;这对于构建有大量用户的服务而言是有利的,而且避免了服务器如何处理随后导入接口崩溃相关信息的问题。此外,使用唯一标识符的方案意味着如果导出器崩溃和重启(因为唯一标识符在每次调用时都要被检查),绑定将被隐式破坏。我们相信这种隐式地解除绑定是一种正确的语义:否则当调用失败时将不会通知用户。最后值得注意的是此种方案只允许调用通过RPC机制显示导出的过程。另一种更有效的方案是使用导出器的server-stub调度程序的内部表示来发布导入器;我们认为这种做法并不是我们期望的,因为它将允许对服务端机器的任何程序进行未经检查的访问,因此,将无法强制进行任何保护或者安全方案。

        限制对更新Grapevine数据库的访问控制能够限制那些将要导出特定接口名称的用户。这些是期望的语义:比如,对一个随机用户而言,他声称他的工作站是一个邮件服务器从而想去拦截我的消息流量,这是不可能的。对服务的副本而言,这种访问控制也至关重要。服务副本的客户端可能不会事先知道服务实例的名称。如果客户端希望使用双向验证来确保服务是真实的,并且如果我们希望避免使用几个简单的密码来作为每个服务实例的验证,那么客户端必须能够安全地获取服务实例的名称列表。在客户端与Grapevine交互的过程中,当接口被导入时,我们可以采用一种安全协议来实现这种安全机制。因此,Grapevine的访问控制能够使客户端确保服务实例是真实的(经授权的)。

        我们允许多种事件绑定选择。最灵活的选择是导入器仅仅指定接口的类型而不用指定其实例:此处关于接口的实例是动态决定的。接下来(最常见的)接口实例是Rname,它将延迟特定导出机器的选择。最具限制性的功能是将一个网络地址指定为一个实例,从而在编译时将其绑定到一个特定的机器上。我们也提供允许导入器动态实例化接口并导入它们的工具。关于如何实现此操作的详细说明对于本文而言太过复杂,但是总的来说,它允许导入器将其程序绑定到多个导出机器,即使导入器也无法静态的知道它希望绑定多少台机器。事实证明,这在一些开放式的多机算法中非常有用,例如分布式原子事物管理器的实现。我们不允许以比整个接口更细的粒度进行绑定。考虑到我们在包和系统中已经观察到的这种机制的不可用性,这不是我们考虑的选择。

3 包层级传输协议

3.1 要求

        其实不必设计一个特定的包层协议就可以实现RPC的语义。比如,我们本可以通过使用PUP字节流协议(或者Xerox NS序列化包协议)作为传输层的协议来构建我们的包。我们以前的一些实验就使用了PUP字节流, Xerox NS "Courier" RPC协议使用了NS序列化包协议。Grapevine协议本质上类似于使用PUP字节流的远程过程调用。我们的测量以及每个实施的经验使我们确信这种方法并不能令人满意。RPC通信的特性意味着如果设计并实现一个专门用于RPC的协议将会在性能上有极大的提升。我们的实验表明,性能增益可能达到10倍。

        一种中间的站位可能是成立的:我们从来没有尝试过使用现存传输层协议构建一个传输协议的实现以专用于RPC。然而,RPC通信的性质完全不同于通常采用字节流的大数据传输,我们认为这个中间位置是不可行的。

        我们在协议中强调的目标是最小化在启动调用和获得结果之间经过的时间。让协议用于大批量数据传输这并不重要:绝大多数的时间其实是花在了传输数据上。我们还努力减小用户量很大时加给服务器的负担。当执行大批量数据传输时,采用花费大量成本在建立和断开连接上是可以接受的,而且这要求维护一个连接中大量的状态信息。这些都是可以接受的因为相对于数据传输本身而言,成本是极小的。我们相信这对于RPC来说是不合适的。我们设想我们的机器可以服务大量的用户,要求大量的状态信息或者昂贵的连接握手是不可接收的。

        正是这个RPC包级别定义了语义和我们为调用提供的保证。我们保证如果调用返回给了用户,随后在服务端的过程就被精确地调用了一次。否则,发生的异常将被告知用户,而过程则被调用了一次或者根本没有被调用——用户不会被告知是哪种情况。如果发生了异常,用户不知道是服务端崩溃了还是通信网络出了问题。如果服务器端的机器上的RPCRuntime仍在响应,我们等待结果的时间将没有上限;那就是说,如果有通信故障或崩溃,我们将废弃调用,而如果服务端代码出现了死锁或者无限循环则不会废弃调用。这和本地过程调用的语义完全相同。

3.2 简单调用

        我们试图使每个通信调用都做到特别高效,因为在所有参数和结果都适用一个简单的包缓冲区以及频繁调用会发生的情况都是如此。发起调用时,调用者发送一个包含调用标识符(下面会讨论),指定所需过程(正如绑定连接中描述)的数据,以及参数。当被调用者接收到该包时相对应的过程将被调用。当过程返回时,一个包含了同样调用标识符的结果包,即结果,将会被返回给调用者。

        为了补齐可能丢失的包,发送包的机器负责重传直到它收到确认已经接收到包。然而,调用结果足以确认调用包已经被接收,而且调用包也足以确认给进程先前调用的结果包。因此,在调用持续时间和调用间隔均小于传输间隔的情况下,我们在每次调用中精确地传输了两个包(每个方向一个)。如果调用持续的时间较长或者调用间隔较长,甚至达到了再发送另外两个包(重传或显示的确认包)的时间;我们仍然认为这个是可以接受的,因为在这种情况下,很明显通信时间花费不再是性能的限制因素。

        调用标识符主要为两个目的服务。它能够让调用者确定结果包确实是它当前调用的结果(例如,不是以前的某个调用延迟的结果),另外,它允许被调用者清除重复的调用包(例如,由重传造成的)。调用标识符由调用机器标识符(这个是永久的也是全局唯一的),一个机器相关的进程标识符,以及一个序列数字组成。我们在学术上将【机器标识符,进程】称为一个activity。Activity的一个重要属性是在任何时刻每个activity最多只能由一个活跃的远程调用——在它接收到前一个调用结果之前,他将不会再初始化任何新的调用。每个activity的调用序列数字必须是不变的(但不是必须有顺序)。被调用者机器上的RPCRuntime维护着一张表,这张表提供了每个活跃activity最近一次调用的序列数字。当调用包被接收时,就从这张表中查找它的调用标识符。这个调用包可能会被当做副本丢弃(很可能是在确认后)除非它的序列数字比在这张表中查到的大。图3展示了在一个简单的调用中包的传输过程。

RPC(三)《Implementing Remote Procedure Calls》译文_第3张图片
图3. 简单调用中的包传输

        将这种安排和在更重量级的传输协中建立连接,维护和中止进行比较很有趣。在我们的协议中,我们认为连接就是调用者机器上的activity和服务端机器上的RPCRtime包接收来自该activity共享状态信息的过程。我们不要求特定的建立连接协议(相比于其他协议所要求的双包握手而言);接收到先前未知的activity的包已足够去隐式地创建连接。当连接处于激活状态(有调用正在被处理,或者该调用的最后一个结果包还没有被确认),两端都维持着大量的状态信息。然而,当连接处于空闲状态时,服务器上的唯一状态信息是它的序列号表中的条目。当连接处于空闲状态时,调用者会最小化状态信息:仅够一台机器计数就足够了。当初始化一个新的调用,它的序列号就是这个计数器的下一个值。这就是来自一个activity的调用的序列号仅仅被要求是不变的,而不是有序的原因。当一个连接处于空闲状态时,任一一台机器中的进程都与该连接无关。不要求通信(比如“pinging”包交换)去维护空闲连接。我们没有明确的连接终端协议。如果一个连接处于空闲状态,服务器就会在一定的时间间隔后丢弃该空闲连接的状态信息,此时不再有任何接收重传调用包的危险(例如,五分钟后),而且它可以无需告知调用者机器就这么做。这种方案无需成本即可保证传统的面向连接协议。但尽管如此,在远程绑定时我们依赖于介绍过的唯一标识符。如果服务发生崩溃然后重启,而这时调用者仍然在重传一个调用包(虽不太可能,但也合理),没有标识符的话我们将无法判断重复数据。我们也假设即使调用者机器重启,来自同一个activity的调用序列号也不能重复(否则这个来自于重启机器的调用可能会被当做副本删除)。事实上,我们认为这个是32位会话标识符的副作用,我们将其与安全调用一起使用。对于非安全调用,会话标识符可以被看作是区分调用机器的永久唯一标识符。在每次调用中,会话标识符都会同调用序列号一起被传输。我们基于每个机器维护的32位时钟生成会话标识符(当机器重启时,从网络时间服务器初始化)。

        根据以前的系统经验,我们预计这种轻量级的连接管理在构建大型繁忙的分布式中将会非常重要。

3.3 复杂调用

        如上所述,包传输器负责重传包直到它被确认。在这样做的过程中,包被修改以请求一个显示的确认。这将处理丢失包,持续时间长的调用,和调用间的长时间间隔。当调用者对确认感到满意时,调用进程将等待结果包。然而在等待过程中,调用者会周期性地发送探测包给被调用者,而这是被调用者需要确认的。这允许调用者注意到是否被调用者已经崩溃或者发生了严重的通信故障,并将异常告知用户。如果这些探测持续被确认那么调用者将无限期等待,因为他知道被调用者正在(或声称)调用。在我们的实现中,第一个探测是在一个短暂地延迟之后发出的,这个延迟大约相当于这两个机器之间的往返时间。探测之间的间隔会逐渐增加,直到大约10分钟后,探测会以每五分钟一次的频率发送。每一个探测服从的传输策略类似于该调用中其他包所使用的策略。所以,如果发生了通信故障,相对于调用者等待调用结果的总时间,调用者将被相当迅速地告知。注意这只会检测到通信级别的故障:如果在调用过程中被调用者发生死锁将不会被检测。这符合我们使RPC语义与本地过程调用语义类似的原则。我们有可用的语言工具来检测进程,并在合适的时候丢弃它;该工具只适用于等待远程调用的进程。

        重传和确认可能的替代策略是如果它不能够比预期的重传间隔更快地产生下一个包,那么就让包的接收者自发地生成确认。这将会导致在处理持续时间较长的调用或者调用间隔时间过长时保存包的重传。我们认为保存这个包并不会带来巨大的增益,因为这需要额外的成本去检测自发的确认。在我们的实现中,这种额外的成本体现在维护一个额外的数据结构,以使服务器上额外的进程适时地产生自发确认,加上额外进程的成本来计算何时去生成确认。事实上,当不需要确认时,避免增加额外的成本是很困难的。调用者则没有类似的额外花销,因为调用者有一种必要的重传机制以应对包的丢失。

        如果参数(或结果)太大以至于无法以单个包发送,它们将以多个包的形式发送,但是最后一个要求显示确认。因此当发送一个大的调用参数时,包将被调用者和被调用者交互发送,发送者发送数据包而被调用者回复确认。这允许这种实现在每一端仅仅使用一个包缓冲区来用于调用,并避免了在一般的批量数据传输协议中发现的缓冲区和流控制策略的的必要性。为了能够消除重复数据,该调用的多个数据包的每一个都有一个调用相关的序列号。图4展示了复杂调用的包序列。

RPC(三)《Implementing Remote Procedure Calls》译文_第4张图片
图4. 一个复杂调用

        正如在3.1节中的描述,这个协议旨在处理本地网络中的简单调用。如果调用需要多个数据包来发送其参数和结果,我们的协议会比逻辑上要求更多的数据包。我们认为这是可以接受的;仍然需要为协议涉及有效的批量传输;我们还没有尝试将RPC和批量数据合并到一个协议中。为了在一个方向上传输大量数据,我们的协议发送的的数据包量达到了一个好的批量数据协议可以发送的两倍(因为我们确认每个包)。这在具有大延迟和高数据率的长途网络中尤其不合适。然而,如果通信活动可以合理地表示为过程调用,那么即使在如此长的网络中,我们的协议就具有理想的特性。有时候使用RPC在这些网络上进行批量数据传输是可行的,通过在多个进程中间复用这些数据,而每个进程都进行单包调用——那么代价就只是对每个包的额外确认,在某些情况下这是可以接受的。要求对每一个参数包(除了最后一个)进行一个确认的优点是这简化并优化了实现。用我们的协议来进行简单调用是可行的,而且能自动切换到一个更常用的协议来进行复杂调用。我们还没有探究这种可能性。

3.4 异常捕获

        Mesa语言提供了精巧的工具来通知调用者调用过程的异常。这些被称为信号的异常可以被认为是动态绑定过程的活动:当引发异常时,Mesa运行时系统会动态地扫描调用堆栈以确定是否有捕获异常的短语。如果是这样,catch语句的主体将被执行,并在引发异常时给出参数。Catch语句可能会返回(带有结果),并导致执行恢复引发异常的地方,也可能跳出到一个闭合的语境上下文中从而终止。在这种终止的情况下,堆栈上的动态更新的过程活动将被释放(以最近最新的顺序)。

        我们的RPC包忠实地模仿了这种机制。协议中有工具允许服务器上的进程处理一个传输异常包而不是结果包的调用。这个包被调用者机器上的RPCRuntime处理仿佛该包是一个调用包。但它会在对应的进程中引发异常而不是发起一个新的调用。如果有适当的catch语句,则该语句将被执行。如果catch语句返回,结果被返回给被调用者机器,事件正常执行。如果catch语句因跳转而终止那么被调用者将被告知,然后释放相对应的过程活动。由此可看出,我们再次模仿了本地调用的语义。这并不十分准确:事实上我们只允许被调用者机器传达那些调用者导出的在Mesa接口中定义的异常。这简化了我们的实现(从调用者机器的环境传输到被调用者环境过程中传输异常的名称)。在单机程序中的程序规范是如果一个包想传达异常给它的调用者,那么该异常应该被定义在该包的接口中;别的异常则应该由调试器处理。我们为RPC异常维持并实施了该规范。

        除了被调用者引发的异常外,如果有通信问题RPCRuntime可能会引发呼叫失败异常。这是我们的客户注意本地调用和远程调用间区别的主要方式。

3.5 使用过程

        在Mesa和Cedar中,并发进程可以作为一种内置的语言特性使用。在进程交换空间创建进程和改变处理器状态是廉价的。例如,分叉一个新的进程的花费相当于十个本地过程调用。一个进程交换包含堆栈评估和一个寄存器的交换,以及使一些缓存信息失效。然而,在远程调用的规模上,进程的创建和交换则会产生很大的成本。这已经在Nelson的某些论实验中展现出来。因此我们在构建包和设计协议时有意地保持了低成本。

        降低成本的第一步是在每个机器中维护一批旨在处理传入数据包的空闲服务进程。这意味着调用可以在没有发生服务进程创建以及初始化服务进程某些状态的情况下就被处理。当一个服务进程完成一个调用时,它会恢复到空闲状态而不是死去。当然,如果它们是为了响应大量RPC调用的短暂高峰而被创建,多余的空闲服务进程会自行终止。

        每一个包都包含源和目标的进程标识符。在那些来源于调用者机器上的包里,源进程标识符是调用的进程。在那些源于被调用者机器的包里,源进程标识符是处理调用的服务端进程。调用过程中,当进程传输包时,它会根据该调用的上一个包中的源进程标识符来设置传输包中的目的进程标识符。如果一个进程正在等待该调用的下一个包,该进程会在一个与我们的以太网终端处理器共享的(简单的)数据结构中记录此事实。当终端处理器接收到一个RPC包,它会查看该包的目标进程标识符。如果此时该机器上相应的进程正在等待RPC包,那么该输入包就直接被分配给该等待进程。否则,该包被分配给一个空闲的服务进程(然后该进程去确定这个包是当前请求确认调用的一部分,还是服务进程应该处理的一个新调用的开始,亦或是一个可以被丢弃的副本)。这意味着大多数情况下传入包将会被分派给一个需要进程交换的进程。(当然,对于给定不正确的进程标识符的情况,这些进程是有弹性的。)当调用活动初始化一个新得调用时,它会试图使用处理该调用活动的上一个调用的进程标识符作为其目标。这是有益的,因为那个进程很可能正在等待确认上一个调用的结果,而且新的调用包足以确认。调用者使用一个错误的目标进程只会导致轻微的性能下降,所以调用者仅仅会为每个调用进程维护一个简单的目标进程。

        总之,正常的事件顺序如下:一个希望发起调用的进程生成该调用的第一个包,猜测一个可能的合理值作为目标进程标识符并把源设为他自己。然后它将该包呈献给以太网输出设备以等待一个输入包。在被调用者机器上,终端处理器接收到这个包并记下一个对应的服务进程。该服务进程处理该包,然后生成响应包。在这个响应包中的目标进程就是调用者机器上的那个正在等待的进程。当响应包到达调用者机器,调用者机器上的终端处理器直接把它传给调用进程。调用进程现在知道了服务进程的进程标识符,并将之用于该调用随后的包,或者在初始化下一个调用时使用。

        这种方案的效果是在简单调用中不会创建任何进程,而且在每个调用中通常只有四个进程交换。本质上,进程交换最小可能的数量是2(除非我们忙碌等待)——我们引入额外的两个是因为传入包是被一个终端处理器处理而不是由设备的微代码直接分配给正确的进程(因为我们决定不写专门的微代码)。

3.6 其他优化

        上面的讨论展示了我们已经采用的一些优化:我们使用随后的包来隐式确认先前的包,我们试图最小化维护我们连接的成本,我们避免建立和终止链接的花销,我们减少调用中需要交换的进程数量。一些别的细节的优化也有显著的回报。

        我们通过绕过对应于正常协议层级的软件层来传输和接收RPC包。(事实上,我们仅仅在调用者和被调用者处于同一网络时这样做——我们仍然使用互联网层级来路由网络。)这获得了显著地性能增益,但是某种意义上来说是作弊:这是个成功的优化因为只有RPC包能够使用它。也就是说,修改了网络驱动软件以让他将RPC包视作一种特例:如果有10种特例,这将无利可图。然而,我们的目标希望RPC是一种特例:我们有意于让它成为一种主要的通信协议。我们相信这种优化的效用不仅仅是我们对于分层协议层级结构的特殊实现工艺,而是对于一个特定的传输层协议而言,通过绕过完整通用的低层级来显著提升其性能是可行的。

        仍然有我们没有使用的合理的优化:我们在本地网络通信中避免使用网络包的格式,我们可以让简单调用使用特定的包格式,我们可以实现目的的网络微代码,我们可以禁止非RPC通信,甚至我们可以通过忙碌等待保存更多的交换进程。我们避免了这些优化因为某种程度的它们中的每一个都不够方便,而且我们相信我们已经足够有效地实现了我们的目标。使用它们可能会在性能中引入额外的两个因素。

3.7 安全

        我们的RPC包和协议为调用提供了基本的加密安全工具。这些工具使用Grapevine作为验证服务(密匙分发中心)并使用了联邦数据加密标准。为调用者提供了识别被调用者的保证,反之亦然。我们为调用和结果提供了完整的端到端的加密。加密技术可以避免窃听(隐藏数据模式),检测修改,重播或者对调用进行创建。遗憾的是,这里没有足够的空间来的描述我们为支持这种机制而做出的补充和修改。它们将在下一篇论文中报道。

4 性能

        正如我们已经提到的,Nelson的论文包含了对几个RPC协议和实现的广泛分析,还包括了对不同性能特征影响因素的测试。在此我们就不再赘述。

        我们已经对我们RPC包的使用做了如下测量。测量的远程调用发生在两台连接于以太网中的Dorados之间。该以太网的原始数据速率是2.94兆每秒。Dorados运行着Cedar。测量是在与别的用户共享的以太网上进行的,但是该网络(除了我们的测试)负载很轻,大概在总容量的5%到10%。表1中的时间单位都是微秒,通过对Daroda的微处理器循环除以已知的晶体频率以测量。它们精确到10%。【???精确到10%???怎么表述】时间是经过的时间:包括在等待网络所花费的时间以及来自别的设备干扰所花的时间。我们的测量从用户程序调用由server-stub导出到本地的过程开始,直到收到该过程调用的响应为止。这个间隔包括花费在user-stub中的时间,每个机器上RPCRuntime中的时间,和花费在server-stub,以及服务器端过程实现的时间(以及在每个方向上的传输时间)。测试过程全部被导出到单个界面。我们没有使用任何加密设施。

        我们针对每个程序分别测量了12000个调用经过的时间。表1展示了我们观察得到的最小的经过时间,以及平均时间。我们也呈现了每个调用所有包传输的总时间(根据我们协议中使用的已知包的大小计算得出,而不是直接测量)。最后我们展示的是如果用户程序直接绑定了服务程序的情况下做出响应调用经过的时间(例如,发起一个纯粹的本地调用,没有任何RPC包的参与)。纯粹本地调用的时间应该给读者提供Dorado处理器速度和Mesa语言的校准。本地调用的时间也指示了总时间中哪一部分是因为使用RPC而花费的。

        前五个过程分别有0,1,2,4和10个参数,以及0,1,2,4和10个结果,【这儿也觉得翻译的优点不得劲儿】每一个参数或结果的长度都是16位。接下来的五个过程调用都是有一个参数和一个结果,每个参数和结果分别是大小为1,4,10,40,和100个词的数组。底部的第二行显示了一个由引发了异常但是由调用者恢复的过程调用。最后一行是同样由调用者造成异常但是被释放的过程。

        为了在某一个方向上传送数据,除RPC外的其他协议具有优势,因为它们可以在另一方向传送很少的数据。尽管如此,通过使用多个进程进行交叉并行地远程调用,我们在3兆以太网上的Dorado主内存中的数据传输速率达到了2兆每秒。

        我们没有测量导出和导入一个接口的成本。这两项操作的时间主要花在与Grapevine的请求应答上。在定位导出器机器后,调用导出器以确定调度器标识符使用了一个带有一些数据的RPC调用。


RPC(三)《Implementing Remote Procedure Calls》译文_第5张图片
表1. 一些远程调用的性能结果

5 现状和讨论

        我们所描述的包已经完全实现而且在被Cedar的程序员使用。整个RPCRuntime包有四个模块(包交换,包序列化,绑定和安全),源码总共有大约2200行。其实Lupine(stub生成器)更大。客户端可以将RPC用于多个项目,包括用于Alpine(支持多机器事物的文件服务器)的完整通信协议,以及用于给予以太网的电话和声音项目的通信控制。(它也已经用于两款网络游戏,以为在多台机器上的玩家提供实时的通信。)我们所有的客户都发现该包使用方便,尽管这两个项目还没有全面使用。已为BCPL,InterLisp,SmallTalk和C制定了该协议的实现。

        我们仍然处于获取使用RPC经验的早期阶段,确实有更多的工作需要处理。当它被那些正在提交的项目热情使用时,我们对RPC设计的强度和适用性有了更多的信心。【这句有不妥】的确在某些情况下,RPC似乎是错误的通信范式。在这些情况下,基于多波或广播的解决方案似乎更合适。似乎在分布式系统中有时候过程调用(以及我们语言的并行处理和协同设施)不是一个足够强大的工具,虽然在单机中似乎从没有任何这种情况出现。

        我们的愿望之一就是提供一个高性能和低成本的RPC包,它将鼓励从前不可行的新的分布式系统的开发。目前很难去证明我们关于高性能的主张是正确的,因为我们缺少能够证明这种高性能的重要性的演示案例。但是我坚信这种例子将会到来:目前的缺乏是基于这样一种事实,历史上分布式通信一直是不方便且缓慢的。我们已经看到正在被开发的分布式算法并没有被看作一种主要的工程,如果这种趋势继续我们将取得成功。

        我们认为确定的一个问题是,我们RPC目标是否能够通过使用那些实现了适用于RPC策略以及大数据量传输的通用协议实现足够的性能水平。当然,没有完全令人信服的论据认为这是不可能的。但另一方面,我们至今没有看到它实现。

        我们相信这里讨论的RPC包的各部分在几个方面都非常有趣。它们代表了RPC设计范围中一些特定的点。我们相信在没有采取极端措施,也没有牺牲有用的调用和参数语义的情况下,我们已经实现了非常好的性能。管理传输层连接的技术能够最小化通信成本和服务端必须维护的状态,它对我们服务端处理大量用户的实验非常重要。我们的绑定语义十分强大,但是这一概念对一个熟悉单机绑定的程序员而言十分简单。实现它们非常简单且有效。

你可能感兴趣的:(RPC(三)《Implementing Remote Procedure Calls》译文)