文章

RPC

如果要搞分布式,那必然涉及到服务之间的相互调用,即远程过程调用(Remote Procedure Call,PRC)。

远程服务调用

  1. RPC
    1. 本地方法调用
      1. stack
      2. RPC的三大基本问题
    2. RPC:模仿不来
      1. IPC:进程间数据交换
      2. 通信成本
    3. RPC现状
  2. 如果换个思路
  3. JSON-RPC
  4. 感想

RPC

想要rpc,需要哪些步骤?本能的想法,是看看本地方法调用需要哪些步骤,类比一下。因此一开始rpc所追求的目标就是:让计算机能够像调用本地方法一样去调用远程方法。

虽然现在人们已经认清现实,放弃追求“像调用本地方法一样”这个目标了:D

为此,需要先理解本地方法调用的步骤。

今天已经很难找到没有开发和使用过远程服务的程序员了,但是没有正确理解远程服务的程序员却仍比比皆是。

本地方法调用

function/method/procedure,其实都是一个意思。

一个函数调用另外一个函数,需要分析程序运行栈stack。

stack

首先看一下linux内存模型一个linux进程(C program)的内存模型主要包含以下几部分:

linux memory

其中:

  • 代码区:code segment,代表内存里程序的可执行代码(二进制)部分,包含的是可执行指令
  • stack:程序运行栈;

Java程序本质上是openJDK执行的一个c程序,它的进程内存空间同上。其中jvm的heap/stack/code segment也都分别存在于进程空间的相应部分,详见Java内存模型

除此之外,还有几个和程序运行相关的寄存器:在cpu里,能存放一个值。这个值可能代表一个普通数值,也可能代表一个地址:

  • PC:Program Counter,记录下一条要执行的程序指令(在code segment里)的地址
  • SP:Stack Pointer,指向当前程序运行栈的栈顶(在stack)的地址
  • frame pointer:当前栈帧的起始地址。frame pointer到stack pointer之间的部分就是当前函数占用的栈帧;

这是一个画的非常好的栈帧示意图(只不过一般我们画栈帧的时候上面是高地址,下面是低地址,所以栈的增长方向看起来是从上到下的,因此反过来看会更好): linux memory stack

来自文章:Stack Memory: An Overview (Part 3),Neil Fox

由编译原理的课程可知,压栈顺序大概是:

  • 参数逆序(压在父函数栈):以方便访问;
  • return address(压在父函数栈):返回地址(不是返回值!),program counter的值,即调用子函数的那条指令的下一条要执行的指令。当程序返回的时候,要拿到这个值以继续之前的程序执行;
  • previous ebp:上一个栈(父函数)的ebp;
  • 子函数的局部变量(压在子函数栈):用完随栈的销毁而销毁;

所以局部变量太大或者递归层次太多会stack overflow。

为什么要压pc/ebp的值?本质道理是一样的:因为pc/ebp寄存器就一个……发生函数调用的时候,子函数也要用到这些寄存器,如果不把父函数的pc/ebp的值保存下来,被覆盖后就丢了。俗称:保存现场,以恢复上下文。至于保存到子栈帧外还是栈帧内,都行,只要会保存且只保存一次就行。所以x86的cpu规定了哪些寄存器是caller save,哪些是callee save:

  • caller save:进行子函数调用前,由调用者提前保存好这些寄存器的值(保存方法通常是把寄存器的值压入堆栈中),之后在被调用者(子函数)中就可以随意覆盖这些寄存器的值了;
  • callee save:在函数调用时,调用者不必保存这些寄存器的值而直接进行子函数调用,进入子函数后,子函数在覆盖这些寄存器之前,需要先保存这些寄存器的值。即这些寄存器的值是由被调用者来保存和恢复的;

有人干就行,你干了我就不用干了。参考x86-64 下函数调用及栈帧原理

大体上是这么设计的,但是不同的cpu架构设计的并不完全一样。比如我在编译原理的课设上还在子栈帧里压了子函数的返回值。而x86架构的cpu比较壕(贵),寄存器比较多,有一个专门的rax寄存器存放子函数的返回值,栈帧上就不用保存返回值了

rax不用像pc/ebp一样保存在栈帧上,因为:子函数先有返回值,父函数后有返回值,且父函数返回返回值的时候子函数的返回值就没用了,所以rax寄存器不会像其他寄存器一样存在相互覆盖丢数据的情况

当发生函数调用的时候,关键步骤:

  1. 保存上下文
    1. 压PC里的返回地址
    2. 压previous ebp
  2. 创建子栈帧:
    1. 当前esp的值写入ebp(当前的栈顶就是子函数栈的起始位置)
    2. 压子函数局部变量,esp继续增长;

当函数调用返回时,关键步骤:

  1. 恢复上下文
    1. 恢复父栈帧的终点(也就是子栈帧的起点):当前ebp的值写入esp(同时相当于销毁了子栈帧)
    2. 恢复父栈帧起点:弹出previous ebp,写入ebp
    3. 恢复程序执行的流程:弹出previous PC的值,写入pc

RPC的三大基本问题

概括地说,本地调用子函数需要以下几个步骤:

  1. 传参:参数压栈;
  2. 确定要调用的方法:program counter指向被调用方法的地址(在此之前,pc值需要被压栈保存);
  3. 执行方法:为新的函数创建新的栈帧;
  4. 返回结果:销毁子方法的栈帧,返回值压栈;
  5. 恢复上下文,继续执行函数调用后的下一条指令;

这些步骤在同一个进程内都理所当然,但是如果在两个进程内,问题就麻烦很多:

  1. 数据表示:参数和返回值在同一个进程内,有着同一种表示方式。一旦在不同的进程内,可能会:
    1. 两种不同的语言,数据结构不互通;
    2. 不同的硬件指令集;
    3. 不同的操作系统big-endian/little-endian;
  2. 数据传输:参数和返回值怎么从一个进程发给另一个进程;
  3. 方法表示:怎么明确表示调用的是另一个进程里的哪个方法?如果语言不同,大家对方法的表述都不一样,比如java说调用的是public void print(...)方法,python:public是什么意思?

这三个问题,就是RPC要解决的三大基本问题。而这些问题都来自对本地方法调用的模仿

模仿确实是很自然的想法。

RPC:模仿不来

RPC想模仿本地方法调用,最现实的一个问题就是:怎么进行数据传输?

IPC:进程间数据交换

linux进程间通信(Inter-Process Communication,IPC)常用的方法:

  • pipe:常用于传递少量字节流/字符流;
    • 普通管道:拥有亲缘关系的进程(一个进程启动另一个进程)之间的通信。比如ps | grep xxx两个独立的进程,前者把自己的stdout接到后者的stdin上
    • named pipe:任意两个进程;
  • signal:比如kill -9,SIGTERM/SIGKILL等;
  • semaphore:翻译成信号量太容易和signal信号弄混了,不如不翻译了。进程间同步的手段,和线程间同步一样,就是拿一个特殊变量充当锁,在上面进行wait/notify。只不过这个特殊变量是由操作系统提供的
  • message queue:用于进程间大量数据传输。实际写法跟调用kafka/socket的api发送/接收数据差不多,只不过这个queue在linux里
  • shared memory:效率最高的ipc方式。原本进程的内存地址空间相互隔离,通过shared memory主动创建、维护某一块内存。当然一般需要使用信号量控制同步。和多线程读同一块区域类似。
  • UNIX domain socket:又叫IPC socket,仅指本机的socket通信。跨机器走网络的socket(AF_INET)就叫socket,不叫IPC socket(AF_LOCAL)。IPC socket要比socket快很多,因为不经过网络协议栈,不封包拆包、也不用计算校验和、不发送ack;
    • 示例:这个就不举例了,用得太多了;

还有一些很好的示意图

ipcs命令可以查看linux系统的semaphore/message queue/shared memory使用情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Ξ ~ → ipcs -a

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status

------ Semaphore Arrays --------
key        semid      owner      perms      nsems

Ξ ~ → ipcs -l

------ Messages Limits --------
max queues system wide = 32000
max size of message (bytes) = 8192
default max size of queue (bytes) = 16384

------ Shared Memory Limits --------
max number of segments = 4096
max seg size (kbytes) = 18014398509465599
max total shared memory (kbytes) = 18014398509481980
min seg size (bytes) = 1

------ Semaphore Limits --------
max number of arrays = 32000
max semaphores per array = 32000
max semaphores system wide = 1024000000
max ops per semop call = 500
semaphore max value = 32767

通信成本

rpc算不算一种ipc?从概念上看,绝对算。但是rpc的通信成本不像上述ipc方法一样可以被上层使用人员忽略。一旦上层程序员不考虑rpc的通信成本随意调用,对程序来说将会是灾难,因为网络是不可靠的、有延迟的、带宽有限、不安全……

所以:rpc是一种高层次/语言层次的特征,ipc是一种低层次/系统层次的特征,二者不是一个层面的东西。两个不同领域的东西是不存在包含关系的,所以说rpc不是一种ipc!

这一观点和既然有 HTTP 请求,为什么还要用 RPC 调用?是类似的,其认为restful不是rpc的一种,除非以非常宽泛的概念理解rpc。

同样,如果够宽泛,甚至也可以认为restful是一种ipc。这种在概念上的宽泛的理解意义不大,还是把他们看成并列的方案,更能看出他们的区别。

RPC现状

rpc虽然模仿ipc未遂(“像调用本地方法一样”),性能上做不到,但逻辑上还是模仿得来的——只要能搞定这三个问题:

  1. 数据表示(序列化,eg:protobuf);
  2. 数据传输(eg:tcp、http2);
  3. 方法表示(IDL,eg:protobuf);

抓住rpc和本地方法调用的类比关系,对理解rpc的本质很有帮助。

然而想用一个统一的方案搞定这三个问题非常难,没有谁能做到简单、普适、高性能:

  • 想要语言无关,就会麻烦:比如用Interface Description Language(IDL,接口描述语言)以和语言无关的方式描述接口
  • 想要简单,功能就不会齐全:比如用http传输数据,可以少考虑一些底层细节,但肯定不如tcp快;
  • 想要提升效率二进制传输,就要对每一种语言单独支持;
  • 等等;

所以到现在出现了各种类型的rpc框架,分别瞄准不同的目标:

  • 性能:序列化效率、信息密度(传输协议层次越高,信息密度越低)
    • gRPC:使用protocol buffer、http2;
    • thrift:tcp;
  • 面向对象(分布式对象,distribution object):不满足rpc只将面向过程编码方式带到分布式领域,要在分布式系统中进行跨进程的面向对象编程
    • RMI
  • 简单:
    • JSON-RPC:功能最弱、速度最慢的rpc之一。但真的非常简单!而且所有主流语言都支持(其实是主流语言都支持http,hhh);

还有一个趋势:以插件的方式集成rpc的三个基本问题的解决方案,让用户自己权衡。框架本身则多做一些更高层次的功能,比如分布式的负载均衡、服务注册等。比如dubbo。

如果换个思路

分布式一定要使用rpc吗?

仔细想想,rpc的三大问题来自对本地方法调用的模拟。如果换个思路,不模拟“方法调用”,那么看待数据表示、数据传递、方法表示这三个问题时就会有全新的视角——Representational State Transfer,REST!

JSON-RPC

来看看json-rpc究竟有多简单——

简单到specification 2.0甚至都没有几行字。不过依然一本正经地像其他RFC一样用规范化的语言进行specification的表述。

难兄难弟:XML-RPC

用json代表数据,所以一共六种数据类型:JSON can represent

  1. four primitive types (Strings, Numbers, Booleans, and Null)
  2. and two structured types (Objects and Arrays)

比如批量request:

1
2
3
4
5
6
7
8
    [
        {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
        {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]},
        {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"},
        {"foo": "boo"},
        {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"},
        {"jsonrpc": "2.0", "method": "get_data", "id": "9"} 
    ]

批量response:

1
2
3
4
5
6
7
    [
        {"jsonrpc": "2.0", "result": 7, "id": "1"},
        {"jsonrpc": "2.0", "result": 19, "id": "2"},
        {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null},
        {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "5"},
        {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"}
    ]

三大问题的解决方案:

  1. 数据表示:文本。直接在json里写上具体的参数值(params)、返回值(result);
  2. 数据传输:比如http;
  3. 方法表示:文本。直接在json里写上方法名(method);

json-rpc只规定了使用json进行数据表示,并没有限定具体传输协议:

It is transport agnostic in that the concepts can be used within the same process, over sockets, over http, or in many various message passing environments.

所以如果要支持多种协议,实现的代码也不少,比如:jsonrpc4j

感想

什么感觉?就好像我是地球人,但今天才知道这竟然是一个三体的世界!

rpc的本质、换个思路看rpc就能引出另一种方案rest。我灌顶了。

本文由作者按照 CC BY 4.0 进行授权