凤凰架构-访问远程服务


1. 访问远程服务概述

远程服务将计算机程序的工作范围从单机扩展到网络,从本地延伸到远程,它是构建分布式系统的首要基础

但是远程服务并不仅仅只是为了分布式系统服务的,更是在当前时代下,浏览器,移动设备,桌面应用和服务端的程序之间相互交互的一个基础。

访问远程服务是理解分布式系统如何运行的基础,需要重点关注以下三个问题:

  • RPC本身解决了什么问题?(是什么?)
  • RPC怎么解决的这些问题?(怎么做?)
  • RPC为什么这样解决问题?(为什么?)

2. 进程间通信

RPC出现的最初目的:就是为了让计算机能够跟调用本地方法一样去调用远程方法,因此我们先从这个角度出发,先理解一下计算机是如何调用本地方法的?

// Caller: 调用者,如下代码中的调用者函数main()
// Callee: 被调用者,如下代码中,System.out.println
// Call Site: 调用点m也就是发生方法调用的指令流位置
// Parameter: 参数,由调用者传递给被调用者的数据,也就是字符串"hello world!"
// Retval: 返回值
public static void main(String[] args) {
    System.out.println("hello world!");
}

本地方法的执行流程如下:

  1. 传递方法参数:保存方法返回地址,将字符串hello world字符串引用地址压入虚拟机栈
  2. 确定方法调用:根据println()方法的签名,从方法区中找出对应的函数入口地址(此过程必须找到明确的Callee),然后基于此地址执行调用,修改程序计数器的值
  3. 执行被调方法:从栈中弹出Parameter的值或者引用,并且以此为输入,执行Callee内部的逻辑
  4. 返回执行结果:将执行结果压栈,恢复程序计数器的值

现在转换视角,假设println()是一个远程方法调用,那么以上四步会产生什么差异呢?

第一步,说将hello world压入栈中,这个字符串要被caller和callee同时引用,而在分布式环境中,那么也就意味着这个字符串要同时被两个进程所感知,解决方法可以是本地进程之间的通信,也可以是Socket远程通信,总之需要让两个进程共同感知到这个数据所在**(远程通信交互技术)**

第二步,如何定位方法?这恐怕是RPC调用中最难的一个问题,首先要定位到一个进程,这就涉及到网络寻址之类的问题**(注册中心、上下线感知)**

第三步,当参数传递过去后,由远程进程中的代理服务将参数传递到具体的方法,这一步实际上和本地调用方法一样简单

第四步,当产生了Retval之后,此时也由代理服务将Retval原路返回

以上就是一个远程方法调用的一个基本雏形,根据以上讨论的结果,我们还可以将方法调用划分为以下三种:

(1)本机本进程方法调用

(2)本机多进程方法调用

(3)多机多进程方法调用

以下来讨论一下(2)

本机多进程方法调用,基本上需要操作系统的干预,因此这也是操作系统八股中的老股,回顾一下:

管道通信:管道一般来说有两种,一种是匿名管道,匿名管道是基于fork()系统调用实现的,一般来说我们在Linux环境下输入这个指令:

ps -ef | grep java

首先Shell它本身是一个进程,负责和用户进行交互等工作

然后当它执行了ps -ef的时候,这时候就启动了一个ps进程,这个ps进程和Shell进程属于一个父子进程的关系

当它执行了grep java的时候,这时候grep去接收ps -ef输出的结果,并用自身的处理函数过滤出那些含有java的关键字

在这个过程中,很关键的一步是后面的grep接收了前面指令的输入,具体的原理是:由于父子进程之间共享同一套文件描述符,而|管道描述符本质上是在内核中创建了一段缓存,这一段缓存是匿名的,只有Shell进程及其子进程可见,因此ps和grep进程就可以看到这一段缓存,然后就可以向里面读写数据

还有一种管道是命名管道,命名管道可以通过命令手动创建,然后通过输入流和输出流进行数据的读写

mkfifo cache
ps -ef > cache
cache > my.txt

关于命名管道和匿名管道的区别,主要在于匿名管道仅有血缘关系的那些进程可以访问,而命名管道的访问进程,是可以没有血缘关系的

命名管道的开销更小,更加灵活

信号:信号用于通知目标进程有某种事件发生,除了用于进程间的通信之外,还可以发送信号给自身,这种就类似于消息流转模型

这里也简单讲一下信号的基本原理,信号要起作用,首先有两个关键点:

  • 系统开中断,也就是在CPU运行的过程中允许发生中断
  • 对于对应的信号,目标进程注册了信号处理函数

实际上,当发起一个信号的时候,这时候同步地会发起一个中断,首先会从中断向量中找到这个中断对应的处理函数

然后会找到信号接收进程中对于此信号对应的信号处理函数,并且执行调用,待处理完毕后,返回原进程

因此可以看到,信号这种机制本质上还是系统调用,有一定的开销

信号量:信号量是用于进程之间同步的工具,相当于操作系统提供一个特殊变量,来管理进程的阻塞和唤醒状态,程序基于信号量的机制,可以实现阻塞等待-唤醒机制,它的底层有两个关键数据结构

struct Semaphore {
  	int count; // 用于记录信号量的值,表示当前资源可用的个数
    Queue queue;// 关于此信号量的阻塞队列 
};

信号的作用原理:

当使用acquire()这个API的时候,如果当前的count <= 0,那么这时候就会将当前进程阻塞掉,加入到阻塞队列中,如果当前的count > 0,那么这时候进程不会阻塞,获取资源后运行

当使用release()这个API的时候,如果当前的count < 0,那么这时候就会唤醒queue中阻塞的进程,当count >= 0的时候,这时候会让信号量中的值单纯+1

当使用信号量的时候,会用配套的API,而我们知道这个信号量内部的count它也是临界资源,因此在这样的情况下,使用配套的API的使用会使得操作系统陷入关中断的状态,修改这个状态需要陷入内核态,因此也是具备一定的开销的

消息队列:消息队列是存储在操作系统内核中的数据结构,可用于进程间的数据交换,可以理解成操作系统内部维护了一条链表,用户可以从表头上取数据,可以从表尾中插数据,但是由于操作的是内核中的数据,因此就涉及到了系统调用,都具备一定的开销

共享内存:共享内存的原理,本质上就是说将进程的虚拟地址空间映射到的物理空间设置为同一块,这样两个进程就能够同时访问同一块内存了

套接字接口:基于操作系统协议栈实现的网络传输接口,当使用多机间进程传输时,需要基于操作系统网络协议栈,而当是访问本机的时候,这时候的原理是这样的:在操作系统内核中有一条链表,一个进程向上push数据,另一个进程从上面pull数据,并不需要经过操作网络协议栈进行数据包的封装

3. RPC的基本问题

远程服务调用的定义:是指互不重合的内存地址中的两个程序,在语言层面上,以同步的方式使用带宽有限的信道来传输程序控制信息

如何表示数据:这里的数据包含了有传递给方法的参数,以及方法执行后的返回值

在本进程中传递数据的时候,由于数据在本进程中具有程序语言预置和程序员手动定义的数据类型,因此不设计到编码和解码的问题

但是在多进程中传递数据的时候,由于数据的传输可能是字节流或者是字符流,因此如何来编码和解码,定义出一套规范来解决两者对于一段数据的唯一性读取(也就是能够将对方的信息原原本本地翻译回来)。

一个比较好的解决方案是:将交互双方所涉及到的数据转换为事先约定好的中立数据流格式进行传输,这种中立数据流,有一套规则,通过这一套规则,可以将中立数据流转换成不同版本的数据流,比如说,Java语言产生的数据流,在传输前转换为proto格式的数据流,到达接收端的时候,proto格式的数据流,转换为python语言可以识别的数据流。但是可见,这一套逻辑依赖了中立数据流的编码和解码,一旦编码解码规则是有问题的,后续一切都会产生问题

如何传递数据:如何通过网络,在两个网络的EndPoints之间相互操作、交换数据,两个服务之间的交互不只是扔个序列化数据流就能够完成工作的,在这之外,还有异常、超时、安全、认证、授权、事务等相关处理,这里相关处理一样会产生大量的数据

如何确定方法:简单来说,就比如说两个进程不同的方法,要怎么定位到对方?

一种比较简单的方案,类似于HTTP2中的HPACK算法,在双方共同维护一本字典,在比如说caller需要调用callee的call()方法,那么我就在这本字典上记录一个键值对:1111=>callee.call()

当需要调用方法的时候,这时候直接传输一个1111,就知道你想要调用callee.call()这个方法

目前比较高级的方案也有,比如说IDL方法,通过IDL方案就可以写出一个接口无关的描述语言,这个东西和上面说到的那个中立数据流类似,本质上都是将一套语言翻来覆去地翻译,达到两套语言语义一致的效果罢了

4. RPC多样设计

常见的框架:DubbogrpcThrift

  • 面向对象发展,希望在分布式系统中也能够进行跨进程的面向对象编程
  • 朝着性能发展,代表为 gRPC 和 Thrift。决定 RPC 性能的主要就两个因素:序列化效率和信息密度。序列化效率很好理解,序列化输出结果的容量越小,速度越快,效率自然越高;信息密度则取决于协议中有效荷载(Payload)所占总传输数据的比例大小,使用传输协议的层次越高,信息密度就越低,SOAP 使用 XML 拙劣的性能表现就是前车之鉴。gRPC 和 Thrift 都有自己优秀的专有序列化器,而传输协议方面,gRPC 是基于 HTTP/2 的,支持多路复用和 Header 压缩,Thrift 则直接基于传输层的 TCP 协议来实现,省去了额外应用层协议的开销。
  • 朝着简化发展,代表为 JSON-RPC,说要选功能最强、速度最快的 RPC 可能会很有争议,但选功能弱的、速度慢的,JSON-RPC 肯定会候选人中之一。牺牲了功能和效率,换来的是协议的简单轻便,接口与格式都更为通用,尤其适合用于 Web 浏览器这类一般不会有额外协议支持、额外客户端支持的应用场合。

RPC 框架有明显的朝着更高层次(不仅仅负责调用远程服务,还管理远程服务)与插件化方向发展的趋势,不再追求独立地解决 RPC 的全部三个问题(表示数据、传递数据、表示方法),而是将一部分功能设计成扩展点,让用户自己去选择。框架聚焦于提供核心的、更高层次的能力,譬如提供负载均衡、服务注册、可观察性等方面的支持。


文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录