前言
为什么要进行进程间通信呢?因为需要以下这些事:
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
一、管道
1.匿名管道
首先我们前面说过,进程是具有独立性的,但是要实现两个进程通信就必须让这两个进程看到同一份资源,这该怎么办呢两个独立的进程如何看到同一份资源呢?其实这个操作是操作系统直接或间接提供的。下面我们先看看管道,然后再画图讲解如何让进程看到同一份资源。
who这个命令是一个进程,wc也是一个进程通过管道完成了进程间通信,那么如何证明这两个是进程呢?我们证明一下:
首先我们用sleep命令创建了3个进程,然后后面加个&符号是让这些进程在后台运行,否则如果在前台运行我们就不能输入指令了,然后通过查看 进程发现三个sleep进程都在并且他们的父进程都是bash。将sleep比作刚刚的who和wc指令就证明了进程间通信。
下面我们讲解一下管道的原理:
首先先有进程,也就是task_struct,每个进程都有对应的文件描述符表,文件描述符表中有相应的数组,数组中存放了标准输入0,标准输出1,标准错误2,而每个进程描述符都会存放相应struct file的地址,在进程间通信的时候系统会提供一个内存文件,这个内存文件不会在磁盘刷新,这个文件被称为匿名文件,当我们以读和写方式打开一个文件,然后我们fork创建一个子进程,子进程也有task_struct,并且子进程会继承父进程的文件描述符表(但是不会复制父进程打开的文件对象),而文件描述符表中存放文件的地址都是相同的,所以子进程的文件描述符表也指向父进程的文件,正是因为这样,在父进程以读和写打开一份文件,而子进程也同样读和写打开和父进程打开的一样的一份文件,这就让两个进程看到了同一份资源。但是这种管道只能实现单向通信,比如我们关闭父进程的写端,关闭子进程的读端让子进程去写这两个进程就实现单向通信了。管道只能单向通信的原因是文件只有一个缓冲区,一个写入位置一个读取位置所以只能单向通信,要是想双向通信那就打开两个管道!而上面所讲的管道就是匿名管道。
下面我们写一个管道的程序:
首先0 1 2是默认打开的标准输入,标准输出,标准错误,而3就是读端,4就是写端,我们要实现单向信道一定是父子进程一读一写。
首先我们在VScode中创建等会要用的.cc文件和makefile。
要创建管道,首先我们要知道创建管道的函数pipe:
此函数有一个参数是一个一维数组,这个一维数组只有两个元素,这个参数也叫输出型参数(这两个元素我们的例子中就是读端和写端)。
下面我们完成主体:
#include <iostream> #include <string> #include <cassert> #include <unistd.h> #include <vector> #include "task.hpp" #include <sys/wait.h> #include <sys/types.h> using namespace std; int main() { //1.1创建管道 int pipefd[2] = {0}; int n = pipe(pipefd); if (n<0) { std::cout<<"pipe error,"<<errno<<":"<<strerror(errno)<<std::endl; return 1; } std::cout<<"pipefd[0]:"<<pipefd[0]<<std::endl; std::cout<<"pipefd[1]:"<<pipefd[1]<<std::endl; //1.2创建进程 pid_t id = fork(); if (id==-1) { //创建子进程失败 exit(-1); } if (id==0) { //子进程 close(pipefd[0]); const std::string namestr = "hello,我是子进程"; int cnt = 1; char buffer[1024]; while (true) { snprintf(buffer,sizeof buffer,"%s,计数器:%d,我的pid:%d\n",namestr.c_str(),cnt++,getpid()); write(pipefd[1],buffer,strlen(buffer)); sleep(1); } //写入成功后将写端关闭 close(pipefd[1]) exit(0); } //父进程 close(pipefd[1]); //关闭不需要的fd,让父进程读取,子进程写入。 char buffer[1024]; while (true) { int n = read(pipefd[0],buffer,sizeof(buffer-1)); if (n>0) { buffer[n] = '\0'; std::cout<<"我是父进程,child give me message:"<<buffer<<std::endl; } } return 0; }
我们的程序很简单,首先创建管道,如果创建失败就报错打印错误码,然后我们打印一下读端和写端(如果没问题的话读端是3,写端是4),然后第二步我们创建子进程,创建子进程同样要判断创建失败的情况,如果创建成功我们先关闭子进程的读端,然后搞一个1024的缓冲区让字符串写入到缓冲区里,sleep可以让现象更明显,写完后我们为了保证管道安全所以关闭刚刚使用的写端,在父进程中同理只不过是变成了读,下面我们运行一下代码:
我们确实看到了两个进程,并且确实也完成了简单的单向通信,因为子进程把自己的数据给了父进程。通过上面的实例代码我们能知道管道的特点吗?
1.单向通信
2.管道的本质是文件,因为fd的生命周期随进程,管道的生命周期也是随进程的。
3.管道通信,通常用来进行具有"血缘"关系的进程,进行进程间通信,常用于父子通信----pipe打开管道,并不清楚管道的名字,所以是匿名管道。
下面我们修改一下代码,刚刚我们让子进程发的慢一点(sleep了1秒),现在我们让父进程读的慢一点看看是是什么样子:
下面我们运行起来:
通过结果我们发现写入的次数和读取的次数不是严格匹配的。所以这也是管道的一个特点。
下面我们再将代码修改一下,让父进程读取恢复正常,让子进程写的慢一些我们再看看效果:
通过程序运行结果我们发现,当写入很慢的时候读取也变慢了,也就是说写入是会影响读取的。
下面我们再将代码修改一下,我们让子进程持续写入一个字符,父进程先等10秒再读:
通过上图我们可以看到当子进程写入65535个也就是2的16次方的数据后,父进程直接打印了,并且时间并没有到10秒,所以打印的原因是因为管道是有容量的,当我们的写端将管道文件写满了我们就不能继续写了,这与之前read端读完了所有的管道数据,如果写端不发就只能等待是相对应的。所以管道的另一个特点来了:具有一定的协同能力,让reader和writer能够按照一定的步骤进行通信(自带同步机制)。
下面我们再试试将写端关闭了读端会怎么样呢?
read函数的返回值可以判断读取了多少,比如返回值大于0就是将缓冲区的数据都读完了,返回值等于0就是读到了文件结尾,否则就是读取异常,下面我们将代码运行起来:
结果就是子进程写了一个字符退出后,父进程读取到\0就会读到文件结尾而退出。那么如果我们写端一直写,将读端关闭了会发生什么呢?因为这里的行为是无意义的,所以操作系统会杀死这个一直写入的进程(因为操作系统不会维护无意义,低效率,或者浪费资源的事情,操作系统会杀死一直写入的进程,会通过信号来终止进程(13号信号)),那么如何证明会被操作系统杀死呢,下面我们让父进程等待一下子进程然后看看:
通过结果我们发现确实是这样,子进程被操作系统用13号信号终止了。以上就是匿名管道的相关知识,下面我们用一批管道实现控制进程的代码:
首先我们创建一个ctrlProecess.cc的文件夹,然后写一个makefile,准备工作做好后我们就可以按照思路写代码了,我们第一步要先进行构建控制结构,父进程写入,子进程读取,并且既然是多个管道我们直接用个循环来控制:
我们在判断是否成功创建子进程的时候直接用assert断言了,其实这里应该要用if语句判断的,但是由于这个函数基本不会出错并且我们只是演示一下代码所以就用断言了,当返回值等于0的时候那么一定是子进程了,否则就是父进程了,那么我们该如何通信呢,让父进程写入,子进程读取,所以我们先关闭不需要的端口。
现在前期准备工作已经完成了,下面如何让父进程管理自己创建的管道和进程呢?还记得我们之前说过的先描述,在组织吗?答案就是这个。
我们为了描述这些创建的管道所以用一个类来封装,成员变量包括子进程的pid以及父进程写入所需要的端口,然后我们在构造函数中将这些变量初始化一下。
class EndPoint { public: pid_t _child_id; int _write_fd; EndPoint(int id,int fd) :_child_id(id) ,_write_fd(fd) { } ~EndPoint() { } };
因为是多个管道所以我们将刚刚的自定义对象放到vector中去管理,这不就完成了先描述,在组织的任务吗,所需要的头文件大家记得加上,下面我们将vector定义到函数最开始。
我们的目的是让父进程写入,子进程读取,所以肯定是在父进程的地方来描述这个子进程:
将一个个子进程和写端的对象放入向量后,父进程就拿到了一批Endpoint对象,这个结构里包含了要写入的文件描述符的写端和新的子进程。下面的工作就是要子进程读取指令,我们要让子进程在读取指令的时候,都从标准输入读取,要从标准输入读取我们就要用的以前学的知识了,那就是输入重定向,输入重定向的方式很多,我们主要用函数的方式:
还记得我们之前学的dup2函数吗,在这里就可以派上用场了。我们的目的是要子进程都要从标准输入读取,所以函数的第一个参数old就是pipefd[0]了因为我们要让pipefd[0]重定向到0(标准输入),所以第二个参数new就是0了,然后我们让子进程开始等待获取命令,这里我们直接写一个函数WaitCommand()将所以的代码写入这个函数里,并且将刚刚创建进程的代码也放入一个creatProcess函数中,让各个函数完成相应的功能会让代码看的更简洁:
下面在让子进程要执行方法的函数中先写一个read函数和一个死循环我们来看看代码能否正常创建子进程,然后再编写后序的代码:
运行起来后我们可以看到之前写的创建进程的函数没毛病,下面我们来设计一下让子进程执行的方法的函数。在完成这个函数前先新建一个头文件task.hpp,然后创建一些简单的打印任务: