learn-tech/专栏/周志明的架构课/07_远程服务调用(上):从本地方法到远程方法的桥梁.md
2024-10-16 06:37:41 +08:00

14 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        07 _ 远程服务调用(上):从本地方法到远程方法的桥梁
                        你好,我是周志明。从今天这一讲开始,我们就进入了课程的第二个模块:架构师的视角。

“架构师”这个词,其实指向非常宽泛,你可以说做企业战略设计的是架构师,也可以说做业务流程分析的是架构师。而在这门课程中,我所针对的架构师视角,特指软件系统中技术模型的系统设计者。在这个模块当中,我会带你系统性地了解,在做架构设计的时候,架构师都应该思考哪些问题、可以选择哪些主流的解决方案和行业标准做法,以及这些主流方案都有什么优缺点、会给架构设计带来什么影响,等等。

理解了架构师的这些职责,你对“架构设计”这种听起来就很抽象的工作,是不是有个更具体的认识了?

从今天开始我会花两讲的时间和你一起学习“远程服务调用Remote Procedure CallRPC”这个话题。我会尽可能地从根源到现状、从表现到本质为你解释清楚RPC的一些常见的问题。

那今天我们就先从“什么是RPC”开始一起去学习“远程服务”这个构建分布式系统的最基本的前置条件看看它是如何出现、如何发展的以及当前业界的主流实现手段。

其实RPC这个词儿在计算机科学中已经有超过40年的历史了肯定不是一个新概念。但是直到今天我们还是会在知乎等网站上看到很多人提问“什么是RPC”“如何评价某某RPC技术”“RPC好还是REST好仍然“每天”都有新的不同形状的RPC轮子被发明出来仍然有层出不穷的文章去比对Google gRPC、Facebook Thrift等各个厂家的RPC技术的优劣。

像计算机科学这种知识快速更迭的领域一项40岁高龄的技术能有如此的关注度可以说是相当稀罕的现象了。那为什么会出现这种现象呢

我分析了其中的原因一方面可能是微服务风潮带来的热度另一方面也不得不承认作为开发者我们很多人对RPC本身可以解决什么问题、如何解决这些问题、为什么要这样解决都或多或少存在些认知模糊的情况。

那接下来我就给你详细解读一下关于RPC的各种分歧和普遍的错误认知。

进程间通讯

尽管今天的大多数RPC技术已经不再追求“与本地方法调用一致”这个目标了但不可否认的是RPC出现的最初目的就是为了让计算机能够跟调用本地方法一样去调用远程方法。所以我们先来看一下在本地方法调用的时候都会发生些什么。

我们先通过下面这段Java风格的伪代码来定义几个概念

// 调用者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()这一行的时候,计算机(物理机或者虚拟机)会做以下这些事情:

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

接下来我们就需要考虑一下当println()方法不在当前进程的内存地址空间中,会出现什么问题。不难想到,此时至少面临两个直接的障碍:

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

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

第一管道Pipe或具名管道Named Pipe

管道其实类似于两个进程间的桥梁,用于进程间传递少量的字符流或字节流。普通管道可用于有亲缘关系进程间的通信(由一个进程启动的另外一个进程);而具名管道摆脱了普通管道没有名字的限制,除了具有普通管道所具有的功能以外,它还允许无亲缘关系进程间的通信。

管道典型的应用就是命令行中的“ | ”操作符比如说命令“ps -ef | grep java” ,就是管道操作符“ | ”将ps命令的标准输出通过管道连接到grep命令的标准输入上。

第二信号Signal

信号是用来通知目标进程有某种事件发生的。除了用于进程间通信外信号还可以被进程发送给进程自身。信号的典型应用是kill命令比如“kill -9 pid”意思就是由Shell进程向指定PID的进程发送SIGKILL信号。

第三信号量Semaphore

信号量是用于两个进程之间同步协作的手段相当于操作系统提供的一个特殊变量。我们可以在信号量上进行wait()和notify()操作。

第四消息队列Message Queue

前面所说的这三种方式只适合传递少量信息而POSIX标准中有定义“消息队列”用于进程间通讯的方法。也就是说进程可以向队列中添加消息而被赋予读权限的进程则可以从队列中消费消息。消息队列就克服了信号承载信息量少、管道只能用于无格式字节流以及缓冲区大小受限等缺点 ,但实时性相对受限。

第五共享内存Shared Memory

允许多个进程可以访问同一块内存空间,这是效率最高的进程间通讯形式。进程的内存地址空间是独立隔离的,但操作系统提供了让进程主动创建、映射、分离、控制某一块内存的接口。由于内存是多进程共享的,所以往往会与其它通信机制,如信号量等结合使用,来达到进程间的同步及互斥。

第六本地套接字接口IPC Socket

消息队列和共享内存这两种方式,只适合单机多进程间的通讯。而套接字接口,是更为普适的进程间通信机制,可用于不同机器之间的进程通信。

套接字Socket起初是由Unix系统的BSD分支开发出来的但现在已经移植到所有的Unix和Linux系统上了。基于效率考虑当仅限于本机进程间通讯的时候套接字接口是被优化过的不会经过网络协议栈不需要打包拆包、计算校验和、维护序号和应答等操作只是简单地将应用层数据从一个进程拷贝到另一个进程这种进程间通讯方式有个专有的名称Unix Domain Socket又叫做IPC Socket。

通信的成本

我之所以花这么多篇幅来介绍IPC的手段是因为计算机科学家们最初的想法就是将RPC作为IPC的一种特例来看待其实现在分类上这么说也仍然合适只是在具体操作手段上不会这么做了

这里我们需要特别关注的是最后一种基于套接字接口的通讯方式IPC Socket。因为它不仅适用于本地相同机器的不同进程间通讯而且因为Socket是网络栈的统一接口它也理所当然地能支持基于网络的跨机器、跨进程的通讯。比如Linux系统的图形化界面中X Window服务器和GUI程序之间的交互就是由这套机制来实现的。

此外这样做还有一个看起来无比诱人的好处。因为IPC Socket是操作系统提供的标准接口所以它完全有可能把远程方法调用的通讯细节隐藏在操作系统底层从应用层面上来看可以做到远程调用与本地方法调用几乎完全一致。

事实上,在原始分布式时代的初期确实是奔着这个目标去做的,但这种透明的调用形式反而让程序员们误以为通信是无成本的,从而被滥用,以至于显著降低了分布式系统的性能。

1987年当“透明的RPC调用”一度成为主流范式的时候安德鲁 · 塔能鲍姆Andrew Tanenbaum教授曾发表了一篇论文“A Critique of the Remote Procedure Call Paradigm”对这种透明的RPC范式提出了一系列质问

两个进程通讯,谁作为服务端,谁作为客户端? 怎样进行异常处理?异常该如何让调用者获知? 服务端出现多线程竞争之后怎么办? 如何提高网络利用的效率,比如连接是否可被多个请求复用以减少开销?是否支持多播? 参数、返回值如何表示?应该有怎样的字节序? 如何保证网络的可靠性,比如调用期间某个链接忽然断开了怎么办? 服务端发送请求后,收不到回复该怎么办? ……

论文的中心观点是:把本地调用与远程调用当作一样的来处理,是犯了方向性的错误,把系统间的调用做成透明的,反而会增加程序员工作的复杂度。

此后几年关于RPC应该如何发展、如何实现的论文层出不穷有支持的也有反对有冷静分析的也有狂热唾骂的但历史逐渐证明了Andrew Tanenbaum的预言是正确的。

最终1994年至1997年间由ACM和Sun的院士Peter Deutsch、套接字接口发明者Bill Joy、Java之父James Gosling等众多在Sun Microsystems工作的大佬们共同总结了通过网络进行分布式运算的八宗罪8 Fallacies of Distributed Computing

网络是可靠的The network is reliable 延迟是不存在的Latency is zero 带宽是无限的Bandwidth is infinite 网络是安全的The network is secure 拓扑结构是一成不变的Topology doesnt change 总会有一个管理员There is one administrator 不考虑传输成本Transport cost is zero 网络是同质化的The network is homogeneous

这八宗罪被认为是程序员在网络编程中经常忽略的八大问题潜台词就是如果远程服务调用要弄透明化的话就必须为这些罪过买单。这算是给RPC能否等同于IPC来实现暂时定下了一个具有公信力的结论。

到这时为止RPC应该是一种高层次的或者说语言层次的特征而不是像IPC那样是低层次的或者说系统层次的特征就成为了工业界、学术界的主流观点。

在1980年代初期传奇的施乐Palo Alto研究中心发布了基于Cedar语言的RPC框架Lupine并实现了世界上第一个基于RPC的商业应用Courier。这里施乐PARC定义的“远程服务调用”的概念就是符合上面针对RPC的结论的。所以尽管此前已经有用其他名词指代RPC的操作我们也一般认为RPC的概念最早是由施乐公司所提出的。

首次提出远程服务调用的定义- Remote procedure call is the synchronous language-level transfer of control between programs in address spaces whose primary communication is a narrow channel.- —— Bruce Jay NelsonRemote Procedure CallXerox PARC1981

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

小结

这一讲我们讨论了RPC的起源、概念以及它发展上的一些分歧。以此为基础我们才能更好地理解后面几讲要学习的内容包括RPC本身要解决的三大问题、RPC框架的现状与发展以及它与REST的区别。

RPC以模拟进程间方法调用为起点许多思想和概念都借鉴的是IPC因此这一讲我也介绍了IPC中的一些关键概念和实现方法。但是RPC原本想照着IPC的发展思路却在实现层面上遇到了很大的困难。RPC作为一种跨网络的通讯手段能否无视通讯的成本去迁就编程和设计的原则这一点从几十年前的DCE开始直到今天学术界、工业界都还有争议。

在下一讲我会和你一起学习在RPC的定义提出之后工业界中出现过的、著名的RPC协议以及当今常用的各种RPC框架学习它们的共性也就是它们都必须解决哪几个问题各自以什么为关注点以及为何不会出现“完美的”RPC框架。

一课一思

“远程方法不应该无视通讯成本”这个观点从性能的角度来看是有益的但从简单的角度看则是有害的。在现代的软件系统开发中你用过什么RPC框架吗它们有没有把“像本地方法一样调用远程方法”作为卖点

欢迎在留言区分享你的答案。如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

好,感谢你的阅读,我们下一讲再见。