RPC

118 阅读7分钟

什么是RPC?

RPC出现的最初目的,就是为了让计算机能够跟调用本地方法一样,去调用远程方法。那在调用本地方法时,会发送什么呢?

// 调用者(Caller)      : main()
// 被调用者(Callee)      : println()
// 调用点(Call Site)   : 发生方法调用的指令流位置
// 调用参数(Parameter) : 由Caller传递给Callee的数据,即“hello world”
// 返回值(Retval)      : 由Callee传递给Caller的数据,如果方法正常完成,返回值是void,否则是对应的异常
public static void main(String[] args) {
  System.out.println(“hello world”);
}

通过这段伪代码,你可以发现,程序运行至调用println() 这一行时,计算机(物理机或者虚拟机)会做以下的事情:

  1. 传递方法参数: 将字符串 hello world的引用压栈。
  2. 确定方法版本:根据 println() 方法的签名,确定它的执行版本其实并不是一个简单的过程,不管是编译时的静态解析也好,还是运行时的动态分派也好,程序都必须根据某些语言规范中明确定义的原则,找到明确的被调用者 Callee。这里的“明确”是指唯一的一个 Callee,或者有严格优先级的多个 Callee,比如不同的重载版本。
  3. 执行被调用方法:从栈中获得Parameter,以此为输入,执行Callee的内部逻辑。
  4. 返回执行结果:将Callee的执行结果压栈,并将指令流恢复到Call Site处,继续向下执行。

那么,当println()方法不在当前进程的内存地址空间中,会出现什么问题。至少面临两个直接的障碍:

  • 第一,前面的第一步和第四步所作的传递参数,传回结果都依赖于栈内存的帮助,如果 调用者 和 被调用者分属于不同的进程,就会拥有不同的栈内存,那么在Caller进程的内存中将参数压栈,对于Callee进程的执行毫无意义。
  • 第二, 第二步的方法版本选择依赖于语言规则的定义,而如果Caller 和 Callee不是同一种语言实现的,方法版本选择将是一项模糊的不可知行为。

为了简化,先假设 Caller 与 Callee 是使用同一种语言实现的,先来解决两个进程之间如何交换数据的问题,这件事情在计算机科学中被称为“进程间通讯”(Inter-Process Communication,IPC)。那么我们可以考虑的解决办法就有以下几种:

  1. 管道(Pipe)或具名管道(Named Pipe):
  2. 信号:信号是用来通知目标进程有某种事件发生的。除了用于进程间通信外,信号还可以被进程发送给进程自身。信号的典型应用就是Kill命令,比如“Kill -9 pid”,意思就是由Shell进程向指定PID进程发送SIGKILL信号。
  3. 信号量
  4. 消息队列:消息队列克服了信号承载信息量少,管道只能用于无格式字节流,以及缓冲区大小受限等缺点,但实时性受限。
  5. 共享内存:允许多个进程可以访问同一块内存空间,这是效率最高的进程间通讯形式。
  6. 本地套接字接口(IP Socket):消息队列和共享内存这两种方式,只适合单机多进程间的通讯。而套接字接口,是更为普适的进程间通信机制,可用于不同机器之间的进程通信。

如果只是想能够调用本地方法一样地去调用远程方法,就可以将RPC当作一种特殊的IPC(完全可以使用基于套接字接口的通讯方式),采用一种透明的调用形式,但这是有问题的,远程方法调用是需要考虑网络的通讯成本的!

我们就可以得出 RPC 的定义了:RPC 是一种语言级别的通讯协议,它允许运行于一台计算机上的程序以某种管道作为通讯媒介(即某种传输协议的网络),去调用另外一个地址空间(通常为网络上的另外一台计算机)。

如何选择适合自己的RPC框架?

RPC框架要解决的三个基本问题

所有流行过的RPC框架,都不外乎通过各种手段来解决三个基本问题:

  1. 如何表示数据?
  2. 如何传递数据?
  3. 如何表示方法?

如何表示数据?

这里的数据包括括了传递给方法的参数,以及方法的返回值。无论是将参数传递给另外一个进程,还是从另外一个进程中取回执行结果,都会涉及应该如何表示的问题。 有效的做法就是,将交互双方涉及的数据,转换为某种事先约定好的中立数据流格式来传输,将数据流转换回不同语言中对应的数据类型来使用。 这就是序列化与反序列化。

如何传递数据?

准确来说,传递数据是指如何通过网络,在两个服务Endpoint之间相互操作,交换数据。这里“传递数据”通常指的是应用层协议,实际传输层一般就是基于标准的TCP,UDP等传输层协议来完成的。 两个服务交互不是只扔个序列化数据流来表示参数和结果就行了,诸如异常、超时、安全、认证、授权、事务等信息,都可能存在双方交换信息的需求。在计算机科学中,有一个“Wire Protocol”,用来表示两个EndPoint之间交换数据的行为。

如何表示方法?

这在本地方法调用中不成问题,因为编译器或解释器会根据语言规范,把调用的方法转换为进程地址空间中方法入口位置的指针。 不过,一旦考虑到不同语言,因为每个语言的方法签名都有可能有所差别,所以需要一个统一的标准去“表示这些方法”以及“找到这些方法”,这个标准其实可以很简单:只要给程序中的每个方法,都规定一个通用的又绝对不会重复的编号;在调用时,直接传这个编号就可以找到对应的方法。【DCE最后,弄出了一套与语言无关的接口描述语言(IDL),成为了,此后许多RPC参考或依赖的基础。】

统一的RPC

Web Service 采用了 XML 作为远程过程调用的序列化、接口描述、服务发现等所有编码的载体,当时 XML 是计算机工业最新的银弹,只要是定义为 XML 的东西,几乎就都被认为是好的,风头一时无两,连微软自己都主动宣布放弃 DCOM,迅速转投 Web Service 的怀抱。 当程序员们对 Web Service 的热情迅速燃起,又逐渐冷却之后,也不禁开始反思:那些面向透明的、简单的 RPC 协议,如 DCE/RPC、DCOM、Java RMI,要么依赖于操作系统,要么依赖于特定语言,总有一些先天约束;那些面向通用的、普适的 RPC 协议,如 CORBA,就无法逃过使用复杂性的困扰;而那些意图通过技术手段来屏蔽复杂性的 RPC 协议,如 Web Service,又不免受到性能问题的束缚。

而到了最近几年,RPC 框架有明显朝着更高层次(不仅仅负责调用远程服务,还管理远程服务)与插件化方向发展的趋势,不再选择自己去解决表示数据、传递数据和表示方法这三个问题,而是将全部或者一部分问题设计为扩展点,实现核心能力的可配置,再辅以外围功能,如负载均衡、服务注册、可观察性等方面的支持。这一类框架的代表,有 Facebook 的 Thrift 和阿里的 Dubbo(现在两者都是 Apache 的)。