在操作系统中,两个进程之间是如何进行通信的?
随着我们的应用系统越来越大,单进程往往无法满足我们的要求,将一个大的系统拆分成多个功能模块,解耦,往往是一种常用的设计。无论是从将功能模块化、数据隔离等方面考虑,多进程协作都有着优势。
那么就意味着进程之间需要进行数据的传递,于是进程间通信(Inter-Process Communication)也就是我们常说的 IPC
就非常重要了。今天我们就来看看有哪些方式能实现 IPC。
大纲,我们主要围绕着 IPC 的方式展开,今天比较简单:
- 共享内存
- 管道
- 信号
- 信号量
- 消息队列
- 套接字
管道
代码语言:javascript复制ps -ef |grep target
通过 shell 的管道符号 “ | “,将第一个命令的输出通过管道作为第二个命令的输入。
特点:
- 单方向:由一方发送,另一方接收
- 使用功能内存作为缓冲区,没有持久化
创建:
- 匿名管道:通过
pipe
系统调用创建 - 命名管道:通过
mkfifo
创建
其实本质是创建了两个文件描述符,然后通过内存作为缓冲区,来实现通信。
消息队列
UNIX 系统提供了 System V 消息队列来作为一种进程间通信的方式,其本质就是一个队列,发送者从一端发送消息,接收者从一端接收消息。
通过 msgget()
系统调用来创建消息队列,msgsnd()
和 msgrcv()
进行消息的发送和接收
优点:相比管道而言,消息队列优势就在于有一个队列,可以将你需要通信的消息做个缓冲。
缺点:消息队列是通过标识符引用的,而不是文件描述符 fd,所以一些 select,epoll 都无法使用,并且消息在用户态和内核态之间传递会有拷贝的开销。
共享内存
共享内存非常容易理解,就是多个进程共享物理内存的同一块区域。通过共享内存的改动来传递信息,从而实现通信。
实现方式就是我们之前在说内存的时候提到的:多个进程在其所在的虚拟地址空间中映射相同的物理内存页。
我们可以通过 shmget()
系统调用来创建一个新的共享内存。
信号量
信号量(semaphore)我第一次听到这个名字的时候总觉得它很高大上,但其实实际上它并不复杂的。简单的理解,就可以把它理解为一个同步的计数器,或者是一个加了锁的计数器。
信号量有两个操作 P 和 V,P 是 -1,V 是 1,两个进程通过计数器的 0 1 变化从而进行消息传递,就是传递了一个信号。
当一个进程,执行 P 操作时,会尝试将计数器 -1,如果此时计数器会被减成负数,则会阻塞当前的进程,直到另一个进程将计数器 1,也就是执行 V 操作。
信号量虽然有通知能力,但是它是建立在进程本身要去主动查询计数器状态或者阻塞等待计数器状态变更的。
信号
信号是一个单方向的事件通知能力,一个进程可以随时发送一个信号给另一个进程,另一个进程接收时不需要等待,内核会切换到对应的处理函数中,进行信号的响应,处理完成之后恢复上下文。
听名字和说起来好像很复杂,其实我们经常在用它。
比如 Ctrl C
产生 SIGINT
信号,Ctrl Z
产生 SIGTSTP
信号
再比如我们经常使用 kill 命令来关闭一个进程,这就是向进程发送了一个停止信号,让它停下来。
套接字
socket(套接字),我们往往最初总是在网络中使用到它,它本身确实是一种通信机制。通过 bind 绑定一个端口,就可以使用 ip 端口的形式进行访问。当然还可以使用本地文件系统的一个路径作为地址,也就是 UNIX domain socket。
socket 有着我们非常熟悉的协议:TCP 和 UDP,这里我就不展开这两个协议本身了。
那,说白了,我一个进程启动一个 HTTP 的服务,另一个进程通过 127.0.0.1 端口就可以访问到这个服务,从而进行通信。虽然看起来有些复杂,但不失为一种通信方式。
总结
这么快就开始总结了?没错,对于进程间通信其实个人认为开发有些细节并不是非要去了解和详细深入,比如信号来了如何处理,为什么进程接收到信号就会做出相对应的反应?再比如管道符号 “|” 究竟是如何实现的,两个命令是不是父子进程的关系为什么复制的 fd 可以互通…. 在我看完这些知识点之后,我觉得更重要的是选择和设计。
选择
当这些 IPC 机制摆在你面前的时候你应该如何选择?当你要选择实现方案的时候你必须明确场景和优缺点
- 管道:单向传输,内存缓冲,无论是匿名还是命名管道,都需要一个管道。
- 消息队列:这个机制就和我们的中间件各种 MQ 类似,问题在于消息的拷贝消耗,还有消息大小队列长度等。
- 共享内存:最大优点就是方便,最大的问题也就是并发,如果没有信号量去控制多进程的并发访问共享内存的话,那么势必导致结果就是并发修改问题。
- 信号量:因为是一个计数器,所以没办法传递更多的信息,更多是将控制传递。
- 信号:信号就是用于系统操作,因为你需要提前定义什么信号是什么操作,根据不同的信号做出不同的反应,信号的种类只有那些。
- 套接字:消息支持自定义,想传递什么就传递什么,而且可靠,有协议去保证,但是也就增加了实现的复杂,通信成本变大。(明明是一台机器上的两个兄弟,却活的像两个陌生人)
从上面的特点我们就可以先看出:管道,信号都是用在比较特别的场景中,通常我们在 shell 命令中使用管道很多,而信号往往是在使用 kill 命令传递一些必要的信号进行进程管理的时候。
通信?共享内存?
在英文中有一句名言:
Don’t communicate by sharing memory; share memory by communicating. (R. Pike)
意思是不要通过共享内存来通信,而是通过通信来共享内存。这次思想同时也被提到在 golang
中。
所以一般情况下我们不会使用共享内存来通信,因为它带来的竞争问题太麻烦了,我们只是为了让两个进程通信,最后搞出一个并发问题?duck 不必。并发问题在系统中往往是特别难处理的,能避免就避免。
再者其实共享内存本身就是一件非常危险的事情,操作系统设计了虚拟地址很大原因就是为了数据隔离,为了安全。
信号量?通信?
有关信号量其实我们在实际的开发中是会用到的,很多时候并发的控制传递都是通过它来实现的,还有很多底层的锁的实现,都有它的身影。
不过在我的理解中,信号量更多的是为了并发而存在的,如果单独只是想要通信,好像它传递的消息太少了。用它来控制并发,传递一个“控制信号”更实际。
套接字
我其实觉得,实际工作中,大多数场景还是用它最为靠谱,用的也最多。无论是本地启动一个 http 服务来进行通信,还是使用 rpc 进行与一个本地的 sdk 通信,又或者是 docker 中通过 UNIX domain socket
来进行通信,都是 socket。
所以我觉得它才是我们一般的选择,也更加贴近通信这一词。
设计
当我们在设计一种通信方式的时候需要考虑的有哪些:
- 通信所要达到的目标,是需要传递信息,传递内容
- 消息内容大小
- 消息传递方向
- 竞争问题
- 消息的接收和处理是否需要及时
- …
总的来说进程间通信之所以有那么多种方式,主要原因也是因为各种不同的场景需要,所以当我们在进行一些系统设计的时候很多时候这些设计方案都是一些基本的模型,利用这些模型进行扩展从而可以得到针对特定场景的方案。
参考链接
- Linux/UNIX 系统编程手册
- Explain: Don’t communicate by sharing memory; share memory by communicating