⚪前言
如何理解进程间通信?
进程具有独立性,所以进程想要通信难度是比较大的,成本高。
在日常生活中,通信的本质是传递信息,但站在程序员角度来看,进程间通信的本质:让不同的进程看到同一份资源(内存空间)。
进程间通信就是进程之间互相传递数据,那么进程间能直接相互传递数据吗?
不能,因为进程具有独立性,所有的数据操作都会发生写时拷贝,父子进程都不能传递,更不要说两个进程毫无关系还想直接相互传递数据。
所以两个进程如果想要通信就一定要通过中间媒介的方式来进行通信,那么就必须先想办法让不同的进程看到同一份公共的资源,这里所谓公共的资源就是系统通过某种方式提供的系统内存。 这块空间通常是由操作系统提供的,可以被两个不同的进程都看到,然后它们才能实现通信。
传递数据就是由一个进程拷到对应的内存里,这块内存另一个进程当然也能看到,所以也自然能从内存里拷到自己的进程中。
综上所述,我们就知道了进程间通信要学的就是如何通过系统,让不同的进程看到同一份资源。操作系统提供的通信方案有很多种,这句话的含义就是操作系统让不同进程看到同一份资源的方式有很多种,最典型的有管道、消息队列、共享内存、信号量等等。下面主要谈管道和共享内存,而信号量会在后面多线程的部分再展开,这里主要以概念为主。
所以进程间通信的本质就是让不同的进程,能看到同一份系统资源,而这份资源就是系统通过某种方式提供的系统内存,因为方式是有差别的,所以通信策略也是有差别的。
一、进程间通信介绍
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代码 ------ 站在文件描述符角度深度理解管道)
cpp
#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 来进行通信呢?
因为有写时拷贝的存在,父子进程需要保证各自数据的私有性,再怎么样也无法更改通信。
4、管道的特点
(1)管道只能用于具有血缘关系的进程进行通信,它常用父子进程通信
通常,一个管道由一个进程创建,然后该进程调用 fork(),此后父子进程之间就可应用该管道。
(2)管道为了让进程间协同,提供了访问控制(管道自带同步机制)
父进程写完数据后休眠 1s,而子进程却没有,子进程一瞬间就把数据读完了,在父进程休眠的那 1s 内,子进程在干什么呢?
管道和显示器都是一个文件。为什么之前父子进程同时往显示器打印写入时没有出现这样的情况?这种情况称为缺乏访问控制,而当前这个问题具有访问控制。
此时管道中并没有数据,子进程在进行等待管道内部有数据就绪,这需要写端造成。
如果此时父进程一直往管道里写,而子进程休眠上 20s 呢?
父进程写到 3972 次时就没再继续写了。换而言之,如果管道里写端已经写满了,此时是不能再继续写入的,而写端就在等待管道内部有空闲空间,这需要读端造成。
综上而言,通信双方在管道中,如果其中一方不写了,另一方把数据读完后就必须等待对方写入才可以继续读;反之如果一方写满了,另一方不读,那么一方就必须等待另一方读取后才可以继续写。这种特性就叫做进程间同步,它们两方必须得通过某种同步机制来保证数据安全:管道是内存空间。如果一方不写,另一方还在那读,那么读到的数据肯定是垃圾数据;同样,如果一方一直写入,但另外一方却不读,那就可能会导致原来的数据被覆盖。
进程间同步其实是一种保护临界资源的一种处理方案,后面会再详细介绍。
(3)管道提供面向流式的通信服务 ------ 面向字节流
这里先简单理解一下,更进一步理解需要后面学习到网络部分。
流是什么?
下面是一段缓冲区,那么一定要有人去缓冲区中写入和读取。流就是想按几个字节就按几个字节写,想按几个字节读就按几个字节读。像这样的缓冲区对于读和写而言,就是字节流。
(4)管道是基于文件的
一般而言,进程退出,管道释放,所以管道的生命是随进程的,文件的生命周期也是随进程的。
(5)管道是单向通信的
管道是单向通信的,其本质是半双工通信的一种特殊情况。
管道是半双工的,数据只能向一个方向流动;当需要双方通信时,需要建立起两个管道。
举一个生活中的例子:人与人之间交流时一般是半双工(一个人说,一个人听),而在吵架时可能就是全双工(两个人都在说,也都在听)。
(6)管道能够保证一定程度的数据读取的原子性
如果往管道写 hello world,刚准备写 world,而 hello 就被读走了,此时就不能保证原子性。这里的一定程度一般指的是 4kb。
原子性的详细介绍主要是在后面的多线程部分。
4、管道的读写规则
当没有数据可读时:
- O_NONBLOCK disable:read 调用阻塞,即进程暂停执行,一直等到有数据来到为止。
- O_NONBLOCK enable:read 调用返回 -1,errno 值为 EAGAIN。
当管道满的时候:
- O_NONBLOCK disable: write 调用阻塞,直到有进程读走数据。
- O_NONBLOCK enable:调用返回 -1,errno 值为 EAGAIN。
- 如果所有管道写端对应的文件描述符被关闭,则 read 返回 0。
- 如果所有管道读端对应的文件描述符被关闭,则 write 操作会产生信号 SIGPIPE,进而可能导致 write 进程退出。
当要写入的数据量不大于 PIPE_BUF 时,linux 将保证写入的原子性。
- 当要写入的数据量大于 PIPE_BUF 时,linux 将不再保证写入的原子性。
⚪验证部分特性
a. 读写端测试
如果读端关闭,写端一直写,肯定是没有意义的,其本质就是在浪费资源,所以这个时候写进程会立马被 OS 通过发送信号的方式终止。而写进程是子进程,此时父进程就可以 waitpid,从而知道子进程退出的原因。这里可以看到唯一还没有研究的正是 write 端写和 read 端不读&关闭,这也就是为什么要让子进程写,父进程读的原因,因为它更适合测试。
此时子进程不断的写,而父进程读取一次后就关闭读,子进程再写,OS 就发送 13 号信号终止了进程。
b. 单机版的负载均衡
运行效果:
(1)管道的大小
a. 子进程一直写,每次写一个字节,然后计数器统计,父进程不要读。可以看到结果是 65536 byte,也就是管道的大小是 64kb,不过操作系统不同数据可能不一样
b. ulimit -a 查看系统资源
这里通过计算器算出来结果其实也才 4 kb,而实践出来却是 64kb。这里的 64kb 是当前云服务器管道的最大容量,而这里的 4kb 只是以原子性写入管道中的单元大小(可以通过 man 7 pipe 手册进行查看,可以看到 PIPE_BUF 是 4096 byte(4kb),只要在这个范围内就都是原子的)。
5、命名管道 fifo
命名管道是一种特殊类型的文件。
命名管道是供毫不相关的进程进行进程间通信。命名管道一般叫做 fifo,fifo 一定不陌生,因为数据结构中队列就是这种特性。
(1)理解命名管道的原理
要让两个毫不相干的进程进行通信,首先一定是要保证这两个进程可以看到同一份资源。因为需要相同的文件路径,所以让进程 1 和 进程 2 分别以读写的方式打开同一路径下的文件,此时内存中一定会包含 struct file 结构体,以及该文件所对应的缓冲区。那么此时进程 2 把数据写到缓冲区中,进程 1 就可以进行读取。
命名管道也是管道,它也遵守管道的面向字节流,同步机制,单向通信等特点。唯一和匿名管道不同的是它可以和不相关的进程进行通信。
对于普通文件来说,是需要将数据刷新到磁盘上持久化存储的,所以它就应该要把写入的数据刷新到磁盘上。换而言之,进程 2 把文件打开写数据到磁盘然后关闭,进程 1 再从磁盘读取,那么这当然可以通信,但是数据放在磁盘上的效率就太低了,便没有什么价值。
所以系统中就存在一种特殊的文件 ------ 管道文件,虽然它也有路径标识,但是系统不会把它对应的内存数据刷新到磁盘上,既然它是文件,那么它就一定有自己的名字,且它一定在系统路径中。而路径是唯一的,那么双方进程就可以通过管道文件的路径看到同一份资源。
(2)创建命名管道
a. 命令行创建
cpp
mkfifo filename
b. 在程序里创建
cpp
int mkfifo(const char *filename,mode_t mode);
(3)用命名管道实现server & client通信
A. mkfifo name_pipe
创建完管道文件之后,向管道里面写入内容,但是因为对方还没打开,此时处于阻塞状态。
一方写入,另一方读取,就可以看到所写入的内容了。这就叫作一个进程向另一个进程写入消息的过程。
name_pipe 就是一个管道文件,此时往文件中写入数据后,可以发现它的大小依旧是 0,因为数据只会在内存中,不会往磁盘刷。
B. 一边不断的往管道里写数据,另一边以管道作为标准输入然后输出重定向到 cat,最后显示出来
也可以说是 cat 从管道中把数据读取出来,这就完成了两个进程之间的通信。
C. 代码
a. 准备工作
想要 make 后一次生成两个不相关的可执行程序,需要我们在开头的时候定义 all 伪目标,它依赖的是两个可执行程序,没有依赖方法(因为它有依赖关系,所以 makefile 会推导 client 和 server 是怎么形成的)。
虽然 makefile 这样的技术已经很老了,但是它很稳定,几乎是现在主流的各种各样的工具的基础。虽然实际在公司并不会自己写 makefile(除非自己写测试代码),公司一般都有很多工具来自动生成 makefile,但是必要的 makefile 编写还是需要我们了解的,因为上层的工具和 makefile 有关系。
b. mkfifo 函数
mkfifo 既是命令 ,也是一个库函数。
第一个参数是命名管道的路径 ,第二个参数是命名管道的权限。成功返回 0,失败返回 -1。
c. 实现管道通信
此时代码中的 mkfifo 和命令中的 mkfifo 达到的效果是一样的。
下面 client.c 中以写打开管道文件,然后从键盘读取数据到 buffer,然后在往管道中写入 buffer 中的数据。然后 server.c 以读打开管道文件,把数据往 buffer 中读,然后再打印 buffer 中的数据。
【单个进程实现方式】
结果显示:
【多个子进程实现方式】
显示结果:
当前只有一个子进程:
一个管道可以有多个读端,它是单向通信的。
6、命名管道的打开规则
如果当前打开操作是为读而打开 FIFO 时:
- O_NONBLOCK disable:阻塞直到有相应进程为写而打开该 FIFO。
- O_NONBLOCK enable:立刻返回成功。
如果当前打开操作是为写而打开 FIFO 时:
- O_NONBLOCK disable:阻塞直到有相应进程为读而打开该 FIFO。
- O_NONBLOCK enable:立刻返回失败,错误码为 ENXIO。
7、匿名管道与命名管道的比较
- 匿名管道是供具有血缘关系的进程进行进程间通信;命名管道可供非具有血缘关系的进程进行进程间通信。
- 让不同进程看到同一份资源的手段不一样,匿名管道是通过子进程继承的方式(父子共享文件的特征让进程看到同一份资源);命名管道是通过打开同一目录的方式(命名管道是文件路径具有唯一性的特征)。
- pipe 创建的管道文件因为没有名字,所以它只能在在内存上;fifo 创建的管道文件有名字,所以它在磁盘上,只不过不会把数据写到磁盘上。
- FIFO(命名管道)与 pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。匿名管道由 pipe 函数创建并打开,命名管道由 mkfifo 函数创建,打开用 open。