📔【操作系统】进程间的通信方式
- 进程间的通信方式
- 信号 Signal
- 管道 Pipe
- 命名管道 FIFO
- 信号量 Semaphore
- 共享内存 Shared Memory
- 消息队列 Message Queue
- 套接字 Socket
- 🗂 技术面试题汇总
更多面试题总结请看:🗂【面试题】技术面试题汇总
进程间的通信方式
- 信号
- 管道
- 信号量
- 共享内存
- 消息队列
- 套接字
对比:
方式 | 传输的信息量 | 使用场景 | 关键词 |
---|---|---|---|
信号 | 少量 | 任何 | 硬件来源、软件来源 / 信号队列 |
管道 | 大量 | 亲缘进程间 | 单向流动 / 内核缓冲区 / 循环队列 / 没有格式的字节流 / 操作系统负责同步 |
命名管道 | 大量 | 任何 | 磁盘文件 / 访问权限 / 无数据块 / 内核缓冲区 / 操作系统负责同步 |
信号量 | N | 任何 | 互斥同步 / 原子性 / P 减 V 增 |
共享内存 | 大量 | 多个进程 | 内存映射 / 简单快速 / 操作系统不保证同步 |
消息队列 | 比信号多,但有限制 | 任何 | 有格式 / 按消息类型过滤 / 操作系统负责同步 |
套接字 | 大量 | 不同主机的进程 | 读缓存区 / 写缓冲区 / 操作系统负责同步 |
信号 Signal
信号是 Linux 系统响应某些条件而产生的一个事件,由操作系统事先定义,接收到该信号的进程可以采取自定义的行为。这是一种“订阅-发布”的模式。
信号来源分为硬件来源和软件来源。
- 硬件来源。如按下 CTRL+C、除 0、非法内存访问等等
- 软件来源。如 Kill 命令、Alarm Clock 超时、当 Reader 中止之后又向管道写数据,等等
一般的信号是都是由一个错误产生的。以除 0 为例。在 x86 机器上 DIV 或 IDIV 指令除数为 0 时,会引发 0 号中断,编号 #DE(Divide Error),即所谓除零异常。这是一个硬件级中断,会导致陷入内核,执行操作系统预定义在 IDT 中的中断处理程序。而操作系统处理这个异常的方法,就是向进程发送一个信号 SIGFPE
。如果进程设置了相应的 signal handler,就执行进程的处理方法。否则,执行操作系统的默认操作,一般这种信号的默认操作是杀死进程。
同理,溢出、非法内存访问(越界)、非法指令等也都属于硬件中断,由操作系统处理。操作系统会将这些硬件异常包装成“信号”发送给进程。如果进程不处理这几个异常信号,那么默认的行为就是挂掉。
但是,信号也可以作为进程间通信的一种方式,明确地由一个进程发送给另一个进程。
进程如何发送信号?
- 操作系统提供发送信号的系统调用
- 该系统调用会将信号放到目标进程的信号队列中
- 如果目标进程未处于执行状态,则该信号就由内核保存起来,直到该进程恢复执行并传递给它为止。如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时才被传递给进程
进程如何接收信号?
- 每个进程有一个信号队列,放其他进程发给它、等待它处理的信号
- 进程在执行过程中的特定时刻,检查并处理自己的信号队列。如从系统空间返回到用户空间之前
- 发送信号时,必须指明发送目标进程的号码。一般用在具有亲缘关系的进程之间
用户进程对信号的处理过程有三种:
- 处理信号。定义信号处理函数,当信号发生时,执行相应的处理函数
- 忽略信号。当不希望接收到的信号对进程的执行产生影响,而让进程继续执行时,可以忽略该信号,即不对信号进程作任何处理
- 不处理也不忽略。执行默认操作,linux 对每种信号都规定了默认操作
有的信号,用户进程是无法处理也无法忽略的,比如SIGSTOP
、SIGKILL
等。
关于信号的更详细的内容,可以查看这篇文章。
管道 Pipe
管道命令,在 Linux Shell 中经常使用,一般,我们使用管道操作符 |
来表示两个命令之间的数据通信。比如:
ps -ef | grep java | xargs echo
管道操作符的内部实现其实就是 Linux 的管道接口。由管道操作符 |
分割的每个命令是独立的进程,各个进程的标准输出 STDOUT,会作为下一个进程的标准输入 STDIN。
定义
管道是一种半双工的通信方式,数据只能单向流动,上游进程往管道中写入数据,下游进程从管道中接收数据。如果想实现双方通信,那么需要建立两个管道。
管道适合于传输大量信息。管道发送的内容是以字节为单位的,没有格式的字节流。
创建管道
通过 pipe()
系统调用来创建并打开一个管道,当最后一个使用它的进程关闭对他的引用时,pipe 将自动撤销。
通过 pipe()
创建的是匿名管道,只能用于具有亲缘关系的进程之间(父子进程或兄弟进程)。
管道的实现
管道就是一个文件,是一种只存在于内存中的特殊的文件系统。
在 Linux 中,管道借助了文件系统的 File 结构实现。父进程使用 File 结构保存向管道写入数据的例程地址,子进程保存从管道读出数据的例程地址。这解释了上文所说的:
- 单向流动
- 只能用于具有亲缘关系的进程之间
管道是由内核管理的一个缓冲区,缓冲区被设计成为环形的数据结构,以便管道可以被循环利用(循环队列)。
管道的同步
管道是一个具有特定大小的缓冲区
- 操作系统会保证读写进程的同步
- 下游进程或者上游进程需要等另一方释放锁后才能操作管道。管道就相当于一个文件,同一时刻只能有一个进程访问
- 当管道为空时,下游进程读阻塞;当管道满时,上游进程写阻塞
- 管道不再被任何进程使用时,自动消失
命名管道 FIFO
Linux 管道包含匿名管道和命名管道。上面说的是匿名管道,只能用在亲缘进程中,管道文件信息保存在内存里。
命名管道(FIFO)可用于没有亲缘的进程间。Pipe 和 FIFO 除了建立、打开、删除的方式不同外,二者几乎一模一样。
通过 mknode()
系统调用或者 mkfifo()
函数建立命名管道。一旦建立,任何有访问权的进程都可以通过文件名将其打开和进行读写,而不局限于父子进程。
建立命名管道时,会在磁盘中创建一个索引节点,命名管道的名字就相当于索引节点的文件名。索引节点设置了进程的访问权限,但是没有数据块。命名管道实质上也是通过内核缓冲区来实现数据传输。有访问权限的进程,可以通过磁盘的索引节点来读写这块缓冲区。
当不再被任何进程使用时,命名管道在内存中释放,但磁盘节点仍然存在。
信号量 Semaphore
信号量是一种特殊的变量,对它的操作都是原子的,有两种操作:V(signal()
)和 P(wait()
)。V 操作会增加信号量 S 的数值,P 操作会减少它。
- V(S):如果有其他进程因等待 S 而被挂起,就让它恢复运行,否则 S 加 1
- P(S):如果 S 为 0,则挂起进程,否则 S 减 1
P、V 来自于荷兰语:Probeer (try)、Verhoog (increment)。
如果信号量是一个任意的整数,通常被称为计数信号量(Counting semaphore),或一般信号量(general semaphore);如果信号量只有二进制的 0 或 1,称为二进制信号量(binary semaphore)。在 Linux 系统中,二进制信号量又称互斥锁(Mutex)。信号量可以用于实现进程或线程的互斥和同步。
信号量在底层的实现是通过硬件提供的原子指令,如 Test And Set
、Compare And Swap
等。比如 golang 实现互斥量就是使用了 Compare And Swap
指令(github)。
共享内存 Shared Memory
共享内存顾名思义,允许两个或多个进程共享同一段物理内存。不同进程可以将同一段共享内存映射到自己的地址空间,然后像访问正常内存一样访问它。不同进程可以通过向共享内存端读写数据来交换信息。
一个进程可以通过操作系统的系统调用,创建一块共享内存区;其他进程通过系统调用把这段内存映射到自己的用户地址空间中;之后各个进程向读写正常内存一样,读写共享内存。共享内存区只会驻留在创建它的进程地址空间内。
共享内存的优点是简单且高效,访问共享内存区域和访问进程独有的内存区域一样快,原因是不需要系统调用,不涉及用户态到内核态的转换,也不需要对数据不必要的复制。
比如管道和消息队列,需要在内核和用户空间进行四次的数据拷贝(读输入文件、写到管道;读管道、写到输出文件),而共享内存则只拷贝两次:一次从输入文件到共享内存区,另一次从共享内存到输出文件(图示)。此外,消息传递的实现经常采用系统调用,也就经常需要用户态和内核态互相转换;而共享内存只在建立共享内存区域时需要系统调用;一旦建立共享内存,所有访问都可作为常规内存访问,无需借助内核。
共享内存的缺点是存在并发问题,有可能出现多个进程修改同一块内存,因此共享内存一般与信号量结合使用。
Linux 的 2.2.x 内核支持多种共享内存方式,如 mmap() 系统调用,Posix 共享内存,以及系统 V 共享内存。
mmap() 系统调用的主要作用是将普通文件映射到进程的地址空间,然后可以像访问普通内存一样对文件进行访问,不必再调用 read(),write() 等操作。mmap() 不是专门用来共享内存的,但是多个进程可以通过 mmap() 映射同一个普通文件,来实现共享内存。
系统 V 则是通过映射特殊文件系统 shm 中的文件实现进程间的共享内存。通过 shmget 可以创建或获得共享内存的标识符。取得共享内存标识符后,通过 shmat 将这个内存区映射到本进程的虚拟地址空间。
有关 mmap() 系统调用、系统 V 共享内存的详细介绍,以及两者的对比,可以进一步查看这两篇文章:
消息队列 Message Queue
消息队列是一个消息的链表,保存在内核中。消息队列中的每个消息都是一个数据块,具有特定的格式。操作系统中可以存在多个消息队列,每个消息队列有唯一的 key,称为消息队列标识符。
消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。和信号相比,消息队列能够传递更多的信息。与管道相比,消息队列提供了有格式的数据,但消息队列仍然有大小限制。
消息队列允许一个或多个进程向它写入与读取消息。消息的发送者和接收者不需要同时与消息队列交互。消息会保存在队列中,直到接收者取回它。也就是说,消息队列是异步的,但这也造成了一个缺点,就是接收者必须轮询消息队列,才能收到最近的消息。
操作系统提供创建消息队列、取消息、发消息等系统调用。
操作系统负责读写同步:若消息队列已满,则写消息进程排队等待;若取消息进程没有找到需要的消息,则在等待队列中寻找。
消息队列和管道相比,相同点在于二者都是通过发送-接收的方式进行通信,并且数据都有最大长度限制。不同点在于消息队列的数据是有格式的,并且取消息进程可以选择接收特定类型的消息,而不是像管道中那样默认全部接收。
套接字 Socket
- 不同的计算机的进程之间通过 socket 通信,也可用于同一台计算机的不同进程
- 需要通信的进程之间首先要各自创建一个 socket,内容包括主机地址与端口号,声明自己接收来自某端口地址的数据
- 进程通过 socket 把消息发送到网络层中,网络层通过主机地址将其发到目的主机,目的主机通过端口号发给对应进程
操作系统提供创建 socket、发送、接收的系统调用,为每个 socket 设置发送缓冲区、接收缓冲区。
🗂 技术面试题汇总
- 版权声明:本文采用知识共享 3.0 许可证 (保持署名-自由转载-非商用-非衍生)
- 发表于 2020-02-26