本篇文章,继续与大家分享与Linux相关的知识。本次内容主要会涉及到什么是进程间通信,为什么要有进程间通信,怎么实现进程间通信,管道原理和管道的应用。
什么是进程间通信?
进程间通信就是两个或多个进程进行数据层面的交互。因为进程间独立性的存在,导致进程间通信的成本比较高。
为什么要有进程通信?
每个进程都有自己的任务,一个进程可能需要将它处理后的数据,交给另一个进程进行进一步的处理。或者是一个进程负责给其他的进程某送一个指令,让其他的进程指令。或者一个进程只是给,某个进程发送通知。为了满足以上这些需求,就有了进程通信。
怎么实现进程通信?
进程通信的本质是,让不同的进程看到同一块资源。这个资源,是特定形式的内存空间。
那这个资源由谁来提供?一般由操作系统来提供。为什么由操作系统来提供呢?因为如果由通信的进程来提供。那么,这份资源就属于提供这份资源的进程。而进程具有独立性,如果允许其他进程访问修改进程的资源,就破坏了进程独立性。所以,进程通信所使用的资源只能由操作系统来提供。
进程访问这份资源空间,进行通信。本质是在访问操作系统,而进程是用户的代表。操作系统不相信用户,用户中有坏人。所以,操作系统需要从底层设计,从接口设计好一套逻辑。然后,给我们提供一个个的系统调用接口。我们通过这些接口,来完成资源的创建,使用,和释放。
一般操作系统里,会有一个独立的通信模块,负责进程间的通信。它隶属于文件系统,也被成为IPC通信模块。
进程之间通信,这个进程发送的信息,怎么读取解析?是从前往后解析,还是从后往前。一次发送多少字节?这些都是问题?如果这家公司设置为10个字节,那家公司设置5个字节。那么,大家就没法一起玩了。不同厂商生产的电子产品,就没法相互通信。所以,我们需要定制标准,规范进程之间的通信方式。这样一来,不同厂商生产的电子产品就能实现通信了。
针对进程间通信,制定的标准有很多。但最后保留下来的只有两个标准。一个是system V,另一个是 posix。
system V是用于计算机自身内部通信的标准。posix是用于网络之间通信的标准。
除了system V和posix这两种标准规定的通信方式,还有一种基于文件级别的通信方式,我们称之为管道。
管道
原理
什么是管道呢?我们直接从它的原理入手。
通过前面文章的学习,我们知道,一个进程会有自己的task_struct。task_struct结构体里,会有一个指针,指向自己的文件描述表。文件描述表里有一个数组指针,指向一个数组。数组保存了进程所打开的文件。每个进程都会默认打开三个文件,标准输入输出。它们占用了数组的前三个位置,所以,我们打开的文件,分配的文件描述符从3开始。被打开的文件,会有自己的属性inode,自己的读写方法file_operators,自己的缓冲区,缓冲区中的内容会定期刷新到磁盘中。
如果我们把磁盘部分去掉,也就是文件的内容只保留在它的缓冲区中。这种文件,我们称之为内存级文件。
我们之前说过,Linux下一切皆文件,管道也是文件。进程通信的本质是让进程看到同一份资源。对于管道通信,也就是看到同一份文件。
那我们如何让不同的进程看到同一份文件呢?还记得创建子进程的原理吗?我们使用fork创建子进程。父子进程就天然的看见同一份文件。如果父进程是以r方式打开的文件。那么,子进程也相当于以r方式打开了文件。
刚刚我们是以读权限打开的文件。下面,我们先以读方式打开文件,再以写方式打开文件。然后,我们fork创建子进程,父子进程就看到了同一份资源,父子进程可以对这个文件资源进行读写。但我们通常会将,读取数据的进程的写端给关闭,把写数据的进程的读端给关闭。这样做,一方面是设计简单,另一方面是避免数据读写异常。假设我们子进程负责写数据,父进程负责读数据。那么,我们会把子进程的读端关闭,父进程的写端关闭。如此就实现了父子进程的通信,子进程向文件里写,父进程从文件里读。这个过程是单向的,就和我们生活中的自来水管道一样,水从管道的一端进入,从另一端流出。所以,我们将这种通信方式,称为管道通信。你会发现我们刚刚所形成的管道并没有名字,所以,也称匿名管道。如果你想实现双向通信,那么,你使用多个管道即可。
我们是通过创建子进程的方式,来让父子进程看到同一份资源的。也就是说,进程必须具有血缘关系,才能使用管道通信。
至此,我们也就理解了管道的原理。那怎么通信起来呢?
我们先来认识形成管道的接口。
接口
创建管道使用的函数是pipe。我们不难发现它的参数带了一个2。这是什么意思呢?带2是想告诉我们需要给它,传一个可容纳两个元素的整形数组。
这个数组是一个输出型参数,它会把形成管道所用到的文件描述符,以数组的形式返回给我们。数组下标为0的位置,是读端。数组下标为1的位置,是写端。我们可以这样记,0像一张嘴,所以是读端。1像一只笔,所以是写端。
我们的管道是有固定大小的,可能是64KB,也可能是其他的大小,这取决于内核的版本。
我们可以使用如下指令来查看,重要资源的限制大小
从显示结果看,管道的大小是8个513bity,也就是4KB。
可当我们查看man手册的第七章的pipe函数时
我们能确定管道是有固定大小的,但好像并不是所谓的4KB。它由版本来决定,我们的是65536byte。接着容量往后看,我们会看到一个PIPE_BUF的名词,它的大小刚好是4KB。它的解释中说,小于PIPE_BUF,读写一定是原子性的。这是什么意思?假设我们写入的内容是"hello world!"。在我们写端写入hello的时候,读端不能只读hello。读端只能等写端写完“hello word!”后,再把“hello world!”一起读取。这种操作就做原子性。当我们写入的内容大于4KB的时候,就没法保证原子性了。
到这里,你就明白了。下图,显示的不是管道的最大容量,而是管道能保证读写原子性的最大容量。
编码实现
说了这么多,我们对管道通信有了一个大致的认识。下面,我们来实践一下:
第一步:
写一个自动化工具makefile
第二步:
创建管道,并检查管道创建情况
ctrl+~,在vscode中调出终端,编译运行。我们就可以看到下图中的信息,我们的管道创建好了。读端是3,写端是4。
第三步:
将刚刚的测试代码注释,编写父子进程通信代码的基本框架。
第四步:
实现Writer函数和Reader函数。
Writer:
在Writer的实现中,我们会用到snprintf。这个函数是C语言提供的字符串级别格式的接口。
它的用法很简单,只是比printf函数多了两个参数。第一个参数,传递地址,告诉它数据往哪里写。第二个参数,传大小,也就是写入数据的那段空间的大小。简单来说就是,最多能写多少数据。后面的参数,就模仿printf参数的使用方式。
编译运行,我们能看到数据能正常写入buffer中
我们把测试代码注释掉,改为向文件里写入
Reader:
编译运行,我们就能看到父进程会收到一条一条的数据。我之所以让number++,是为了模拟不同的动态信息。
管道特征
我们打开脚本后,再运行程序。
我们不难发现,每隔一秒,父进程才打印一次。
可是,Reader函数中并没有sleep函数呀!为什么,父进程会等待一秒,再打印?这是因为父进程等了子进程。我们称这种情况为父子协同。
后面我们会学到,多线程。一个线程,就是一个执行流。多个执行流访问共享资源,还需要考虑同步互斥。管道这里也是一种共享资源,我们让父子进程只能单向通信,某种意义上,也是同步互斥。这些我们后面详细讲解。
父子协同,同步互斥,是为了保证管道数据的安全性。
对于管道中的数据,会出现如下四种情况:
第一种:读写端正常,管道如果为空,读端就要阻塞起来。这种情况就是我们刚刚程序运行的情况。
第二种:读写端正常,管道如果被写满了,写端就要阻塞起来。
我们可以简单验证一下:
修改Writer函数的代码,让子进程一个字节一个字节的写入,每写一次,number加1,并且打印number的值。
Reader函数中,增加一个sleep函数,休息50秒,别让父进程读取数据这么快。
编译运行,程序会快速打印很多的数字。过了一会,写端写满了数据,就不在写入了。也就是写端阻塞住了。
再过上几十秒,我们才能看到父进程向显示器写入数据。我们写入数据的时候,是一个字节一个字节写的。可为什么我们读数据的时候,是一次性全部读玩的呢?这是因为管道是面向字节流的。你想让它切割成一个一个字符的读取,它可不管那么多。在它看来,这些就是一个个的字节。1·
第三种:读端正常读,写端关闭。读端就会读到0,表明读到文件(pipe)的结尾,不会被阻塞。
我们简单的验证一下:
我们让Writer函数的while循环在number大于5的时候,break。也就是写端写入五次后关闭。
Reader函数增加一个输出,打印变量n的值。
编译运行,我们就能看到在写端关闭后,读端并不会被阻塞住。
第四种:写端正常写入,读端关闭了。操作系统就要杀掉正在写入的进程。如何杀掉呢?通过信号。是几号信号?13
我们可以简单验证一下:
我们需要做三步:
第一步:修改Writer函数
把Writer函数写入写数据的方式,改成一开始的方式,让子进程不断向管道写入内容。
第二步:修改Reader函数
在Reaader函数,增加一个变量cnt,用来计数,充当计数器。当它大于5的时候,我们结束循环。
第三步:修改main函数Reader掉用处往后的代码
我们把关闭读端的动作提前到子进程回收之前。在关闭父进程的读端后,我们打印相应的提示。再让父进程休息5秒后,再回收子进程。这样方便我们观察。回收子进程之后,我们打印子进程退出的信息。最后,再打印提示信息,告诉我们父进程退出了。
编译运行,打开叫脚本监控
我们就能看如下运行结果。父进程读了五次内容后,把读端关闭。子进程就被操作系统杀掉了,进入了僵尸状态。再过五秒,父进程就把子进程回收了,并得到了子进程的退出状态。子进程确实是被操作系统通过13号信号,杀掉了。再过五秒,父进程就退出了。
我们总结一下管道的特征:
1.具有血缘关系的进程,才能通过管道进行通信
2.管道只能单向通信
3.父子进程是会进程协同的,同步与互斥 --保护管道文件的数据安全
4.管道是面向字节流的
5.管道是基于文件的,而文件的生命周期是随进程的!进程结束,文件也就关闭了。
管道的应用场景
第一个应用场景:
如下指令使用的管道和,我们刚刚所说的匿名管道是什么关系呢?
我简单的写一个指令你就明白了。
指令:
脚本:
我写的这三个sleep指令,虽然不会产生什么数据,但第一个sleep指令的结果还是会转交给第二个sleep,第二个sleep处理后,再转给第三个sleep。通过监控结果来看,我们不难看出这三个sleep指令,由三个不同的进程来执行,并且它们具有同一个父进程,也就是具有血缘关系。具有血缘关系的进程之间的数据交互,这是谁特征?不就是我们刚刚学的管道吗?我们三个sleep中使用的管道,很明显没有名字吧!准确来说,我们指令中用的是匿名管道。
第二个应用场景:
还记得我们模拟的自定义shell吗?我们可以让它支持管道的功能。
怎么实现呢?
大致思路如下:
第一步:分析输入的命令字符串,统计管道的个数,将命令打散成多个子命令字符串。
第二步:malloc申请空间,pipe先申请多个管道。
第三步:循环创建多个子进程,针对子进程进行重定向。最开始:输出重定向,1->指定的一个管道的写端。中间,输入输出重定向,0标准输入重定向到上一个管道的读端,1标准输出重定向到下一个管道的写端。
第四步:分别让不同的子进程执行不同的命令 --- exec* -- exec*不会影响该进程曾经打开的文件,不会影响预先设计好的管道重定向。
具体实现,我们这就不做演示了。我们把主要精力放在下一个应用场景。
第三个应用场景
使用管道实现一个简易版本的进程池。
大家都知道在一些干旱的地区,十分缺水。村里的人每次取水,都要到十里外取水。每次取水都要走很长的路,成本很高。于是,就有人想。我们可不可以建一个蓄水池,然后,请一辆车,把水运到我们的蓄水池中。这样每次取水就直接到蓄水池取就可以了。这就是池化技术。我们每次执行指令都需要创建进程。调用系统掉用fork也是有成本的,所以,我们可以提前创建好一批进程,用数组保存起来,等指令来了,直接分配给进程进行执行即可。
进程池的用途,我们了解了。那如何实现呢?
思路
我们可以创建一批子进程,和一批管道。让父进程向管道的读端写内容,子进程再从管道的读端读内容。这样就可以实现一个任务派发的过程了。然后,子进程拿到相应的指令,执行就可以了。我们这里规定父进程每次只能向管道写4个字节,子进程每次只能从管道中读取4个字节。
在这个过程中,父进程就相当于master,总督的职位,负责派发任务。子进程就相当于员工,worker或者slaver,负责执行任务。
思路有了,我们直接开始实现。
实现
我们重新创建三个文件
一个是Makeflile,便于我们编译程序
一个是ProcessPool.cpp,用来模拟实现进程池。另一个是Task.hpp,用来模拟我们的任务
第一步:
父进程和每个子进程之间都有一个管道,我们该怎么把它们管理起来,是不是先组织,再描述。我们需要定义一个chnnel结构体来描述父子进程之间通信的管道信息,再用一个vector把它们组织起来
第二步:
初始化,让父子进程之间建立管道。
这里,我们先做个简单的测试。测试,管道的建立情况
编译运行,父子进程之间的通信管道建立成功
如果我们希望main函数的逻辑更为清晰,美观,我们可以把刚刚初始化部分的代码,用一个InitProcessPool函数封装起来。测试部分的代码用Debug函数封装起来
这里有一个格式规范,对于函数的参数,如果是输出型参数则用*、如果是输入型参数则用const和&、如果是输入输出型参数则用&
我们刚刚使用重定向后,就如下图所示。0号位置的文件描述符不再指向键盘文件,而是指向我们打开的管道文件。
第三步:
实现slaver函数,接受父进程发送的数据
第四步:
控制子进程,给子进程分配任务。
怎么分配呢?我们可以采用随机数的方式或者轮转遍历的方式。这两种方式都能做到让每个进程都会被调度到,而不是某个进程一直在执行任务,其他进程闲着。我们称这种情况为负载均衡。
我们先用随机数的方式来实现
编译运行,我们就可以看到不同的子进程能够收到父进程发来的任务码。
那我们的任务有什么呢?
第五步:
我们在Task.hpp中,简单设计几个任务
有了任务后,我们怎么用呢?首先,我们得在ProcessPool.cpp中定义一个全局指针数组存放任务
这个指针数组是不是还得初始化,所以,我们还得在Task.hpp中定义加载任务的函数LoadTask
初始化指针数组的方法有了,我们可以开始进行任务的分配了
编译运行,我们就看到父进程给子进程派发了刷新日志等任务
同样的,我们也可以把控制子进程的代码,给封装成一个ctrlSlaver函数
刚刚我们用的是随机数的方法,我们换成轮转玩玩。
编译运行,我们就能看到父子进程的交互了
如果你不想,随机派发任务还可以写个菜单,自己手动派发任务。
编译运行,就能做到手动派发任务了
好了,到这里,我们本次的分享就到此结束了,不知道我有没有说明白,给予你一点点收获。关于更多和Linux相关的知识,会在后面的文章更新。如果你有所收获,别忘了给我点个赞,这是对我最好的回馈,当然你也可以在评论发表一下你的收获和心得,亦或者指出我的不足之处。如果喜欢我的分享,别忘了给我点关注噢。