本篇文章,继续与大家分享与Linux相关的知识。本次内容主要会涉及到什么是进程间通信,为什么要有进程间通信,怎么实现进程间通信,管道原理和管道的应用。

什么是进程间通信?

进程间通信就是两个或多个进程进行数据层面的交互。因为进程间独立性的存在,导致进程间通信的成本比较高。

为什么要有进程通信?

每个进程都有自己的任务,一个进程可能需要将它处理后的数据,交给另一个进程进行进一步的处理。或者是一个进程负责给其他的进程某送一个指令,让其他的进程指令。或者一个进程只是给,某个进程发送通知。为了满足以上这些需求,就有了进程通信。

怎么实现进程通信?

进程通信的本质是,让不同的进程看到同一块资源。这个资源,是特定形式的内存空间

那这个资源由谁来提供?一般由操作系统来提供。为什么由操作系统来提供呢?因为如果由通信的进程来提供。那么,这份资源就属于提供这份资源的进程。而进程具有独立性,如果允许其他进程访问修改进程的资源,就破坏了进程独立性。所以,进程通信所使用的资源只能由操作系统来提供。

进程访问这份资源空间,进行通信。本质是在访问操作系统,而进程是用户的代表。操作系统不相信用户,用户中有坏人。所以,操作系统需要从底层设计,从接口设计好一套逻辑。然后,给我们提供一个个的系统调用接口。我们通过这些接口,来完成资源的创建,使用,和释放。

一般操作系统里,会有一个独立的通信模块,负责进程间的通信。它隶属文件系统,也被成为IPC通信模块

进程之间通信,这个进程发送的信息,怎么读取解析?是从前往后解析,还是从后往前。一次发送多少字节?这些都是问题?如果这家公司设置为10个字节,那家公司设置5个字节。那么,大家就没法一起玩了。不同厂商生产的电子产品,就没法相互通信。所以,我们需要定制标准,规范进程之间的通信方式。这样一来,不同厂商生产的电子产品就能实现通信了。

针对进程间通信,制定的标准有很多。但最后保留下来的只有两个标准。一个是system V,另一个是 posix。

system V是用于计算机自身内部通信的标准。posix是用于网络之间通信的标准。

Linux-进程间的通信之匿名管道_匿名管道

除了system V和posix这两种标准规定的通信方式,还有一种基于文件级别的通信方式,我们称之为管道

管道

原理

什么是管道呢?我们直接从它的原理入手。

通过前面文章的学习,我们知道,一个进程会有自己的task_struct。task_struct结构体里,会有一个指针,指向自己的文件描述表。文件描述表里有一个数组指针,指向一个数组。数组保存了进程所打开的文件。每个进程都会默认打开三个文件,标准输入输出。它们占用了数组的前三个位置,所以,我们打开的文件,分配的文件描述符从3开始。被打开的文件,会有自己的属性inode,自己的读写方法file_operators,自己的缓冲区,缓冲区中的内容会定期刷新到磁盘中。

如果我们把磁盘部分去掉,也就是文件的内容只保留在它的缓冲区中。这种文件,我们称之为内存级文件

我们之前说过,Linux下一切皆文件,管道也是文件。进程通信的本质是让进程看到同一份资源。对于管道通信,也就是看到同一份文件。

那我们如何让不同的进程看到同一份文件呢?还记得创建子进程的原理吗?我们使用fork创建子进程。父子进程就天然的看见同一份文件。如果父进程是以r方式打开的文件。那么,子进程也相当于以r方式打开了文件。

Linux-进程间的通信之匿名管道_管道的原理_02

刚刚我们是以读权限打开的文件。下面,我们先以读方式打开文件,再以写方式打开文件。然后,我们fork创建子进程,父子进程就看到了同一份资源,父子进程可以对这个文件资源进行读写。但我们通常会将,读取数据的进程的写端给关闭,把写数据的进程的读端给关闭。这样做,一方面是设计简单,另一方面是避免数据读写异常。假设我们子进程负责写数据,父进程负责读数据。那么,我们会把子进程的读端关闭,父进程的写端关闭。如此就实现了父子进程的通信,子进程向文件里写,父进程从文件里读。这个过程是单向的,就和我们生活中的自来水管道一样,水从管道的一端进入,从另一端流出。所以,我们将这种通信方式,称为管道通信。你会发现我们刚刚所形成的管道并没有名字,所以,也称匿名管道。如果你想实现双向通信,那么,你使用多个管道即可。

我们是通过创建子进程的方式,来让父子进程看到同一份资源的。也就是说,进程必须具有血缘关系,才能使用管道通信。

Linux-进程间的通信之匿名管道_匿名管道_03

至此,我们也就理解了管道的原理。那怎么通信起来呢?

我们先来认识形成管道的接口。

接口

创建管道使用的函数是pipe。我们不难发现它的参数带了一个2。这是什么意思呢?带2是想告诉我们需要给它,传一个可容纳两个元素的整形数组。

Linux-进程间的通信之匿名管道_管道的运用_04

这个数组是一个输出型参数,它会把形成管道所用到的文件描述符,以数组的形式返回给我们。数组标为0的位置,是读端。数组下标为1的位置,是写端。我们可以这样记,0像一张嘴,所以是读端。1像一只笔,所以是写端。

我们的管道是有固定大小的,可能是64KB,也可能是其他的大小,这取决于内核的版本。

Linux-进程间的通信之匿名管道_进程间通信_05

我们可以使用如下指令来查看,重要资源的限制大小

ulimit -a

Linux-进程间的通信之匿名管道_进程间通信_06

从显示结果看,管道的大小是8个513bity,也就是4KB。

可当我们查看man手册的第七章的pipe函数时

man 7 pipe

我们能确定管道是有固定大小的,但好像并不是所谓的4KB。它由版本来决定,我们的是65536byte。接着容量往后看,我们会看到一个PIPE_BUF的名词,它的大小刚好是4KB。它的解释中说,小于PIPE_BUF,读写一定是原子性的。这是什么意思?假设我们写入的内容是"hello world!"。在我们写端写入hello的时候,读端不能只读hello。读端只能等写端写完“hello word!”后,再把“hello world!”一起读取。这种操作就做原子性。当我们写入的内容大于4KB的时候,就没法保证原子性了。

Linux-进程间的通信之匿名管道_进程间通信_07

到这里,你就明白了。下图,显示的不是管道的最大容量,而是管道能保证读写原子性的最大容量。

Linux-进程间的通信之匿名管道_进程间通信_06

编码实现

说了这么多,我们对管道通信有了一个大致的认识。下面,我们来实践一下:

第一步:

写一个自动化工具makefile

Linux-进程间的通信之匿名管道_管道的运用_09

第二步:

创建管道,并检查管道创建情况

Linux-进程间的通信之匿名管道_管道的运用_10

ctrl+~,在vscode中调出终端,编译运行。我们就可以看到下图中的信息,我们的管道创建好了。读端是3,写端是4。

Linux-进程间的通信之匿名管道_管道的运用_11

第三步:

将刚刚的测试代码注释,编写父子进程通信代码的基本框架。

Linux-进程间的通信之匿名管道_进程间通信_12

第四步:

实现Writer函数和Reader函数。

Writer:

在Writer的实现中,我们会用到snprintf。这个函数是C语言提供的字符串级别格式的接口。

它的用法很简单,只是比printf函数多了两个参数。第一个参数,传递地址,告诉它数据往哪里写。第二个参数,传大小,也就是写入数据的那段空间的大小。简单来说就是,最多能写多少数据。后面的参数,就模仿printf参数的使用方式。

Linux-进程间的通信之匿名管道_匿名管道_13

Linux-进程间的通信之匿名管道_匿名管道_14

编译运行,我们能看到数据能正常写入buffer中

Linux-进程间的通信之匿名管道_管道的原理_15

我们把测试代码注释掉,改为向文件里写入

Linux-进程间的通信之匿名管道_管道的原理_16

Reader:

Linux-进程间的通信之匿名管道_匿名管道_17

编译运行,我们就能看到父进程会收到一条一条的数据。我之所以让number++,是为了模拟不同的动态信息。

Linux-进程间的通信之匿名管道_管道的运用_18

管道特征

我们打开脚本后,再运行程序。

while :; do ps -axj | grep testPipe | grep -v grep ; sleep 1; echo "##############"; done;

我们不难发现,每隔一秒,父进程才打印一次。

Linux-进程间的通信之匿名管道_管道的运用_19

可是,Reader函数中并没有sleep函数呀!为什么,父进程会等待一秒,再打印?这是因为父进程等了子进程。我们称这种情况为父子协同

Linux-进程间的通信之匿名管道_进程间通信_20

后面我们会学到,多线程。一个线程,就是一个执行流。多个执行流访问共享资源,还需要考虑同步互斥。管道这里也是一种共享资源,我们让父子进程只能单向通信,某种意义上,也是同步互斥。这些我们后面详细讲解。

父子协同,同步互斥,是为了保证管道数据的安全性。

对于管道中的数据,会出现如下四种情况:

第一种:读写端正常,管道如果为空,读端就要阻塞起来。这种情况就是我们刚刚程序运行的情况。

第二种:读写端正常,管道如果被写满了,写端就要阻塞起来。

我们可以简单验证一下:

修改Writer函数的代码,让子进程一个字节一个字节的写入,每写一次,number加1,并且打印number的值。

Reader函数中,增加一个sleep函数,休息50秒,别让父进程读取数据这么快。

Linux-进程间的通信之匿名管道_管道的原理_21

编译运行,程序会快速打印很多的数字。过了一会,写端写满了数据,就不在写入了。也就是写端阻塞住了。

Linux-进程间的通信之匿名管道_管道的原理_22

再过上几十秒,我们才能看到父进程向显示器写入数据。我们写入数据的时候,是一个字节一个字节写的。可为什么我们读数据的时候,是一次性全部读玩的呢?这是因为管道是面向字节流的。你想让它切割成一个一个字符的读取,它可不管那么多。在它看来,这些就是一个个的字节。1·

Linux-进程间的通信之匿名管道_进程间通信_23

第三种:读端正常读,写端关闭。读端就会读到0,表明读到文件(pipe)的结尾,不会被阻塞。

我们简单的验证一下:

我们让Writer函数的while循环在number大于5的时候,break。也就是写端写入五次后关闭。

Reader函数增加一个输出,打印变量n的值。

Linux-进程间的通信之匿名管道_管道的原理_24

编译运行,我们就能看到在写端关闭后,读端并不会被阻塞住。

Linux-进程间的通信之匿名管道_匿名管道_25

第四种:写端正常写入,读端关闭了。操作系统就要杀掉正在写入的进程。如何杀掉呢?通过信号。是几号信号?13

我们可以简单验证一下:

我们需要做三步:

第一步:修改Writer函数

把Writer函数写入写数据的方式,改成一开始的方式,让子进程不断向管道写入内容

Linux-进程间的通信之匿名管道_匿名管道_26

第二步:修改Reader函数

在Reaader函数,增加一个变量cnt,用来计数,充当计数器。当它大于5的时候,我们结束循环。

Linux-进程间的通信之匿名管道_管道的运用_27

第三步:修改main函数Reader掉用处往后的代码

我们把关闭读端的动作提前到子进程回收之前。在关闭父进程的读端后,我们打印相应的提示。再让父进程休息5秒后,再回收子进程。这样方便我们观察。回收子进程之后,我们打印子进程退出的信息。最后,再打印提示信息,告诉我们父进程退出了。

Linux-进程间的通信之匿名管道_管道的运用_28

编译运行,打开叫脚本监控

while :; do ps axj | head -1 && ps axj | grep testPipe | grep -v grep; sleep 1; done;

我们就能看如下运行结果。父进程读了五次内容后,把读端关闭。子进程就被操作系统杀掉了,进入了僵尸状态。再过五秒,父进程就把子进程回收了,并得到了子进程的退出状态。子进程确实是被操作系统通过13号信号,杀掉了。再过五秒,父进程就退出了。

Linux-进程间的通信之匿名管道_管道的运用_29

我们总结一下管道的特征

1.具有血缘关系的进程,才能通过管道进行通信

2.管道只能单向通信

3.父子进程是会进程协同的,同步与互斥 --保护管道文件的数据安全

4.管道是面向字节流的

5.管道是基于文件的,而文件的生命周期是随进程的!进程结束,文件也就关闭了。

Linux-进程间的通信之匿名管道_管道的运用_30

管道的应用场景

第一个应用场景:

如下指令使用的管道和,我们刚刚所说的匿名管道是什么关系呢?

cat test.txt | head -10 | tail -5

我简单的写一个指令你就明白了。

指令:

sleep 666666 | sleep 77777 | sleep 88888

脚本:

while :; do ps axj | head -1 && ps axj | grep sleep | grep -v grep; sleep 1; done;

我写的这三个sleep指令,虽然不会产生什么数据,但第一个sleep指令的结果还是会转交给第二个sleep,第二个sleep处理后,再转给第三个sleep。通过监控结果来看,我们不难看出这三个sleep指令,由三个不同的进程来执行,并且它们具有同一个父进程,也就是具有血缘关系。具有血缘关系的进程之间的数据交互,这是谁特征?不就是我们刚刚学的管道吗?我们三个sleep中使用的管道,很明显没有名字吧!准确来说,我们指令中用的是匿名管道

Linux-进程间的通信之匿名管道_匿名管道_31

第二个应用场景:

还记得我们模拟的自定义shell吗?我们可以让它支持管道的功能。

怎么实现呢?

大致思路如下:

第一步:分析输入的命令字符串,统计管道的个数,将命令打散成多个子命令字符串。

第二步:malloc申请空间,pipe先申请多个管道。

第三步:循环创建多个子进程,针对子进程进行重定向。最开始:输出重定向,1->指定的一个管道的写端。中间,输入输出重定向,0标准输入重定向到上一个管道的读端,1标准输出重定向到下一个管道的写端。

 第四步:分别让不同的子进程执行不同的命令 --- exec* -- exec*不会影响该进程曾经打开的文件,不会影响预先设计好的管道重定向。

Linux-进程间的通信之匿名管道_管道的运用_32

具体实现,我们这就不做演示了。我们把主要精力放在下一个应用场景。

第三个应用场景

使用管道实现一个简易版本的进程池。

大家都知道在一些干旱的地区,十分缺水。村里的人每次取水,都要到十里外取水。每次取水都要走很长的路,成本很高。于是,就有人想。我们可不可以建一个蓄水池,然后,请一辆车,把水运到我们的蓄水池中。这样每次取水就直接到蓄水池取就可以了。这就是池化技术。我们每次执行指令都需要创建进程。调用系统掉用fork也是有成本的,所以,我们可以提前创建好一批进程,用数组保存起来,等指令来了,直接分配给进程进行执行即可。

进程池的用途,我们了解了。那如何实现呢?

思路

我们可以创建一批子进程,和一批管道。让父进程向管道的读端写内容,子进程再从管道的读端读内容。这样就可以实现一个任务派发的过程了。然后,子进程拿到相应的指令,执行就可以了。我们这里规定父进程每次只能向管道写4个字节,子进程每次只能从管道中读取4个字节。

Linux-进程间的通信之匿名管道_进程间通信_33

在这个过程中,父进程就相当于master,总督的职位,负责派发任务。子进程就相当于员工,worker或者slaver,负责执行任务。

思路有了,我们直接开始实现。

实现

我们重新创建三个文件

一个是Makeflile,便于我们编译程序

Linux-进程间的通信之匿名管道_管道的运用_34

一个是ProcessPool.cpp,用来模拟实现进程池。另一个是Task.hpp,用来模拟我们的任务

第一步

父进程和每个子进程之间都有一个管道,我们该怎么把它们管理起来,是不是先组织,再描述。我们需要定义一个chnnel结构体来描述父子进程之间通信的管道信息,再用一个vector把它们组织起来

Linux-进程间的通信之匿名管道_管道的原理_35

第二步

初始化,让父子进程之间建立管道。

Linux-进程间的通信之匿名管道_匿名管道_36

这里,我们先做个简单的测试。测试,管道的建立情况

Linux-进程间的通信之匿名管道_管道的运用_37

编译运行,父子进程之间的通信管道建立成功

Linux-进程间的通信之匿名管道_匿名管道_38

如果我们希望main函数的逻辑更为清晰,美观,我们可以把刚刚初始化部分的代码,用一个InitProcessPool函数封装起来。测试部分的代码用Debug函数封装起来

Linux-进程间的通信之匿名管道_进程间通信_39

Linux-进程间的通信之匿名管道_进程间通信_40

这里有一个格式规范,对于函数的参数,如果是输出型参数则用*、如果是输入型参数则用const和&、如果是输入输出型参数则用&

Linux-进程间的通信之匿名管道_进程间通信_41

我们刚刚使用重定向后,就如下图所示。0号位置的文件描述符不再指向键盘文件,而是指向我们打开的管道文件。

Linux-进程间的通信之匿名管道_进程间通信_42

第三步

实现slaver函数,接受父进程发送的数据

Linux-进程间的通信之匿名管道_管道的原理_43

第四步

控制子进程,给子进程分配任务。

怎么分配呢?我们可以采用随机数的方式或者轮转遍历的方式。这两种方式都能做到让每个进程都会被调度到,而不是某个进程一直在执行任务,其他进程闲着。我们称这种情况为负载均衡

Linux-进程间的通信之匿名管道_管道的原理_44

我们先用随机数的方式来实现

Linux-进程间的通信之匿名管道_进程间通信_45

编译运行,我们就可以看到不同的子进程能够收到父进程发来的任务码。

Linux-进程间的通信之匿名管道_进程间通信_46

那我们的任务有什么呢?

第五步

我们在Task.hpp中,简单设计几个任务

Linux-进程间的通信之匿名管道_匿名管道_47

有了任务后,我们怎么用呢?首先,我们得在ProcessPool.cpp中定义一个全局指针数组存放任务

Linux-进程间的通信之匿名管道_管道的运用_48

这个指针数组是不是还得初始化,所以,我们还得在Task.hpp中定义加载任务的函数LoadTask

Linux-进程间的通信之匿名管道_管道的运用_49

初始化指针数组的方法有了,我们可以开始进行任务的分配了

Linux-进程间的通信之匿名管道_匿名管道_50

编译运行,我们就看到父进程给子进程派发了刷新日志等任务

Linux-进程间的通信之匿名管道_管道的运用_51

同样的,我们也可以把控制子进程的代码,给封装成一个ctrlSlaver函数

Linux-进程间的通信之匿名管道_管道的运用_52

刚刚我们用的是随机数的方法,我们换成轮转玩玩。

Linux-进程间的通信之匿名管道_管道的运用_53

编译运行,我们就能看到父子进程的交互了

Linux-进程间的通信之匿名管道_匿名管道_54

如果你不想,随机派发任务还可以写个菜单,自己手动派发任务。

Linux-进程间的通信之匿名管道_进程间通信_55

编译运行,就能做到手动派发任务了

Linux-进程间的通信之匿名管道_管道的运用_56

第六步

清理收尾,将我们所创建的进程回收,并释放我们申请的空间。

这个比较简单,这里我就直接封装成QuitProcess函数了

Linux-进程间的通信之匿名管道_管道的运用_57

我们前面说过,关闭写端,读端并不会阻塞住,会继续执行。所以,我们需要在读端读到0的时候,让子进程退出

Linux-进程间的通信之匿名管道_匿名管道_58

为了便于我们观察,我们在子进程退出的时候,再打印一个提示信息

Linux-进程间的通信之匿名管道_匿名管道_59

我们能不能正常回收呢?编译运行,输入0,我们就能看到子进程都被回收了

Linux-进程间的通信之匿名管道_匿名管道_60

我们刚刚回收子进程和关闭读端,用了两个for循环,那我们能不能用一个for循环呢?

Linux-进程间的通信之匿名管道_管道的原理_61

编译运行,我们输入0,程序没有正常退出,而是被阻塞住了。

Linux-进程间的通信之匿名管道_进程间通信_62

我们打开脚本一看

while :; do ps axj | head -1 && ps axj | grep ProcessPool | grep -v grep; sleep 1; done;

就能发现11个进程都没有退出

Linux-进程间的通信之匿名管道_管道的运用_63

这是什么原因呢

这是因为父进程在创建子进程的时候,会把自己文件描述符表的内容拷贝到子进程的文件描述表。父子进程的文件描述符表是一样的,这就会导致父进程的1号描述符指向管道写端,子进程的1号描述符也会指向管道写端。指向写端的不止一个父进程还有子进程。所以,我们关闭父进程的写端,子进程并不会退出。

我们能正常的进行父进程交互数据,是因为只有父进程写,子进程读。

那为什么我们,先关闭所有写端,再回收子进程,就不会阻塞住呢

这是因为最后一个进程的写端只有父进程指向,关闭了这个写端,最后一个进程就会退出。它退出了,倒数第二个进程的写端也就全部关闭了。倒数第二个进程,也会随着退出。再以此类推,所有的子进程就退出了。

Linux-进程间的通信之匿名管道_进程间通信_64

所有子进程退出的顺序是从后往前的。

所以,我们使用一个for循环回收所有子进程的第一个方案就是从后往前回收。

Linux-进程间的通信之匿名管道_管道的运用_65

编译运行,输入0,就可以正常回收了

Linux-进程间的通信之匿名管道_管道的运用_66

如果我们就想像下面这样回收呢?

Linux-进程间的通信之匿名管道_匿名管道_67

方法二:保证父子进程之间的写端只有一个。怎么保证?在创建子进程的时候,让子进程把多余的写端关闭即可。

Linux-进程间的通信之匿名管道_管道的运用_68

编译运行,我们就能看到多余的写端被关闭了。我们输入0,程序也能正常退出回收。

Linux-进程间的通信之匿名管道_匿名管道_69

好了,到这里,我们本次的分享就到此结束了,不知道我有没有说明白,给予你一点点收获。关于更多和Linux相关的知识,会在后面的文章更新。如果你有所收获,别忘了给我点个赞,这是对我最好的回馈,当然你也可以在评论发表一下你的收获和心得,亦或者指出我的不足之处。如果喜欢我的分享,别忘了给我点关注噢。