作者:小树苗渴望变成参天大树
作者宣言:认真写好每一篇博客
作者gitee:gitee✨
作者专栏:C语言,数据结构初阶,Linux,C++ 动态规划算法
如 果 你 喜 欢 作 者 的 文 章 ,就 给 作 者 点 点 关 注 吧!
讲解逻辑:
今天我们开始讲解进程间通信,我们之前讲过进程具有独立性,那么有的时候进程还是需要进行一些数据性的交换,但是又不能破怪独立性,这两者看着自相矛盾,但又不冲突,博主就是来带大家去解决这个问题,从原理到模拟实现一个进程间通信的程序,需要大家对之前的进程创建,进程等待,尤其是文件系统那一章节熟悉,那今天的内容才容易理解,所以希望没有这些知识储备的小伙伴可以先看我前面的博客讲解,再来看这篇,效果会更好,接下来我们开始进入正文的讲解。
简单的来说就是两个或者多个进程实现数据层面的交互,因为进程独立性的存在,导致了进程间通信的成本比较高,所以在一会的讲解过程种,大家可能会觉得进程间的通信挺费劲的,这都是情理之中的。
以两个进程间通信为例:由于进程间是独立性的,想要实现通信,需要找一个公共的资源,让这两个进程看到同一份资源,但这份资源又不属于这两个进程的任意一个,这样就不会破坏两者的独立性。
(1)“资源”指的是什么??是一块特定的内存空间。
(2) 这个“资源”谁提供??一般情况下os提供。
为什么不是两个进程的其中一个,假设是其中一个,另一个读取这个数据或者修改,就对拥有这个资源的进程产生影响,破坏独立性,用反证法也可以论述我开头说的第一句话。
(3) 由上面两点我们得出结论,我们进程通过访问这个“资源”,也就是这一块内存空间,进行通信,本质就是在访问os,我们的进程是通过用户编写代码形成可执行程序,形成进程,运行在os上,所以可以间接认为进程代表的就是用户,既然是用户,os系统最不信任的其实就是用户,所以在进程间进行通信的过程,就是用户之间进程通信,中间os从创建这个‘’资源‘’,使用,释放一般(今天博主讲的方式是要通过系统接口去调用,但是其他方式可能不需要)都需要通过系统接口去调用,从底层设计,这些接口设计,都需要os独立去设计的,一般操作系统都会设计一个独立的通信模块(IPC通信模块),归属于文件系统。一会再来介绍为什么归属于文件系统,这些通信之间想要实现再任何一台主机,或者不同的主机上运行,就必须采取同一种方式,就是制定一套标准,再网络部分也有标准由了这些标准才有了我们现在的互联网。
上面的一切都是我们之前学过的知识来推测出来进程间通信会这样搞的,这也更好的衔接我们的知识,那我们有哪些标准,对于本机内部(system V),对于网络(posix)
system V:
- System V 消息队列
- System V 共享内存
- System V 信号量
posix:
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
对于system V,只讲第二点,其余两种不介绍,对于posix我们等到讲解网络的时候再讲,现在知道我们有这两个标准就好了。
管道
还有一种通信方式就是管道,这种通信方式可以说非常的简单,因为他是基于文件级别的通信方式,目前也可以简单理解复用了文件的那一套。他的大致想法是,进程管理系统和文件系统这两者是独立的,进程之间打开相同的文件,只不过把这个文件的引用计数改变一下而已,这个文件不属于两个进程中的任意一个。通过对文件的读写来交换数据,这样就保证了进程之间是独立的。
但是里面的细节还是比较多,接下来我们开始进入下一个话题。
什么是管道:
管道是Unix中最古老的进程间通信的形式。
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道
再我们命令行之前使用|来表示管道,每条命令就是一个进程
管道的特点就是一头进一头出,是单向的,一会介绍也是单向通信方式,下面介绍的是匿名管道
因为管道是基于文件级别的通信方式,我们刚讲解完文件系统没多久,所以大家能更好的理解。以父子进程为例:因为我们现在没有办法控制两个没有关系的进程,所以使用父子进程(这也是一个铺垫)。
我们先来讲解原理一:来看图解(解释是怎么让不同的进程看到同一份资源)
讲解原理二:来看图解(单向通信的设计)
通过上面的原理介绍,我们还有问题:
至此我们管道的原理就讲解完毕,接下来我们去使用一些接口来进行测试一下。
我们来认识一个系统调用接口pipe,来看文档:
pipe就是建立进程间通信的,按照刚才的分析,自己写的程序出来默认打开的三个文件,就没有再打开其他文件,如果使用pipe建立信道,那么pipe会在内存给我们创建一个内存级文件,传进去的参数pipefd数组的返回就会带出两个文件描述符3和4,一会就来测试会不会出现这样的效果:
来看代码:
#include
#include
#include
using namespace std;
int main()
{
int pipefd[2];
int n=pipe(pipefd);
if(n<0)return 1;
cout<<"pipefd[0]:"<<pipefd[0]<<" "<<"pipefd[1]:"<<pipefd[1]<<endl;
return 0;
}
我们看到结果我们分析原理的时候是一模一样的。接下来我将写一个程序来带大家实现父子进程间通信,也是为了更好介绍管道的四种情况。
为什么没有创建子进程就可以进程pipe呢??原因是我们的管道文件只要一个进成建立好。子进程拷贝父进程数据,增加一个执行那个就可以,就好比下面这个图:
pipefd[0]:读端
pipefd[1]:写端
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
#define N 2
#define NUM 1024
void Writer(int wfd)
{
string s="hello,pipe";
pid_t cid=getpid();//获得当前进程的pid
char buffer[NUM];//减少io交互次数
int numbers=0;
int cnt=5;
while(true)
{
sleep(1);
方法1
//sleep(1);
//buffer[0]=0;
// snprintf(buffer,sizeof(buffer),"%s-%d-%d",s.c_str(),cid,numbers++);//因为多写一些内容上去,所以使用一个buffer过度一下
// write(wfd, buffer, strlen(buffer));//发送给父进程
方法2
// string s1=s;
// s+="-";
// s+=to_string(cid);
// s+="-";
// s+=to_string(numbers++);
// write(wfd,s.c_str(),strlen(s.c_str()));//发送给父进程
// s=s1;
/因为是不终止的给父进程写数据,所以使用了buffer,现在只给父进程写5次,每次写一个字符
char c='c';
write(wfd,&c,1);
numbers++;
cout<<"->"<<numbers<<endl;
if(numbers==5)
{
break;
}
}
}
void Reader(int rfd)
{
char buffer[NUM];
while(true)
{
buffer[0]=0;
size_t n=read(rfd,buffer,sizeof(buffer));
if(n>0)
{
buffer[n]=0;//因为读取出来没有结束标志
cout<<"father get msg:["<<getpid()<<"]:"<<buffer<<endl;
}
else if(n==0)
{
printf("father read file done\n");
break;
}
else
{
break;
}
}
}
int main()
{
int pipefd[N]={0};
int n=pipe(pipefd);
if(n<0)return 1;
//cout<<"pipefd[0]:"<
pid_t id=fork();//让子进程写,父进程读取
if(id<0)
{
perror("fork");
return 2;
}
else if(id==0)
{
//child;
//cout<<"pipefd[0]:"<
close(pipefd[0]);//子写关闭读端
//cout<<"pipefd[0]:"<
Writer(pipefd[1]);
close(pipefd[1]);
exit(0);
}
//father
close(pipefd[1]);//父读关闭写端
Reader(pipefd[0]);
int status=0;
pid_t ret=waitpid(id,&status,0);
if(ret==id)//等待成功
{
cout<<"child:"<<ret<<",exitcode:"<<((status>>8)&0xFF)<<",signlcode:"<<(status&0x7F)<<endl;
}
close(pipefd[0]);
sleep(5);
return 0;
}
我们来看结果一:我们通过子进程给父进程不断的发送一句话
写端一直写,读端就一直读
写入五次之后,放下管道里面为空,就读不到数据了。
通过结果发现我们写到65536次就开始写端就开始阻塞了,因为我们是一次往管道里面写一个字符,所以我们的管道文件大小为65536/1024=64kb, 我们使用
ulimit -a
来查看:cat /etc/redhat-release
查看内核版本
在我这台机器的版本下,我们的管道文件大小居然是4kb与我们计算的不一样啊,这是为什么呢,我们来查看一下官方文档:man 7 page -> /page 跳转到这个文档。
前两点我们刚才已经认证过了,第三点提到了一个原子性,这是什么,给大家举个例子,假设子进程想给父进程写一句话hello,world,当刚写完hello,准备写world的时候,父进程看到管道里面有数据,直接就把hello读取走了,这样父进程就不是一起读到这个hello,world这个数据,所以在posix标准里面规定,写入的数据小雨这个pipe_buf大小的时候,即使管理里面有数据,父进程也不读取,这样就保证了原子性,我们可以理解为刚才看到的pipe size就是这个pipe_buf的大小,这个知识大家了解一下就可以了,主要记住管道文件是有固定大小的,看不同的内核版本,大家可以按照我上面的测试方法去计算一下自己的管道文件大小是多少。
我们的程序并没有想第一种情况一种在阻塞等待,而是直接结束了
我们看到我们的读端一旦关闭,子进程就被杀死退出,被父进程的waitpid获取到了,我们来看一下13号信号是什么:
就是管道信号,符合我们的测试。这也是博主为什么设计出父进程读,子进程写的目的,就是为了第四种情况了做实验的,等到讲解一个简单的进程池的时候,反过来让读者去感受一下
通过上面的四种情况以及管道的原理,我们很清楚的知道你名管道具有下面的五点特性
总结:
对于上面的情况,都是基于最上面的代码进行修改去测试的,每种情况要修改那部分博主也截出来了,大家先把我一开始写的程序理解了,然后在测试这些情况,不然很摸不清楚头脑。
大家还记得我们的内存吃,他的见到理解就是,我们如果想要100mb的空间,我们不需要一次申请10mb,申请十次,这样会增加消耗,所以一次申请100mb,放到内存池里面,和自己打交道总比和内存打交道省事,我们的进程池也是类似的道理,先创建对歌子进程,然后想要哪个进程做事就直接分配,不需要在创建进程了,就好比公司,先做人才储备,需要的时候上,如果没有人才储备,到时候在要人就来不及了,让我们一起来看看这个进程池怎么去实现吧。
test.cpp
#include
#include
#include
#include
#include
#include
#include
#include
#include "test.hpp"
using namespace std;
#define process 5 //这是标志一个进程里面有多少个子进程
#define N 2 //这是管道文件返回的数组的大小
vector<task> _task;//任务数组
//本程序是让父进程
class channls
{
public:
channls(const int&fd,const pid_t&id,const string&processname)
:_fd(fd)
,_id(id)
,_processname(processname)
{}
public:
int _fd;//文件描述符
pid_t _id;//进程pid
string _processname;//进程名字,方便我们观察
};
void slaver();//声明
int Initprocess(vector<channls>& cls)
{
for(int i=0;i<process;i++)
{
int pipefd[N];
int n=pipe(pipefd);
if(n<0)
{
perror("pipe:");
return 1;
}
pid_t id=fork();
if(id<0)
{
perror("fork:");
return 2;
}
else if(id==0)
{
//child
close(pipefd[1]);//关闭写端
dup2(pipefd[0],0);
close(pipefd[0]);//放在这里也可以
slaver(); //为了不给这个函数传参数,才使用上面的函数,一会从键盘文件进行读取就可以了,不然就需要这样 slaver(pipefd[0]);
//close(pipefd[0]);
cout<<"process:"<<getpid()<<"quit"<<endl;
exit(-1);
}
//father
close(pipefd[0]);//关闭读端
string name="process"+to_string(i+1);
cls.push_back(channls(pipefd[1],id,name));//父进程会返回子进程的id,所以这里面的id是子进程的id,将自己的写端给子进程,到时候直接往子进程里面写入
//和每个进程之间都会建立一个管道文件,按照文件描述符分配规则,父进程的写端的下标会递增。
}
}
void debug(const vector<channls>& cls)//测试有没有初始对不对,有没有建立进程池,将每个进程进行初始化
{
for(const auto&e:cls)
{
cout<<e._fd<<" "<<e._id<<" "<<e._processname<<endl;
}
}
void slaver()//子进程收到任务去执行任务
{
int cmdcode=0;//收到父进程发过来的任务指令
while(true)
{
size_t n=read(0,&cmdcode,sizeof(int));//如果父进程一直没有给子进程发送数据,就会阻塞等待
if(n==sizeof(int))//读取到父进程给我发送的任务了
{
cout<<"slaver say get a command:"<<getpid()<<": cmdcode:"<<cmdcode<<endl;
if(cmdcode>0&&cmdcode<=_task.size())//下标的映射关系
{
_task[cmdcode-1]();//调用对应的任务
cout<<endl;
}
}
if(n==0)break;
}
}
void menu()
{
cout<<"********************************"<<endl;
cout<<"*****1. 任务1 2. 任务2*******"<<endl;
cout<<"*****3. 任务3 4. 任务4*******"<<endl;
cout<<"*************0.退出*************"<<endl;
cout<<"********************************"<<endl;
}
void quitprocess(const vector<channls>& cls);
void ctrlSlaver(const vector<channls>& cls)
{
//这是一直随机给子进程分派任务
// while(true)//一直给子进程发送任务,如果想控制次数,再循环里面操作break即可
// {
// int which=rand()%cls.size();//随机得出子进程所在数组的下标
// int cmdcode=rand()%_task.size()+1;//因为任务也是数组存储起来的,所以父进程给子进程发一个存储任务数组的下标消息就可以了
// cout<<"father say:"<<"cmdcode:"<
// write(cls[which]._fd,&cmdcode,sizeof(int));//发送任务
// sleep(1);//每隔一秒发送一次任务给子进程
// }
//这是轮转的给子进程发任务
// int which=0;//这是选择哪一个进程
// while(true)//一直给子进程发送任务,如果想控制次数,再循环里面操作break即可
// {
// //随机得出子进程所在数组的下标
// int cmdcode=rand()%_task.size()+1;//因为任务也是数组存储起来的,所以父进程给子进程发一个存储任务数组的下标消息就可以了
// cout<<"father say:"<<"cmdcode:"<
// write(cls[which]._fd,&cmdcode,sizeof(int));//发送任务
// sleep(1);//每隔一秒发送一次任务给子进程
// which++;
// which%=cls.size();
// }
//自己制作一个菜单给子进程发送任务
menu();
while(true)
{
int which=rand()%cls.size();//随机得出子进程所在数组的下标
cout<<"请输入你的选择:";
int cmdcode=0;
cin>>cmdcode;
if(cmdcode<=0||cmdcode>4)
{
break;
}
cout<<"father say:"<<"cmdcode:"<<cmdcode<<"already sento"<<cls[which]._id<<" processname:"<<cls[which]._processname<<endl;
write(cls[which]._fd,&cmdcode,sizeof(int));//发送任务
sleep(1);//每隔一秒发送一次任务给子进
}
}
void quitprocess(const vector<channls>& cls)
{
for(const auto& e:cls) close(e._fd);//子进程读端被关闭,就会被信号杀掉,等着父进程回收
for(const auto& e:cls) waitpid(e._id,NULL,0);
}
int main()
{
LoadTask(&_task);
srand(time(nullptr)^getpid()^1023);//种一个随机数种子
vector<channls> cls;//就类似于进程池
cout<<getpid()<<endl;
Initprocess(cls);//初始化
ctrlSlaver(cls);//父进程开始控制子进程
quitprocess(cls);
return 0;
}
test.hpp:声明和定义可以在一起的头文件,再模板那一节应该提到过
#include
#include
#include
using namespace std;
typedef void(*task)();
void task1()
{
cout<<"任务1"<<endl;
}
void task2()
{
cout<<"任务2"<<endl;
}
void task3()
{
cout<<"任务3"<<endl;
}
void task4()
{
cout<<"任务4"<<endl;
}
void LoadTask(vector<task>* _t)
{
_t->push_back(task1);
_t->push_back(task2);
_t->push_back(task3);
_t->push_back(task4);
}
通过这个程序大家应该感觉有点意思了,这个代码的所有注释都写了,大家下去好好研究一下。
上面程序的bug
上面的程序有一个隐藏的bug,但是影响不大,大家有没有发现博主的退出进程的函数,两个循环是分成写,为什么不一起写,例如下面这样:
void quitprocess(const vector<channls>& cls)
{
for(const auto& e:cls)
{
close(e._fd);
waitpid(e._id,NULL,0);
}
}
因为我们是循环创建子进程,这样就导致,后面创建子进程的时候,父进程和上一个子进程的写端还没有关闭,就被继承下来了,这样就导致,我们的父子进程之间不是只有一个写端和一个读端的单向通信了,我们的子进程之间也可以进行互相通信,如果按照一个循环的方式去写,目的是想让写端关闭,读端就会读到文件尾,符合第三种情况,但是我们的第一个进程不止一个写端,所以一个循环是解决不了的。
但是我们的最后一个进程的管道是只有一个写端和一个读端的,我们先把这个写端关闭,这个进程就会终止,他上面继承下来的写端就会关闭,导致在他创建进程的前面的所有子进程的管道的写端指向都少一个,这样关闭释放到最后一个子进程的时候,也是只有一个写端了。
void quitprocess(const vector<channls>& cls)
{
int last=cls.size()-1;
for(int i=last;i>=0;i--)//从后往前释放,根据进程中指,对应的写端下标也就没有了。
{
close(cls[i]._fd);
waitpid(cls[i]._id,NULL,0);
}
}
但是我们的bug还是没有解决,因为还是存在子进程之间互相通信的可能,所以我们想要解决这个问题,我们就要使用一个数组讲父进程的写端下标保存起来,在子进程里面遍历数组将其关闭即可。
加这三处代码也可以解决这个问题,该说不说这个bug藏的挺身,但是对这个程序影响不大,也是通过这个bug让大家可以更好的理解父子进程间通信的原理,博主认为,人只要一学过难一点的知识,前面那些一开始认为难的也会变得简单容易理解多了,这也是为什么要坚持下去的原因,只有坚持到后面,理解东西的成本就会越低,这样学起来才更有信心。
今天讲的知识,是匿名管道,他的原理不难理解,我们要明白进程间通信的本质是什么,让不同的进程看到同一份资源,这个在后面讲解命名管道和共享内存的时候都会讲,所以希望大家好好的理解这篇的知识点,尤其要好好理解进程池这个代码,我们下篇再见