初学操作系统的时候,我就一直懵逼,为啥进程同步与互斥机制里有信号量机制,进程通信里又有信号量机制,然后你再看网络上的各种面试题汇总或者博客,你会发现很多都是千篇一律的进程通信机制有哪些?进程同步与互斥机制鲜有人问津。看多了我都想把 CSDN 屏了.....,最后知道真相的我只想说为啥不能一篇博客把东西写清楚,没头没尾真的浪费时间。
希望这篇文章能够拯救某段时间和我一样被绕晕的小伙伴。上篇文章我已经讲过进程间的同步与互斥机制,各位小伙伴看完这个再来看进程通信比较好。
全文脉络思维导图如下:
1. 什么是进程通信
顾名思义,进程通信( InterProcess Communication,IPC)就是指「进程之间的信息交换」。实际上,「进程的同步与互斥本质上也是一种进程通信」(这也就是待会我们会在进程通信机制中看见信号量和 PV 操作的原因了),只不过它传输的仅仅是信号量,通过修改信号量,使得进程之间建立联系,相互协调和协同工作,但是它「缺乏传递数据的能力」。
虽然存在某些情况,进程之间交换的信息量很少,比如仅仅交换某个状态信息,这样进程的同步与互斥机制完全可以胜任这项工作。但是大多数情况下,「进程之间需要交换大批数据」,比如传送一批信息或整个文件,这就需要通过一种新的通信机制来完成,也就是所谓的进程通信。
再来从操作系统层面直观的看一些进程通信:我们知道,为了保证安全,每个进程的用户地址空间都是独立的,一般而言一个进程不能直接访问另一个进程的地址空间,不过内核空间是每个进程都共享的,所以「进程之间想要进行信息交换就必须通过内核」。
下面就来我们来列举一下 Linux 内核提供的常见的进程通信机制:
- 管道(也称作共享文件)
- 消息队列(也称作消息传递)
- 共享内存(也称作共享存储)
- 信号量和 PV 操作
- 信号
- 套接字(Socket)
2. 管道
匿名管道
各位如果学过 Linux 命令,那对管道肯定不陌生,Linux 管道使用竖线 |
连接多个命令,这被称为管道符。
$ command1 | command2
以上这行代码就组成了一个管道,它的功能是将前一个命令(command1
)的输出,作为后一个命令(command2
)的输入,从这个功能描述中,我们可以看出「管道中的数据只能单向流动」,也就是半双工通信,如果想实现相互通信(全双工通信),我们需要创建两个管道才行。
另外,通过管道符 |
创建的管道是匿名管道,用完了就会被自动销毁。并且,匿名管道只能在具有亲缘关系(父子进程)的进程间使用,。也就是说,「匿名管道只能用于父子进程之间的通信」。
在 Linux 的实际编码中,是通过 pipe
函数来创建匿名管道的,若创建成功则返回 0,创建失败就返回 -1:
int pipe (int fd[2]);
该函数拥有一个存储空间为 2 的文件描述符数组:
fd[0]
指向管道的读端,fd[1]
指向管道的写端fd[1]
的输出是fd[0]
的输入
粗略的解释一下通过匿名管道实现进程间通信的步骤:
1)父进程创建两个匿名管道,管道 1(fd1[0]
和 fd1[1]
)和管道 2(fd2[0]
和 fd2[1]
);
❝因为管道的数据是单向流动的,所以要想实现数据双向通信,就需要两个管道,每个方向一个。 ❞
2)父进程 fork 出子进程,于是对于这两个匿名管道,子进程也分别有两个文件描述符指向匿名管道的读写两端;
3)父进程关闭管道 1 的读端 fd1[0]
和 管道 2 的写端 fd2[1]
,子进程关闭管道 1 的写端 fd1[1]
和 管道 2 的读端 fd2[0]
,这样,管道 1 只能用于父进程写、子进程读;管道 2 只能用于父进程读、子进程写。管道是用「环形队列」实现的,数据从写端流入从读端流出,这就实现了父子进程之间的双向通信。
看完上面这些讲述,我们来理解下管道的本质是什么:对于管道两端的进程而言,管道就是一个文件(这也就是为啥管道也被称为共享文件机制的原因了),但它不是普通的文件,它不属于某种文件系统,而是自立门户,单独构成一种文件系统,并且只存在于内存中。
简单来说,「管道的本质就是内核在内存中开辟了一个缓冲区,这个缓冲区与管道文件相关联,对管道文件的操作,被内核转换成对这块缓冲区的操作」。
有名管道
匿名管道由于没有名字,只能用于父子进程间的通信。为了克服这个缺点,提出了有名管道,也称做 FIFO
,因为数据是先进先出的传输方式。
所谓有名管道也就是提供一个路径名与之关联,这样,即使与创建有名管道的进程不存在亲缘关系的进程,只要可以访问该路径,就能够通过这个有名管道进行相互通信。
使用 Linux 命令 mkfifo
来创建有名管道:
$ mkfifo myPipe
myPipe
就是这个管道的名称,接下来,我们往 myPipe 这个有名管道中写入数据:
$ echo "hello" > myPipe
执行这行命令后,你会发现它就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。于是,我们执行另外一个命令来读取这个有名管道里的数据:
代码语言:javascript复制$ cat < myPipe
hello
3. 消息队列
可以看出,「管道这种进程通信方式虽然使用简单,但是效率比较低,不适合进程间频繁地交换数据,并且管道只能传输无格式的字节流」。为此,消息传递机制(Linux 中称消息队列)应用而生。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程在需要的时候自行去消息队列中读取数据就可以了。同样的,B 进程要给 A 进程发送消息也是如此。
「消息队列的本质就是存放在内存中的消息的链表,而消息本质上是用户自定义的数据结构」。如果进程从消息队列中读取了某个消息,这个消息就会被从消息队列中删除。对比一下管道机制:
- 消息队列允许一个或多个进程向它写入或读取消息。
- 消息队列可以实现消息的「随机查询」,不一定非要以先进先出的次序读取消息,也可以按消息的类型读取。比有名管道的先进先出原则更有优势。
- 对于消息队列来说,在某个进程往一个队列写入消息之前,并不需要另一个进程在该消息队列上等待消息的到达。而对于管道来说,除非读进程已存在,否则先有写进程进行写入操作是没有意义的。
- 消息队列的生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列就会一直存在。而匿名管道随进程的创建而建立,随进程的结束而销毁。
需要注意的是,消息队列对于交换较少数量的数据很有用,因为无需避免冲突。但是,由于用户进程写入数据到内存中的消息队列时,会发生从用户态「拷贝」数据到内核态的过程;同样的,另一个用户进程读取内存中的消息数据时,会发生从内核态拷贝数据到用户态的过程。因此,「如果数据量较大,使用消息队列就会造成频繁的系统调用,也就是需要消耗更多的时间以便内核介入」。
4. 共享内存
为了避免像消息队列那样频繁的拷贝消息、进行系统调用,共享内存机制出现了。
顾名思义,共享内存就是允许不相干的进程将同一段物理内存连接到它们各自的地址空间中,使得这些进程可以访问同一个物理内存,这个物理内存就成为共享内存。如果某个进程向共享内存写入数据,所做的改动将「立即」影响到可以访问同一段共享内存的任何其他进程。
集合内存管理的内容,我们来深入理解下共享内存的原理。首先,每个进程都有属于自己的进程控制块(PCB)和逻辑地址空间(Addr Space),并且都有一个与之对应的页表,负责将进程的逻辑地址(虚拟地址)与物理地址进行映射,通过内存管理单元(MMU)进行管理。「两个不同进程的逻辑地址通过页表映射到物理空间的同一区域,它们所共同指向的这块区域就是共享内存」。
不同于消息队列频繁的系统调用,对于共享内存机制来说,仅在建立共享内存区域时需要系统调用,一旦建立共享内存,所有的访问都可作为常规内存访问,无需借助内核。这样,数据就不需要在进程之间来回拷贝,所以这是最快的一种进程通信方式。
5. 信号量和 PV 操作
实际上,对具有多 CPU 系统的最新研究表明,在这类系统上,消息传递的性能其实是要优于共享内存的,因为「消息队列无需避免冲突,而共享内存机制可能会发生冲突」。也就是说如果多个进程同时修改同一个共享内存,先来的那个进程写的内容就会被后来的覆盖。
并且,在多道批处理系统中,多个进程是可以并发执行的,但由于系统的资源有限,进程的执行不是一贯到底的, 而是走走停停,以不可预知的速度向前推进(异步性)。但有时候我们又希望多个进程能密切合作,按照某个特定的顺序依次执行,以实现一个共同的任务。
举个例子,如果有 A、B 两个进程分别负责读和写数据的操作,这两个线程是相互合作、相互依赖的。那么写数据应该发生在读数据之前。而实际上,由于异步性的存在,可能会发生先读后写的情况,而此时由于缓冲区还没有被写入数据,读进程 A 没有数据可读,因此读进程 A 被阻塞。
因此,为了解决上述这两个问题,保证共享内存在任何时刻只有一个进程在访问(互斥),并且使得进程们能够按照某个特定顺序访问共享内存(同步),我们就可以使用进程的同步与互斥机制,常见的比如信号量与 PV 操作。
「进程的同步与互斥其实是一种对进程通信的保护机制,并不是用来传输进程之间真正通信的内容的,但是由于它们会传输信号量,所以也被纳入进程通信的范畴,称为低级通信」。
❝下面的内容和上篇文章【看完了进程同步与互斥机制,我终于彻底理解了 PV 操作】中所讲的差不多,看过的小伙伴可直接跳到下一标题。 ❞
信号量其实就是一个变量 ,我们可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为 1 的信号量。
用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而很方便的实现进程互斥或同步。这一对原语就是 PV 操作:
1)「P 操作」:将信号量值减 1,表示「申请占用一个资源」。如果结果小于 0,表示已经没有可用资源,则执行 P 操作的进程被阻塞。如果结果大于等于 0,表示现有的资源足够你使用,则执行 P 操作的进程继续执行。
可以这么理解,当信号量的值为 2 的时候,表示有 2 个资源可以使用,当信号量的值为 -2 的时候,表示有两个进程正在等待使用这个资源。不看这句话真的无法理解 V 操作,看完顿时如梦初醒。
2)「V 操作」:将信号量值加 1,表示「释放一个资源」,即使用完资源后归还资源。若加完后信号量的值小于等于 0,表示有某些进程正在等待该资源,由于我们已经释放出一个资源了,因此需要唤醒一个等待使用该资源(就绪态)的进程,使之运行下去。
我觉得已经讲的足够通俗了,不过对于 V 操作大家可能仍然有困惑,下面再来看两个关于 V 操作的问答:
问:「信号量的值 大于 0 表示有共享资源可供使用,这个时候为什么不需要唤醒进程」?
答:所谓唤醒进程是从就绪队列(阻塞队列)中唤醒进程,而信号量的值大于 0 表示有共享资源可供使用,也就是说这个时候没有进程被阻塞在这个资源上,所以不需要唤醒,正常运行即可。
问:「信号量的值 等于 0 的时候表示没有共享资源可供使用,为什么还要唤醒进程」?
答:V 操作是先执行信号量值加 1 的,也就是说,把信号量的值加 1 后才变成了 0,在此之前,信号量的值是 -1,即有一个进程正在等待这个共享资源,我们需要唤醒它。
信号量和 PV 操作具体的定义如下:
互斥访问共享内存
两步走即可实现不同进程对共享内存的互斥访问:
- 定义一个互斥信号量,并初始化为 1
- 把对共享内存的访问置于 P 操作和 V 操作之间
「P 操作和 V 操作必须成对出现」。缺少 P 操作就不能保证对共享内存的互斥访问,缺少 V 操作就会导致共享内存永远得不到释放、处于等待态的进程永远得不到唤醒。
实现进程同步
回顾一下进程同步,就是要各并发进程按要求有序地运行。
举个例子,以下两个进程 P1、P2 并发执行,由于存在异步性,因此二者交替推进的次序是不确定的。假设 P2 的 “代码4” 要基于 P1 的 “代码1” 和 “代码2” 的运行结果才能执行,那么我们就必须保证 “代码4” 一定是在 “代码2” 之后才会执行。
如果 P2 的 “代码4” 要基于 P1 的 “代码1” 和 “代码2” 的运行结果才能执行,那么我们就必须保证 “代码4” 一定是在 “代码2” 之后才会执行。
使用信号量和 PV 操作实现进程的同步也非常方便,三步走:
- 定义一个同步信号量,并初始化为当前可用资源的数量
- 在优先级较「高」的操作的「后」面执行 V 操作,释放资源
- 在优先级较「低」的操作的「前」面执行 P 操作,申请占用资源
配合下面这张图直观理解下:
6. 信号
注意!「信号和信号量是完全不同的两个概念」!
信号是进程通信机制中唯一的「异步」通信机制,它可以在任何时候发送信号给某个进程。「通过发送指定信号来通知进程某个异步事件的发送,以迫使进程执行信号处理程序。信号处理完毕后,被中断进程将恢复执行」。用户、内核和进程都能生成和发送信号。
信号事件的来源主要有硬件来源和软件来源。所谓硬件来源就是说我们可以通过键盘输入某些组合键给进程发送信号,比如常见的组合键 Ctrl C 产生 SIGINT
信号,表示终止该进程;而软件来源就是通过 kill
系列的命令给进程发送信号,比如 kill -9 1111
,表示给 PID 为 1111 的进程发送 SIGKILL
信号,让其立即结束。我们来查看一下 Linux 中有哪些信号:
7. Socket
至此,上面介绍的 5 种方法都是用于同一台主机上的进程之间进行通信的,如果想要「跨网络与不同主机上的进程进行通信」,那该怎么做呢?这就是 Socket 通信做的事情了(「当然,Socket 也能完成同主机上的进程通信」)。
Socket 起源于 Unix,原意是「插座」,在计算机通信领域,Socket 被翻译为「套接字」,它是计算机之间进行通信的一种约定或一种方式。通过 Socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。
从计算机网络层面来说,「Socket 套接字是网络通信的基石」,是支持 TCP/IP 协议的网络通信的基本操作单元。它是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:连接使用的协议,本地主机的 IP 地址,本地进程的协议端口,远地主机的 IP 地址,远地进程的协议端口。
Socket 的本质其实是一个编程接口(API),是应用层与 TCP/IP 协议族通信的中间软件抽象层,它对 TCP/IP 进行了封装。它「把复杂的 TCP/IP 协议族隐藏在 Socket 接口后面」。对用户来说,只要通过一组简单的 API 就可以实现网络的连接。
8. 总结
简单总结一下上面六种 Linux 内核提供的进程通信机制:
1)首先,最简单的方式就是「管道」,管道的本质是存放在内存中的特殊的文件。也就是说,内核在内存中开辟了一个缓冲区,这个缓冲区与管道文件相关联,对管道文件的操作,被内核转换成对这块缓冲区的操作。管道分为匿名管道和有名管道,匿名管道只能在父子进程之间进行通信,而有名管道没有限制。
2)虽然管道使用简单,但是效率比较低,不适合进程间频繁地交换数据,并且管道只能传输无格式的字节流。为此「消息队列」应用而生。消息队列的本质就是存放在内存中的消息的链表,而消息本质上是用户自定义的数据结构。如果进程从消息队列中读取了某个消息,这个消息就会被从消息队列中删除。
3)消息队列的速度比较慢,因为每次数据的写入和读取都需要经过用户态与内核态之间数据的拷贝过程,「共享内存」可以解决这个问题。所谓共享内存就是:两个不同进程的逻辑地址通过页表映射到物理空间的同一区域,它们所共同指向的这块区域就是共享内存。如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。
对于共享内存机制来说,仅在建立共享内存区域时需要系统调用,一旦建立共享内存,所有的访问都可作为常规内存访问,无需借助内核。这样,数据就不需要在进程之间来回拷贝,所以这是最快的一种进程通信方式。
4)共享内存速度虽然非常快,但是存在冲突问题,为此,我们可以使用信号量和 PV 操作来实现对共享内存的互斥访问,并且还可以实现进程同步。
5)「信号」和信号量是完全不同的两个概念!信号是进程通信机制中唯一的异步通信机制,它可以在任何时候发送信号给某个进程。通过发送指定信号来通知进程某个异步事件的发送,以迫使进程执行信号处理程序。信号处理完毕后,被中断进程将恢复执行。用户、内核和进程都能生成和发送信号。
6)上面介绍的 5 种方法都是用于同一台主机上的进程之间进行通信的,如果想要跨网络与不同主机上的进程进行通信,就需要使用 「Socket」 通信。另外,Socket 也能完成同主机上的进程通信。
总结完毕!