2.1.1 进程间通信
尽管今天的大多数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这行时,计算机(物理机或者虚拟机)要完成以下几项工作。
1)传递方法参数:将字符串hello world的引用地址压栈。
2)确定方法版本:根据println()方法的签名,确定其执行版本。这其实并不是一个简单的过程,无论是编译时静态解析,还是运行时动态分派,都必须根据某些语言规范中明确定义的原则,找到明确的Callee,“明确”是指唯一的一个Callee,或者有严格优先级的多个Callee,譬如不同的重载版本。笔者曾在《深入理解Java虚拟机》的第8章介绍该过程,有兴趣的读者可以参考,这里不再赘述。
3)执行被调方法:从栈中弹出Parameter的值或引用,并以此为输入,执行Callee内部的逻辑。这里我们只关心方法是如何调用的,而不关心方法内部具体是如何执行的。
4)返回执行结果:将Callee的执行结果压栈,并将程序的指令流恢复到Call Site的下一条指令,继续向下执行。
我们再来考虑如果println()方法不在当前进程的内存地址空间中会发生什么问题。不难想到,这样会至少面临两个直接的障碍。首先,第一步和第四步所做的传递参数、传回结果都依赖于栈内存,如果Caller与Callee分属不同的进程,就不会拥有相同的栈内存,此时将参数在Caller进程的内存中压栈,对于Callee进程的执行毫无意义。其次,第二步的方法版本选择依赖于语言规则,如果Caller与Callee不是同一种语言实现的程序,方法版本选择就将是一项模糊的不可知行为。
为了简化讨论,我们暂时忽略第二个障碍,假设Caller与Callee是使用同一种语言实现的,先来解决两个进程之间如何交换数据的问题,这件事情在计算机科学中被称为“进程间通信”(Inter-Process Communication,IPC)。可以考虑的解决办法有以下几种。
·管道(Pipe)或者具名管道(Named Pipe):管道类似于两个进程间的桥梁,可通过管道在进程间传递少量的字符流或字节流。普通管道只用于有亲缘关系的进程(由一个进程启动的另外一个进程)间的通信,具名管道摆脱了普通管道没有名字的限制,除具有管道的所有功能外,它还允许无亲缘关系的进程间的通信。管道典型的应用就是命令行中的“|”操作符,譬如:
ps -ef | grep java
ps与grep都有独立的进程,以上命令就是通过管道操作符“|”将ps命令的标准输出连接到grep命令的标准输入上。
·信号(Signal):信号用于通知目标进程有某种事件发生。除了进程间通信外,进程还可以给进程自身发送信号。信号的典型应用是kill命令,譬如:
kill -9 pid
以上命令即表示由Shell进程向指定PID的进程发送SIGKILL信号。
·信号量(Semaphore):信号量用于在两个进程之间同步协作手段,它相当于操作系统提供的一个特殊变量,程序可以在上面进行wait()和notify()操作。
·消息队列(Message Queue):以上三种方式只适合传递少量消息,POSIX标准中定义了可用于进程间数据量较多的通信的消息队列。进程可以向队列添加消息,被赋予读权限的进程还可以从队列消费消息。消息队列克服了信号承载信息量少、管道只能用于无格式字节流以及缓冲区大小受限等缺点,但实时性相对受限。
·共享内存(Shared Memory):允许多个进程访问同一块公共内存空间,这是效率最高的进程间通信形式。原本每个进程的内存地址空间都是相互隔离的,但操作系统提供了让进程主动创建、映射、分离、控制某一块内存的程序接口。当一块内存被多进程共享时,各个进程往往会与其他通信机制,譬如与信号量结合使用,来达到进程间同步及互斥的协调操作。
·本地套接字接口(IPC Socket):消息队列与共享内存只适合单机多进程间的通信,套接字接口则是更普适的进程间通信机制,可用于不同机器之间的进程通信。套接字(Socket)起初是由UNIX系统的BSD分支开发出来的,现在已经移植到所有主流的操作系统上。出于效率考虑,当仅限于本机进程间通信时,套接字接口是被优化过的,不会经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等操作,只是简单地将应用层数据从一个进程复制到另一个进程,这种进程间通信方式即本地套接字接口(UNIX Domain Socket),又叫作IPC Socket。