⚪前言
如何理解进程间通信?
进程具有独立性,所以进程想要通信难度是比较大的,成本高。
在日常生活中,通信的本质是传递信息,但站在程序员角度来看,进程间通信的本质:让不同的进程看到同一份资源(内存空间)。
进程间通信就是进程之间互相传递数据,那么进程间能直接相互传递数据吗?
不能,因为进程具有独立性,所有的数据操作都会发生写时拷贝,父子进程都不能传递,更不要说两个进程毫无关系还想直接相互传递数据。
所以两个进程如果想要通信就一定要通过中间媒介的方式来进行通信,那么就必须先想办法让不同的进程看到同一份公共的资源,这里所谓公共的资源就是系统通过某种方式提供的系统内存。 这块空间通常是由操作系统提供的,可以被两个不同的进程都看到,然后它们才能实现通信。
传递数据就是由一个进程拷到对应的内存里,这块内存另一个进程当然也能看到,所以也自然能从内存里拷到自己的进程中。
综上所述,我们就知道了进程间通信要学的就是如何通过系统,让不同的进程看到同一份资源。操作系统提供的通信方案有很多种,这句话的含义就是操作系统让不同进程看到同一份资源的方式有很多种,最典型的有管道、消息队列、共享内存、信号量等等。下面主要谈管道和共享内存,而信号量会在后面多线程的部分再展开,这里主要以概念为主。
所以进程间通信的本质就是让不同的进程,能看到同一份系统资源,而这份资源就是系统通过某种方式提供的系统内存,因为方式是有差别的,所以通信策略也是有差别的。
一、进程间通信介绍
1、进程间通信目的
- 数据传输:一个进程需要将它的数据发送给另一个进程(可以理解为一个进程将数据加工成半成品通过某种通信方式给到另一个进程,另一个进程再做加工)。
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
为什么要进行进程间通信?
往往是出于交互数据、控制、通知等目的。
2、进程间通信发展
在进程间通信发展的过程主要有两种流派,一种是只在主机上通信,就是 System V,另一种是可以在主机上的进程跨网络通信,就是 POSIX。下面主要学习 System V,等到后面网络部分再学习 POSIX。
管道是操作系统本身提供的,所以这里能接触到的是管道和 System V 进程间的通信方式。
- 管道
- System V 进程间通信
- POSIX 进程间通信
3、进程间通信的分类
(1)管道
- 匿名管道 pipe
- 命名管道
(2)System V IPC
主要用于单机通信。
- System V 消息队列
- System V 共享内存(不常用)
- System V 信号量(了解原理)
(3)POSIX IPC
主要用于网络通信。
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
上面这些分类的标准在我们使用者看来,都是接口上具有一定的规律。
4、进程间通信的必要性
单进程无法使用并发能力,也无法实现多进程协同。
进程间通信有很多目的,比如:传输数据、同步执行流、消息通知等,就是为了实现多进程协同。
进程间通信不是目的,只是一种手段。
5、进程间通信的技术背景
- 进程是具有独立性的。进程是通过虚拟地址空间 + 页表来保证进程运行的独立性(进程内核数据结构 + 进程的代码和数据)。
- 通信成本较高,进程本身就已经具有独立性了,这时要让不同进程看到同一份资源,肯定不容易。
6、进程间通信的本质理解
进程间通信的前提是:首先要让不同的进程看到同一块“内存”(特定的结构组织的)。
那么我们所谓的进程看到同一块 “内存”是属于哪一个进程呢?—— 不能隶属于任何一个进程,而应该更强调共享。
二、管道
1、什么是管道
现实生活中也存在着很多管道,它们的共同点是:都有一个入口和一个出口(最典型的特点:只能单向通信),在这其中就传送着人们所需要的自来水、石油资源等。
而互联网中的管道传送的是数据资源,所以计算机就模拟出一条管道。数据资源一定是有人想传入,并且有人想获取,那么这里的有人就分别对应发送进程和接受进程。
现实中构建管道所使用的材料是钢铁,而计算机中构建管道缓冲区所使用的材料是系统内存,而这里的系统内存就是让不同进程所看到的同一块系统资源。上面所说的概念只是一种感性的理解,还没有涉及到任何的系统概念,归根结底是想让大家明白不同角色的定位。
- 管道是 Unix 中最古老的进程间通信的形式。
- 我们把从一个进程连接到另一个进程的一个数据流称为一个 “管道”。
管道不能是进程 A 或进程 B 提供的,一定是操作系统提供的,只是两个进程恰好利用某种方式通过管道来进行通信。任何的中间资源不能隶属于某一个进程,因为进程具有独立性,一旦某种中间通信资源隶属于某个进程,那么其它进程一定不能看到。
管道一共有两种通信方案:匿名管道和命名管道。它们的底层原理基本上是一样的,区别在于它们各自的侧重点不同。
2、匿名管道 pipe
匿名管道是供具有血缘关系的进程进行进程间通信,常用于父子进程之间。即便是父子,它们的数据也不是共享的,而是私有的,凡是共享的都是因为双方都不写入罢了。
所有的通信方式,特别是进程间通信,首先是得保证不同的进程看到同一份资源。匿名管道就是这个管道没有名字,它也不需要,匿名管道是由子进程继承父进程的文件描述符中的内容来的。
sleep 在系统中也是一条命令,这里就是想让这三个 sleep 进程别立马退出,然后让它们 & 在后台运行。ps 后我们可以看到这三个进程是兄弟进程,而 13079 一定是 bash。
系统就是 pipe 管道 1 和 管道 2,通过 for 循环 fork() 三个进程(如果是父进程就继续 fork),它们都能打开之前的两个管道文件,然后它们三个进程再关闭对应的读写端形成一条单身的数据流。通过 | 就可以实现 sleep 10000 到 sleep 20000 或者其它命令之间进程间通信。换而言之,我们曾经使用到的 | 就是匿名管道。
补充:进程退出,那么曾经打开的文件也会被关闭(因为进程中保存着打开文件的相关数据结构,而进程退出后,文件就自然会被关闭)。同样,管道也是文件,所以管道的生命周期就是进程的生命周期。
怎么保证父子进程看到同一份资源呢?
我们已经对文件描述符很熟悉了,它和管道强相关的,这里要强调的是:在 struct file 之后是提供文件的方法和缓冲区的。
管道的原理:先让父进程以读和写的方式打开同一个文件(可以理解成以读方式打开一次,再以写方式打开一次)。(注意这里只是为了好理解才这样表述,实际上创建管道,它有自己独立的接口。)相当于父进程以读又以写打开一个 pipe_file 文件(把同一个文件打开两次就得到不同的文件描述符,比如说默认的 1 和 2,所以对于一个文件来说,可以以读的方式打开一次又以写的方式打开一次,不过一般是读方式或写方式其中一种。即便同时打开,用的也是同一个接口。这个过程就称为创建管道的过程。
那么管道有了,下面就需要通信数据,所以父进程 fork() 创建子进程(强调一下,子进程是一个独立的进程,有自己独立的地址空间、页表、文件描述符表,代码共享,数据各自私有,但是结构中的大部分数据都是以父进程为模板)(与进程强相关的都会被拷贝,与文件相关的不变),所以子进程文件描述符表中写入的内容和父进程是一样的,最重要的是,曾经父进程对应打开的 pipe_file 文件,现在子进程中的 3 号 4 号文件描述符也指向 pipe_file 文件。也就能说明,为什么父子进程都 printf,结果都是向显示屏打印。
这就是进程间通信的第一步:保证不同的进程看到同一份资源,这份资源就是系统提供的一段内存区域,那么我们就可以理解父进程通过 3 或 4 号文件描述符往管道中对应读写的数据就在这个文件对应的缓冲区中,而子进程也可以通过 3 或 4 号文件描述符往管道中读写数据。
那么对于地址空间、文件描述符表等数据结构而言,虽然父子进程不共享,但是文件描述符表中的内容是一样的,那么也就意味着父子进程能够指向同一份文件。
管道的本质就是文件,当然,管道和文件也有差别,比如说文件是需要刷新到磁盘上的,而管道通信的临时文件不需要刷新到磁盘上。
管道只能进行单向数据通信。
这也就意味着要么是父进程写、子进程读,要么是子进程写、父进程读。总之一个管道只能进行单向数据通信,如果要双向通信就只能建立多个管道。
如果想让父进程写、子进程读,就关闭父进程的读、子进程的写;如果想让子进程写、父进程读,就关闭子进程的读、父进程的写。父子进程各自关闭不需要的文件描述符就可以达到构建单向通信信道的目的。
在构建单向信道时,父子进程到最后都要关闭一个文件描述符,那为什么曾经还要打开呢?
根本原因:如果父进程只以读或只以写的方式打开这个文件,那么 fork() 创建子进程后只有对应的读或者只有写,那么就会造成父子进程要么都是读,要么都是写,这样就不能完成管道的单向通信。
还有一个原因:我们需要灵活的控制父子进程来完成读写通信,所以最终是父进程写、子进程读,还是子进程写、父进程读,这完全取决于我们的应用场景。
对应的一组写和读可以不关闭吗?
虽然这样也没错,不关闭也可以达到管道的单向通信。不过一般建议还是要关闭其中一个,因为一方面证明了管道的单向通信这样的特性,另一方面主要是为了防止我们误操作。当然,我们也无法确定各种操作系统对于管道的支持情况,所以最好还是按照标准规范。
为什么管道在设计时只支持单向通信?
这是与文件系统强相关的,如果能设计双向通信的话人家早就这么做了。无法支持双向通信的原因大概率跟文件的读写位置有关,一个文件的读写位置只有一个,如果要实现管道双向通信就一定要让双方既能读又能写,所以读写位置必须是两对,那么就需要修改文件系统,这样反而更麻烦,不如直接创建两个管道即可。
注意:并不是所有文件都可以被当作管道,但是管道确实又是一种文件。比如:touch 一个 test.txt,然后让两个没有任何关系的进程一个以写方式打开,一个以读方式打开。这样显然是比较困难的,虽然两进程可以看到同一个文件,但这样就需要写进程把数据刷新到磁盘,读进程再从磁盘中读取,这并不是系统想支持的通信方案。通信一定要考虑成熟、稳定且高效。
3、实现进程间通信(图 + demo代码 —— 站在文件描述符角度深度理解管道)
#include <unistd.h> 功能:创建一无名管道 原型 int pipe(int fd[2]); 参数 fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端 返回值:成功返回0,失败返回错误代码
谁调用就让谁以读写的方式打开一个文件,不需要指定文件名。
pipe 是我们要认识的一 个创建匿名管道的系统调用接口。
pipe 的参数是一个具有 2 个参数的数组,我们都知道数组传参会降维成指针。这里 pipe 的参数是一个输出型参数,说白了就是不需要传入什么参数,而是在需要调用你的时候再拿回什么。我们可以通过这个参数拿到打开的管道文件的 fd,这个数组有两个参数,这意味着它会拿到 2 个 fd,分别是 read、write。不妨思考一下,它在底层无非就是让父进程以读和写方式分别打开一个文件,然后得到两个文件描述符。据经验判断,默认会拿到的 fd 是 3 和 4。
(1)父进程创建管道
(2)父进程 fork 子进程
(3)父进程写,子进程读,通常 fd[0] 对应 read,fd[1] 对应 write,子进程关闭 fd[1],父进程关闭 fd[0],再让父进程等待子进程
(4)父子进程实现通信
- '\0' 是 C 语言中的规定,不是文件的规定,管道也是文件,不需要给文件描述符写入 '\0',所以子进程在 write 的时候不要写 '\0',父进程在往 buffer 里读入数据的时候需要预留一个位置给 '\0'。
- 我们必然不可能把 '\0' 写入文件中,也不可能从文件中读取 '\0'。
- read 的返回值 read 成功就返回它读到了多少个字节,0 表示读到文件结尾,-1 表示出错。写端不仅仅写,在写完后还会把写的文件描述符关闭,此时另一端再读就会读到 0。如果返回值大于 0,则读取成功,并追加 '\0'。如果返回值等于 0,则子进程不再继续写入了,而是关闭写文件描述符并退出。如果是其它情况,那么 read 失败,这里暂且不做处理。此时子进程不断的往管道写入数据,父进程不断的往管道读入数据到 buffer 并打印,每次循环都把 buffer 中的内容清空,以验证父进程的打印数据一定是从子进程中来的(读端从管道中成功读取数据之后,管道中的数据就会被置为无效,下次再写就会覆盖,后面会讲生产者消费模型)。
- 建议在父子进程通信完之后关闭文件对应的文件描述符。
为什么不选择定义全局 buffer 来进行通信呢?
因为有写时拷贝的存在,父子进程需要保证各自数据的私有性,再怎么样也无法更改通信。
【Linux 系统】进程间通信(匿名管道 & 命名管道)-- 详解(下)https://developer.aliyun.com/article/1515603?spm=a2c6h.13148508.setting.18.11104f0e63xoTy