远程过程调用RPC(一)
Introduction, what's wrong with sockets?
Socket是Client-Server(C/S模式)网络通信的基石,它提供了一套相对简单的机制,让一个程序可以与另外一个本地或者远程的程序来建立连接,从而实现消息的发送跟接收。但正是这样的一组接口,确使得我们必须采用一种与非分布式截然不同的方式来设计分布式系统---我们必须使用Read/Write(Input/Output)的接口方式来开发分布式应用。每次我们跟远程进程进行通信的时候,都必须要编写一堆与网络通信相关的代码,这给我们的开发带了额外的工作,同时也增加了开发跟维护的难度。
1984年,Birrell跟Nelson提出了一种允许在不同机器之间的进行过程调用的机制:位于某机器A上的进程A可以调用另一个位于某机器B上面的进程B。当进程A在调用进程B的时候,进程A就会被挂起而进程B则继续工作;当调用结果从进程B返回给进程A的时候,进程A就会被唤醒并继续工作。而这一切,对于程序员而言都是透明的,就好像操作是发生在本地一样。这样的一种调用机制被称之为远程过程调用-Remote Procedure Call,简称RPC。
Steps in a remote procedure call
撇除编译器跟处理器体系结构的差异,让我们来从总体上重温一下本地过程调用是如何实现的吧:
每一个处理器(Processor)都为我们提供了某些形式的调用指令,用于把下一条操作的指令压入堆栈,并把进程的控制权转交给调用指定的地址空间。当调用完成之后,处理器就会触发一条返回(Return)指令,从堆栈当中弹出地址并把进程的控制权转交至对应的地址空间。这就是处理器最基本的工作机制,使我们可以很方便的实现过程调用。关于具体如何来确认参数,如何在堆栈当中存放这些参数,以及如何来执行某一条调用指令,这取决于具体的编译器。在函数被调用的过程当中,编译器的职责就在于确保寄存器的状态被及时保存,为本地变量分配堆栈地址空间,并在函数返回之前恢复寄存器的状态跟堆栈的空间。
但是,当我们进行远程过程调用的时候,情况就变得复杂起来了。以上所提及的关于本地过程调用实现的细节,对于RPC而言完全不适用。编译器必须要作出适当的策略调整,以提供远程过程实现的能力。接下来就让我们利用我们目前所掌握的知识跟工具:本地过程调用跟Socket网络通信,来模拟远程过程调用的实现。
使用远程过程调用机制的难点就在于创建客户端存根函数,使其在用户的角度看来,调用像是从本地触发的一样。在客户端,存根函数是一个用户最终会调用的一个包含了在网络中收发消息的函数功能。具体的操作执行顺序如下图1所示:
图1:远程方法调用过程
1. 客户端调用一个被称之为客户端存根函数的本地过程:从客户端的角度出发,调用客户端存根函数的过程跟调用其它的本地过程无异,本质上都是一个常规的本地过程调用。但是其具体的实现细节确与本地过程调用有着很大的差异。存根函数是远程调用在客户端的一个代理,真正的实现是在远程的服务器端。当客户端调用存根函数的时候,存根函数会将调用的参数,通过编码或者是序列化的方法把他们转换成为一组适合于在网络中传输的二进制字节数组的格式,发送给远程的服务器进程;
2. 存根函数通过本地操作系统的Socket接口,把打包的消息发送给远程系统;
3. 消息通过一定的网络通信协议,从本地系统传送给远程操作系统(无连接状态或有连接状态的传输协议);
4. 服务器通过一个叫做服务器存根函数(也叫Skeleton函数)接收来自于客户端的请求消息。当接收到消息之后,服务器存根函数将会解码或者是反序列化调用参数,把它们转换成适合于系统处理的数据格式;
5. 服务器存根函数调用服务器上的具体实现函数,并传入从客户端请求中接收到的参数;
6. 当服务器函数处理完毕之后,会把最终的处理结果返回给服务器端存根函数;
7. 服务端存根函数将返回结果通过编码或序列化的方法,把它们转换成为适合于在网络中传输的格式,从服务器端发送回客户端;
8. 消息通过网络传输回客户端;
9. 客户存根函数从本地系统获得返回的远程过程处理结果;
10. 客户端存根函数把获得的远程调用结果返回给客户端本地调用。
客户端至此,继续往下执行相应的操作。
远程过程调用主要有以下两方面的优点:
开发人员可以直接利用过程调用语义来调用远程函数并获得处理结果;
简化编写分布式应用的过程,因为RPC为我们隐藏了所有的网络通信细节,应用程序不必再去考虑,担心诸如Socket,端口号,数据格式的转化等问题。
Implementing remote procedure calls
当我们尝试实现远程过程调用的时候,我们必须考虑以下的问题。
How do you pass parameters?
参数传递有两种形式:一种是按值传递,另外一种是按引用传递。按值传递参数的情况相对比较简单,我们只需要把值复制到网络消息中即可;按引用传递的情况就变得复杂的多,单纯的传递引用的地址空间毫无意义,因为在远程系统当中,这一地址空间指向的可能是另外一个完全不同的事物。因此,要做到支持传递引用参数,我们需要向网络中来发送参数的副本,把它们存放在远程系统的内存中,并向他们传递一个指向远程系统调用函数的指针,然后向客户端返回该对象并复制其引用。如若需要在远程系统调用中传递复杂的数据结构,比方说树结构或者是链表结构,那么我们就需要首先把这个复杂的结构转化成为一种无指针形式的数据表示,例如说扁平树的结构,然后通过网络传输,在远程系统端重构数据。
How do we represent data?
在本地操作系统当中,由于数据的表现形式总是一样,因此不存在数据格式不兼容的问题,但是在RPC的背景之下则是另外一番场景。比如在不同机器上,数据的字节顺序可能不一样,整数类型所占用的数据空间大小跟表示的数值范围可能不同,浮点数的表现形式可能存在差异等等。
操作系统,在存储数据的时候可以选择大端存储或者小端存储的形式。大端存储,即低位地址上存放高位字节的数据,而高位地址上存放低位字节的数据;小端存储则反之,低位地址上存放低位字节数据,高位地址上存放高位字节数据。许多老式的处理器,例如Sun SPARCs跟Motorola 680x0s使用的是大端数据存储形式,而绝大多数的Intel处理器采用的则是小端数据存储的形式。另外也有许多其他的处理器架构同时支持两种形式的数据存储,我们只需要在引导加载时配置其具体支持的形式,这类的处理器有ARM,MIPS,PowerPC,SPARC v9和Intel IA-64。
以上的这个问题,在IP协议当中得到了妥善的解决,它强制所有的16位跟32位的头部信息都采用大端数据表示法。而对于RPC而言,我们需要找出一个标准,对所有的数据进行编码并使其可以在异构系统当中进行通信。ONC PRC,采用了一种称之为XDR(eXternalDataRepresentation)的数据格式来进行编码,这种数据表示法分为隐式/显式类型表示法。所谓隐式类型表示法,是指在数据传输过程当中,我们只需要传递数据的值,而无需传递变量的类型或名称。ONC RPC的XDR和DCE的NDR就是采用这种隐式数据类型表现形式的例子。而显示类型表示法,除了传递数据的值,我们还需要同时传递变量的名称或类型,常见的例子有ISO标准ASN .1(Abstract Syntax Notation),JSON(JavaSript Object Notation), Google Protocol Buffers以及各种基于XML的数据表示方法。
What machine and port should we bind to?
在进行远程过程调用的时候,我们需要定位出提供远程服务的系统位置以及在该系统上的某一进程(包括了端口号以及传输地址)。一种相对来说比较简单但并不优雅的解决方案就是,在我们发送远程调用的时候,客户端必须事先知道这个远程服务的地址跟端口号;另一种解决方案,是由Birell和Nelson在1984年的一篇关于介绍RPC的论文当中提出的,维护一个集中式的数据库,并由该数据库来提供定位远程服务的主机信息。客户端需要首先通过该数据库,获得提供远程服务的信息。
What transport protocol should be used?
某些实现仅仅支持单一的传输协议,例如TCP协议;但是,绝大多数的实现提供了多种协议支持供用户进行选择。
What happens when things go wrong?
远程过程调用,我们将会遇到更多可能的错误:服务器运行过程中可能会发生错误,网络传输过程可能出现问题, 服务器奔溃或者是当服务端代码运行期间客户端断开了连接等等。远程过程调用的透明性就此中断,因为在本地过程调用中没有过程调用失败的概念。因此,当我们使用远程过程调用的时候,我们必须随时准备测试或者捕获异常。
What about performance?
一个常规的过程调用是很快的,通常只涉及几条指令周期。那远程过程调用的性能又如何呢?
让我们仔细回顾一下远程过程调用所涉及的步骤:
客户端调用存根函数获取服务端返回结果;(仅此操作便会产生过程调用的开销)
调用执行程序对调用参数进行编码;
调用操作系统的网络接口与远程系统进行通信;(涉及网络延迟等网络开销)
服务器获取客户端的请求消息并把它传递给服务器进程;
调用服务端执行程序对参数进行解码,并调用相关的函数进行处理;
返回服务器处理结果并按原路返回。(返回的过程又是一轮消耗)
综上所述,毫无疑问,远程过程调用的处理比本地过程调用要慢的多得多。可以这么说,一个远程过程调用所耗费的时间基本上是一个本地过程调用所花费时间的上千倍。尽管如此,这并不能成为阻止我们使用远程过程调用的原因,与其提供给我们的功能和优势相比较,性能方面的消耗是可以接受的。
What about security?
这是一个我们绕不开的话题。在本地过程调用中,所有的函数调用都是在同一个进程当中,操作系统通过per-process内存映射来确保某一进程拥有足够的内存保护,因此其他的进程就不能够窥探跟操作另一进程的函数功能。但对RPC而言,我们需要考虑不同的安全问题:
客户端是否向正确的远程进程发送消息,还是发送给了一个冒名顶替的进程?
客户端是否向正确的远程主机发送消息,还是消息发送给了一个冒名顶替的主机?
服务器是否只接受合法客户的消息?服务器能在客户端识别用户吗?
在网络传输过程当中,消息会被其他的进程嗅探吗?
客户端到服务器,或者服务器到客户端,消息在网络传输的过程当中会被其他的进程拦截并修改吗?
协议是否受到重播攻击?也就是说,一个恶意的主机可以捕获一个消息,然后在稍后的时间重新发送它吗?
消息在网络传输的过程当中是否被故意损毁或者截断。
Programming with remote procedure calls
当今许多主流的编程语言(C,C++,Python,Scheme等)并没有为远程过程调用设计内置的语义,因此无法直接生成必要的存根函数。为了能够实现远程过程调用,一个通常的做法就是为其提供一个单独的编译器用以生成存根函数。这个编译器从一个程序指定的远程过程调用接口的定义(用接口定义语言IDL编写)中获取它的输入。
接口定义通常与函数原型声明类似:它列举了一系列包含了输入和返回参数的函数。当RPC运行编译器后,客户端和服务器程序便会被编译并与适当的存根函数相关联(如图2所示)。因此客户端程序需要作出适当的修改,以初始化RPC机制(如定位服务器和建立连接)并处理远程过程调用的异常。
图2:远程过程代码
Advantages of RPC
你无需担心如何获取一个唯一的传输地址(为主机Socket来分配一个唯一的端口号)。服务器可以绑定到任何可用的端口并使用RPC名称服务来注册该端口。客户端将会通过这个名称服务来获取与所需程序相关联的端口。而这一过程,对于程序员来说,都是透明的;
系统可以独立于传输提供者。自动生成的服务器存根函数可以对系统上的任何传输提供者可用(TCP和UDP)。客户端可以动态选择而无需额外编程,因为发送和接收消息的代码是自动生成的;
客户端应用程序只需知道一个传输地址,而名称服务器将负责告诉应用程序去哪里连接给定的服务器函数集;
使用函数的调用模型来替代sockets的send/receive(read/write)接口,用户无需处理参数的编解码。
RPC API
任何的RPC实现都必须提供一组支持库,包括:
Name Service Operations / 名称服务操作
注册和查询绑定信息(包括主机跟端口)。允许应用使用操作系统分配的动态端口。
Binding Operations / 绑定操作
使用合适的通信协议建立客户机/服务器通信(建立通信端点)。
Internationalization Operations / 国际化操作
在极少数情况之下,一个RPC数据包的某一部分可能包含了某些时间格式转化,货币符号转化,以及一些特定于语言的字符串转换函数。
Marshaling / Data Conversion Operations / 编码 / 数据转换操作
把数据序列化成为扁平的字节数组以便于在网络中进行传输,并能够在网络的另一端重建数据。
Stub Memory Management and Garbage Collection / 存根内存管理以及垃圾回收
存根函数需要分配一定的内存空间用以存储参数变量,特别是在模拟传递引用语义的情况之下。RPC包需要分配和清除任何此类操作所分配的地址空间。它们还可能需要分配内存来创建网络缓冲区。对于支持对象的RPC包,RPC系统还需要一种方法来跟踪远程客户端是否仍然具有对对象的引用,以此判断是否可以删除该对象。
Program ID Operaions / 程序标识操作
允许应用程序访问RPC接口的标识或者是句柄,这样服务器所提供的这些接口就可以被使用并用于网络的通信。
Object and Function ID Operations / 对象和函数ID操作
允许将远程函数或远程对象的引用传递给其他进程。并不是所有的 RPC 系统都支持。
下一篇将继续,介绍第一代的RPC系统。
我要小额赞助,鼓励作者写出更好的文章!
领取专属 10元无门槛券
私享最新 技术干货