本章目标
1.IPC概述
2.匿名管道
3.进程池
1.IPC概述
进程间通信的英文名缩写就是IPC,在前面我们了解到进程之间是有很强的独立性的,即使是父子进程之间数据也是不互通的,一旦子进程去修改数据,就会触发写实拷贝.
但是为了让进程间进行数据的传输.就出现了这门技术,进程间通信.
对于下面所说的进程间通信,他们本质原理都是一样的.
都是让两个进程看到同一份资源.
而这个资源一般是由操作系统提供.
1.进程间的通信的目的主要有四个
1.进行数据的传输
2.进程资源间的共享
3.通知事件,例如子进程要在进程终止时通知父进程
4.进行进程间的控制,由一个进程控制一批的进程,就例如我们后面要写的进程池
2.进程间的分类和发展
在最早的时期,最先出现的进程间通信的方法是管道.这个方法是从UNiX时期就已经存在的.在如今的linux和macos这两个类Unix系统当中,仍然存在.使用的很平常.我们之前的指令与指令的链接用的竖线就是管道,例如 ps axj|head -1 这种就是通过管道进行通信,ps 和head都是两个指令,指令的本质是二进制可执行文件,当他们启动的时候就变成了bash下面的子进程,他们两个就是兄弟进程,通信的竖线就是匿名管道
随着时间的发展.又衍生出了两种不同的进程间通信标准
先是system V 这套标准因为时代原因,它主要是在同一台机器上进行进程间通信
随着网络的出现,就有了posix这套标准来实现不同主机间的进程间通信
进程间通信的分类
管道:匿名管道 ,命名管道
system V标准: system V 共享内存,system V 消息队列 system V 信号量
posix 标准:共享内存 ,消息队列, 信号量 ,互斥量 ,条件变量,读写锁
2.匿名管道
1.什么是匿名管道
匿名管道是类Unix系统当中最古老的通信方式,它的本质是模仿文件操作的方式在内存当中创建一个不会刷新到磁盘当中的文件,通过父子进程拷贝的时候看到相同的文件描述符而实现看到同一份资源进行进程间通信的方式.
我们把从⼀个进程连接到另⼀个进程的⼀个数据流称为⼀个"管道"

在讨论具体管道的实现原理前,我们先讨论一个问题
对于一个正常的普通文件,会不会拷贝struct file给子进程
答案是不会,我们子进程只会复制父进程的文件描述符表给子进程,同时增加在struct file 当中的引用计数.
而对于匿名管道的实现原理是这样的,我们结合图例和系统调用看一下


父进程想要通过匿名管道进行进程间通信
它需要在创建子进程前,创建匿名管道
而匿名管道的系统调用 pipe 它有一个输出型的参数.
int pipe[2],这个数组表示表示管道的读写端的文件描述符,下标0表示读,下标1表示写
而在文件描述符表当中,因为 012已经被操作系统的默认打开的标准输入 标准输出,标准错误占用,所以在这张表当中的 3和4就是pipe管道的读写端的文件描述符
而对于读写段的这两个文件描述符必然对应在内存当中的两个struct file ,表示这个管道的读写段,但是这个两个struct file 与正常的文件不同点是
1.它没有名字,它不会向磁盘当中进行刷新数据,它跟磁盘没有关系
2.它读写两个struct file的inode相同
3.他们的缓冲区相同.
接着父进程fork出子进程,子进程也拿到了相同的文件描述符表
对于管道,它只能进行单向通信,我们想要实现通信,对于父子进程就必须关闭一端
例如父进程读,子进程写,父进程就要关闭写端,子进程就要关闭读端
因为是模仿文件操作,对于管道里面的数据读写与文件一致.
c
#include<stdio.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<string.h>
#include<stdlib.h>
const int N = 1024;
int main()
{
int pipefd[2];
pipe(pipefd);
pid_t id = fork();
if(id==0)
{
//子进程 写
close(pipefd[0]);
char buffer[N];
char* a="hello mutou";
int cnt = 5;
while(cnt)
{
memset(buffer,0,sizeof buffer);
snprintf(buffer,N,"%s cnt is %d,pid %d",a,cnt--,getpid());
write(pipefd[1],buffer,strlen(buffer));
// sleep(1);
}
exit(0);
}
else if(id<0)
{
perror("fork error\n");
exit(1);
}
//父进程 ,读
close(pipefd[1]);
char buffer[N];
while(1)
{
// memset(buffer,0,sizeof buffer);
//printf("%s\n",buffer);
memset(buffer,0,sizeof buffer);
ssize_t n = read(pipefd[0],buffer, sizeof buffer-1);
// 第三个参数表示期望读到多少数字,n表示实际读到的个数
if(n>0)
{
buffer[n]= 0;
printf("%s\n",buffer);
}
else if(n==0)
{
printf("read over\n");
break;
}
else
{
perror("read error\n");
exit(1);
}
}
int status= 0;
pid_t rid=waitpid(id,&status,0);
if(rid<=0)
{
perror("wait error\n");
exit(2);
}
else{
printf("exit code %d,signal code %d\n",(status>>8)&0xff,status&0x7f);
}
return 0;
}

想法跟我们想的一趟,我们看到了cnt 的变化,我们现在把它改一下,变成从键盘读数据
c
while(fgets(buffer,N,stdin))
{
write(pipefd[1],buffer,strlen(buffer));
memset(buffer,0,sizeof buffer);
}
只需要修改写的逻辑即可
2.匿名管道的特点和具体写入的时候的情况划分
1.匿名管道是单工通信的
所谓的单工通信就是单向通信只允许一方读和一方写
除了单工通信,通信还有其他两种形式
半双工通信:它允许双端都可以读写,但是读写不能同时运行
典型的就是人类说话,你必须听完一方说完话,你才能根据它的回答继续说
还有一种是双工通信,典型的就是网络.我们可以同时在下载文件的同时上传文件
2.匿名管道只适用在有血缘关系的进程,
例如父子通过匿名管道通信,bash 的竖线的匿名管道通信
3.匿名管道是面向字节流的.
字节流的概念,我们可以这里理解,对于我们的管道里面的数据他们实际上是连续的.
我们可以通过多次写入,一次直接读走.这也是管道读写的本质它是没有边界的.并没有要求我们一次读取一次写入

字节流就是左边这种情况,可以理解成一个大水池,我们可以往这里面写一堆数据,一次性直接读走
还有一种就是字节报,无论我们一次读写多少,一次读就必须匹配一次写.
二者本质上就是连续和不连续的区别.
4.匿名管道的声明周期是随进程的
这点很好理解,本身这种实现就是模仿的文件操作,文件本身就是随着进程的.管道自然如此
5.对于管道来说,它有自己的保护机制,同步和互斥
同步时是表示有顺序,同一时间只能够有一方进行读写.
互斥是当一方进行读写的时候,另一段必须阻塞.
这一点可以在我们后面的情况中体现.如果两端同时进行就可能出现数据的读写出现错误的情况.
具体情况读写划分
我们先看最正常的情况
1.读写双方速度一致



仍然是我们上面的例子,我们通过5次系统调用.向管道里面写,因为文件流的特性,因为速度太快我们的读端直接就能看到5份数据,一次性读走
2.写端写的慢,读端正常
读端会等写段陷入阻塞

我们可以把写端的sleep放开,读端加上\n,如果文件流的特性,写端写的慢,读端读走一份数据,就会陷入阻塞,我们就会看到5个\n

3.写端正常,读端读的慢
我们会看到一行\n

读端加上sleep ,写端直接关掉

4.读端再读,写端关闭
当写端关闭读端会从管道里面读完数据,然后读到文件结尾
我们可以给两端造成速度差.
写端cnt==3的时候sleep 写完cnt次直接关闭fd1,然后读端正常


5.读端关闭, 写端不关
直接被子进程13号信号杀死,因为读端不读,管道继续写就没有意义了.


直接这么加,读一次直接关我们这么做是为了看到退出码

3.进程池
通过上面的测试代码我们细致的了解了匿名管道的具体使用方法和适用情况
我们下面通过一个实际应用中的例子深化理解通过管道达到通知事件以及进程控制的例子
我们要创建一个主从模式的进程池

我们父进程通过管道对其子进程进行控制管理和回收.
https://gitee.com/woodcola/linux-c/tree/master/processpool
这是这份代码具体实现
我们在这里只介绍这份代码的注意事项
1.消除this指针

在这里我们看到我们的进程池当中,我们将任务模块直接加入到了这里面
但是由于是在类内部,我们子进程的入口函数的回调就会出问题
cpp
void Dotesk(int rid)
{
while (true)
{
int task_code = 0;
ssize_t n = read(rid,&task_code,sizeof task_code);
if(n==sizeof task_code)
{
_task_pool[task_code]();
}
else if(n==0)
{
//父进程关闭写的管道
std::cout<<"father close"<<getpid()<<std::endl;
break;
}
else
{
std::cerr<<"read error"<<std::endl;
}
}
}
这个函数是在类内部实现会默认带一个this指针
它不会显示的存在,
正常来说我们可以用static 直接修饰给它干没,但是我们在这个函数内部访问了
非静态的成员
这条路就直接堵死了.
我们剩下的几条路可以用bind去改参数,或者用lamda来去对这个进行一个封装
我们在这里面选择的后者

2.emplace_back
因为我们这两个成员变量的容器都是自定义类型的,我们可以用emplace_back,直接在类内部直接构造.减少拷贝.这里提出来只是说明一件事,对于内置类型,push_back和emplace_back没有区别

3.子进程回收fd问题

因为我们的子进程是fork出来的,对于后面的子进程会复制前面进程的fd表,这会导致越早创建的管道,它的写端的struct file的引用计数会越来越多.而这份进程池子进程的读端永远是fd3
在这里面有三个解决方案.
我们将管道关闭再去关闭进程

父进程会先去关自身的写端,在从第一个到n-1个进程之间,他们的写段实际上这个时候没有关干净.
但是最后一个管道一定是被关掉了.因为从第一个到最后一个管道他们写端的引用计数是逐渐变小的且最后一个是1
此时dotesk的子进程就能读到0从而break掉退出 .从而关掉这个进程指向其他管道的写端.再依此向上关闭

具体关系如上
第二个解决方案是倒叙关闭管道,但是管道的关闭和进程关闭是一致的

第三个解决方案是正常关,但是要修改创建进程时的代码,每一个子进程提前关闭前面进程的复制来的写端fd

