🔥 本文专栏:Linux
🌸作者主页:努力努力再努力wz

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

💪 今日博客励志语录“梦想不是橱窗里的奢侈品,而是荒野中的火种:你或许要披荆斩棘才能靠近它,但每靠近一步,脚下的路都会被照亮一寸。”

看完本文,你会学到:

1.理解什么是进程间的通信

2.父子进程是如何通信的

3.如何自己实现父子进程的通信

★★★ 本文前置知识:

进程的概念

1.理解进程之间的通信

那么提到通信这两个字,那么你的脑海里面会想到什么,在现实生活中,我们可以通过电话或者社交软件比如微信或者QQ来实现通信,那么通信的目的就是为了向对方传递一种信息,比如你今天过的顺不顺利或者今天吃饭了没

那么人与人之间是有联系的,不管是以什么样的方式来传递信息,人与人之间必然有通信的需求,同理,进程之间也存在通信的需求,那么进程之间为什么会有通信的需求呢?

假设有这样的一个场景,完成某项任务可能需要多个进程之间协同合作来完成,那么此时就需要各个进程分工完成各自的模块,那么进程就需要知道彼此对方完成的一个进度以及完成的结果,那么进程之间就需要通信来得知这些信息,所以说进程之间也有通信的需求

那么知道了进程之间也需要通信之后,那么接下来的问题便是进程之间怎么进行通信,那么假设我们要完成一个图片修复工作,那么此时我们可以创建出不同的进程来完成这个任务,其中假设a进程负责接收图片,然后将这个图片传递给b进程,而b进程的任务就是对该图片进行各种处理,那么此时b进程需要从a进程那里获取图片,那么b进程是直接通过访问a进程的数据来获取吗?

而我们知道每一个进程有对应的task_struct结构体,其中有记录了进程自身的虚拟地址空间以及页表,那么要获取进程的数据,那么就得获取进程的虚拟地址空间以及页表将其虚拟地址转换为物理内存地址从而能够访问到进程的相关数据,但是进程的task_struct结构体以及页表是由操作系统来维护管理的,而我们知道进程本质是加载到内存中的可执行文件,那么可执行文件是由源文件经过编译以及链接形成的,而我们用户在源文件中编写的代码就决定了进程的各种行为,那么进程现在想访问另一个进程的数据,那么必然我们只能在代码层面上调用操作系统为我们提供的各种系统调用接口来做到,但是操作系统并没有为我们提供了相关能够访问其他进程页表以及进程地址空间的系统调用接口,操作系统这么做的目的就是为了保证进程之间的独立性,那么每一个进程之间只能看见自己的进程地址空间以及页表,而无法看到以及干涉到别的进程的数据,所以操作系统只为我们提供了针对该进程相关操作的系统调用接口比如fork创建子进程以及open来打开一个磁盘文件等等,但是操作系统没有为我们提供跟查看其他进程的地址空间以及页表的系统调用接口,所以说了这么多,就是为了阐述一个核心的道理:进程间的通信无法通过直接访问对方的数据来实现通信。

那么进程无法直接访问对方的数据,那么意味着只能间接的去访问了,那么怎么间接的访问,那么我可以来举一个例子来帮组我们理解一下操作系统是如何设计才能既保证进程之间的独立性又满足进程之间的通信的需求:那么进程我们可以类比与在同一个房子住的人,那么假设这个房子里面住的某个人得了某种传染性病毒,那么在这个房子里面的住的人们就得将各自隔离到自己的房间中,他们无法直接出来见面,而为了彼此之间的交流,此时他们就想了一个办法,那么就是在客厅也就是公共区域里面放置了一个类似于信箱的结构,那么如果一个人他想与另一个人通信,那么他会将他想说的话写到一个小纸条上面然后放到这个信箱当中,然后回到自己的房间,然后另一个人过来从信箱里面取出小纸条就能够阅读对方传达的信息了,就能保证双方不亲自见面但也能够实现通信的需求

那么上面的例子可能有一点bug,比如写信的人得了病毒那么通过这个信传染给其他人怎么办,但是这些都不重要,重要的是我想从上面的例子中传达一个操作系统来实现进程间通信的一个核心思想:那么就是操作系统会内存中建立一块公共的区域,那么不同的进程只需要将自己传达的信息写入到公共区域,然后另一个进程从公共区域读取即可。

那么进程就可以通过这个公共区域来传达信息,那么这个信息可能是某种信号或者某种命令,那么信息具体是什么以及其应用场景是什么那么得在往后的学习中逐渐的认识到,那么至此,我们脑海里就大概有一个所谓的框架,也就是操作系统是如何实现进程之间的通信的

而注意进程之间具体通信的方式还取决于进程之间的关系,比如是通信的进程是父子进程也就是有血缘关系的进程还是说没有血缘关系的进程,那么进程之间的关系不同会导致通信的具体方式以及细节会有所区别,但是实现的大体思路其实是一样的,而今天这篇文章便是探讨的是父子进程之间是如何来进行通信的

2.父子进程之间如何通信

那么上文我们说了,进程之间的通信的核心就是得有一个公共的区域,那么意味着父子进程就得寻找到这个公共区域

而我们知道创建一个子进程本质就是操作系统会拷贝父进程的task_struct结构体然后修改其中的部分字段得到子进程自己的task_struct,那么意味着子进程会复制拷贝一份父进程的文件描述表,那么文件描述表本质就是一个指针数组,那么数组的每一个元素是一个结构体类型的指针,指向该进程打开的文件的file结构体对象,那么此时子进程会和父进程共同指向打开的文件,也就是和父进程共享打开的文件

而刚才我们说通信的核心是得有一个公共的区域,那么由于父子进程是共享打开的文件,那么这个文件就可以作为公共区域来实现父子进程之间的通信,那么这个文件也有专业的术语来称呼,那么便是管道

匿名管道

那么相信读者看到这里肯定会有很多的疑问,比如为啥叫该文件为管道以及管道如何实现等等,那么先别急,我们慢慢来,那么我们先从管道是什么来说起,再来谈谈管道的实现,那么从管道的实现,你便能知道为什么叫它管道了

1.认识匿名管道

那么我们知道了父进程打开的文件,那么子进程也会继承父进程打来的文件,因为子进程的task_struct结构体是拷贝的一份父进程的task_struct结构体,那么必然就会拷贝一份和父进程相同的文件描述表,从而与父进程共享打开的文件
在这里插入图片描述
那么此时我们脑海中有一个初步的实现思路,那么就是父进程可以自己打开一个文件,然后调用fork接口创建一个子进程,那么子进程对应的文件描述表中肯定也与父进程指向了相同的打开的文件,那么我们此时父进程往这个文件中写入数据,那么我们知道子进程的文件描述表中有该文件对应的file结构体指针,而一个file结构体对象包含了三个非常关键的内容,分别是保存文件属性inode结构体的引用,以及文件函数表,那么它是一堆函数指针的集合,那么其中每一个函数指针指向针对该文件具体操作的函数实现

那么这里我对f_op的作用来做一个补充,那么对f_op感兴趣的读者可以阅读,不感兴趣的可以直接跳过,那么我们知道在Linux操作系统上一切皆文件,那么对于Linux上的各种不同的事物都可以将其抽象为文件,而底层的不同的硬件,那么它也要经过操作系统的管理,那么操作系统管理的方式就是我们最为熟悉的先描述,再组织,那么它对于这些硬件在上层还是抽象成了文件来描述这些硬件,那么这些硬件之间肯定本身的结构是不同的,那么必然对这些硬件的相关操作的具体实现是不一样的,但是对这些硬件操作的种类却是统一的,比如都会有读取以及写入等等操作,那么我们知道进程会与这些外部设备比如显示器以及磁盘等交互,比如我们调用write接口向显示器文件或者普通文件写入,那么文件类型不同,那么肯定write函数的实现是不同的,但是对于我们用户层面上来说,我们根本不用关心write函数的具体实现,而是可以调用统一的接口来实现对不同类型的文件的写入,但是它底层则是通过write函数会接收一个文件描述符,那么从而获取对应文件的file结构体,然后调用其f_op函数表中对应该文件类型的write函数指针,从而获取其指向的write函数实现,那么这样做的好处就是我们可以同一个接口来应用不同类型的资源,隐藏了底层的复杂的实现逻辑

以及address space字段,其中包含了指向了一个基数树的数据结构,那么该数据结构就是保存了该文件对应的页缓存

在这里插入图片描述

所以父进程可以通过其文件描述表中指向的file结构体对象,然后向该文件的页缓存中写入数据,那么子进程也持有该文件的file结构体指针,那么只需要到其对应的页缓存中读取即可,那么这样看似是对的,其实有很多的坑:

那么先说第一个坑:

那么我们知道父进程是通过open系统调用接口来打开磁盘中的某个文件的,那么其中会以指定的模式来打开该文件,比如以只读或者只写还是读写方式打开,而子进程会继承父进程的打开该文件的方式,因为该打开的file结构体对象中会有一个标记字段f_flag来记录其打开的方式,所以如果父进程以只读方式打开,那么父子进程想要通过该文件来实现通信,那么对不起,父子进程都无法向该文件的页缓存中写入,从而无法进行通信,同理,以只写方式打来,那么双方无法读取对方向这个文件中写入的内容,也无法实现通信

第二个坑:

那么聪明的你意识到了这第一个坑,然后你选择了open该文件的时候,以读写方式打开,那么此时你就跳进了第二个坑,因为如果进程是以读写方式打开文件,那么文件的读和写是共用一个偏移量,也就是file结构体的f_pos字段,那么共用一个偏移量意味着,父进程读和子进程只能从当前读写偏移量之后的位置进行读取或者写入,而父子进程是共享打开的文件的,那么其中一个进程的读或者写必然会影响另一个进程的读或者写,因为此时读写是共用一个偏移量的,那么就会导致本来父进程要读取子进程的内容,但是它只能从当前读写的偏移量之后开始读取,从而无法读取到前面的写入的内容,那么有的读者可能就说,那么我把文件偏移量重新设置到文件开头不就可以了吗,但是这样设置过后你还要面临一个问题,由于父子都是同时会对该文件进行写入,那么你还得区分哪一个内容是父进程写入的哪一个内容是子进程写入的,那么也许有些抬杠的读者会觉得到时候再对父子进程写入的数据在标记一下,那么理论上是可以,但是按照你这样实现,那么通信的成本未免也太大了,所以说父进程以读写方式打开一个文件来实现通信是错误的

第三个坑:

那么也许有的读者都意识到了前面的这两个坑,然后都顺利跨过,那么采取的方式就是我父进程open同一个文件,只不过一个以只读打开另一个以只写方式打开,那么这样就会创建两份file结构体,有着两份独立的读写偏移量,并且file结构体指向的还是同一个页缓存,那么一个进程用其中一个只写的file结构体往页缓存里面写入,另一个进程则从只读的file结构体中到页缓存中读取不就可以了吗,其实理论上确实没错,按照这个方式确实能够实现通信,那么只不过这个坑不是说这个方式是错的,而是说没有必要,因为你open打开的是一个磁盘级文件,那么意味着到时候如果操作系统识别到你这个页缓存为脏,那么会将页缓存的数据刷新到磁盘中去,但是我们这个文件只是类似于中转站的角色,那么只是用来保存一个进程写入的数据,而不是需要刷到磁盘中来长时间的存储该数据,所以结论就是可以但没必要


所以我们通过上文的分析我们便知道此时需要的文件是不需要刷新到磁盘中的磁盘级文件,而是有只保存在内存页的文件,那么这个文件我们称之为内存级文件,那么铺垫了那么久,那么此时管道就登场了,那么管道本质上是一个文件,但是它不需要刷新到磁盘中,那么它与普通文件的区别就是它不需要刷新到磁盘并且f_op的函数实现是针对于该管道文件,那么它既然不需要刷新到磁盘,说明磁盘没有该文件的映射,也就意味着该文件没有文件名与路径,所以我们便知道为什么叫它“匿名”的原因,那么接下来我们就认识怎么创建管道以及为什么叫它管道
在这里插入图片描述

2.匿名管道的实现

那么我们创建匿名管道文件就不能再调用open系统调用接口了,因为那是打开磁盘级的文件,那么此时就需要调用pipe系统调用接口:

  • pipe接口

  • 头文件:unistd.h

  • 函数原型

    int pipe(int pipefd[2]);
    
  • 返回值:成功返回0,失败返回-1

  • 参数列表:传递一个数组,由于数组名代表首元素的地址,所以该数组就是作为输出型参数,而pipe接口会创建两个file结构体,这两个结构体是同一个管道文件对应的不同权限的file结构体,一个是只读一个是只写,创建好这两个file结构体之后,系统接着会扫描该进程的文件描述表,从0往后线性扫描分配一个最小未被使用的两个数组下标分别指向以只读打开以及只写打开的file结构体,其中pipefd[0]为只读打开该文件的文件描述符,pipefd[1]为只写打开该文件的文件描述符

所以我们在父进程中调用pipe接口来创建一份管道文件,并且以只读和只写方式打开,而子进程会继承父进程的文件描述表,意味着它也有只读以及只写打开的file结构体

而上文我们说过父子进程同时读写文件的后果,会导致内容混乱,所以这里我们对该管道文件只能单向通信,所以就需父子进程各自关闭对应的一个读写端:要么父进程关闭读端,子进程关闭写端或者父进程关闭写端,子进程关闭读端从而实现单向通信

所以为什么叫该文件为管道,我们就可以联想自来水管道,只能从一边进,然后一边出,所以这个单向通信的特点就很符号生活中的管道,所以叫其为管道文件

实现父子间进程通信

那么知道了父子间进程通信的接口之后,那么我们就可以来实操一手了,那么我们这里让子进程写入一个字符串,该字符串的内容包含三部分,分别是一个字符串“I am child process ”以及该子进程的编号,以及我们还会为字符串分配一个下标,那么最终的字符串就会拼接着三部分内容,然后父进程会读取子进程写入的该字符串,那么从这篇文章开始,我们就要尝试用c和c++混合编程了:

实现思路:

那么首先,我们得先在父进程中调用pipe接口来创建只读端以及只写端的两个file结构体对象,然后调用fork接口创建子进程,然后根据fork的返回值来让父子进程有着不同的执行流,此时我们就得在父进程的执行流中调用close接口来关闭写端,然后再子进程的执行流中来关闭读端

接着我们在定义一个writer函数来完成写入模块,它接受一个只写端的文件描述符,那writer函数的内部逻辑就是将上文所说的那三个内容给将其拼接得到一个字符串保存到一个固定大小的buffer字符数组中,这个过程需要调用snprintf函数,接着将再调用write接口将buffer数组中的字符串写入进管道文件的缓存中,并且在终端打印拼接后的字符串,然后重复这个过程,此时需要涉及到while循环的代码逻辑,然后子进程调用这个writer函数

void writer(int wfd)
{
    string l1="I am child processs";
    char buffer[N];
    int id=getpid();
    int num=0;
    while(true)
    {
         snprintf(buffer,sizeof(buffer),"%s_%d-%d",l1.c_str(),id,num);
        int n= write(wfd,buffer,strlen(buffer));
          if(n<0)
          {
            perror("write");
            return;
          }
         num++;
         cout<<buffer<<endl;
    }
}

而同样我们会定义一个reaer函数来完成读取模块,那么它接收一个只读端的文件描述符,那么该函数内部不断重复读取缓冲区的内容,所以需要一个while循环的逻辑,然后定义一个固定大小的buffer数组来接收读取的内容,然后调用read接口,来将读取到的缓存区的内容保存到buffer数组里面,然后在在终端打印buffer数组里面的内容

void reader(int rfd)
{
    char buffer[N];
      while(true)
      {
        int n= read(rfd,buffer,sizeof(buffer));
        if(n<0)
        {
            perror("read");
            return;
        }
        buffer[n]='\0';
        cout<<"I am father:"<<buffer<<endl;
      }
}

最终父进程的执行流中就需要调用reader函数,并且最后调用waitpid函数来获取子进程的退出情况,那么这就是我们代码的一个大体的实现思路

完整代码实现:

#include<iostream>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<string>
#include<cstdio>
#include<cstring>
using namespace std;
#define N 1024
#define EXIT_FAILURE 1
void writer(int wfd)
{
    string l1="I am child processs";
    char buffer[N];
    int id=getpid();
    int num=0;
    while(true)
    {
         snprintf(buffer,sizeof(buffer),"%s_%d-%d",l1.c_str(),id,num);
        int n= write(wfd,buffer,strlen(buffer));
          if(n<0)
          {
            perror("write");
            return;
          }
         num++;
         cout<<buffer<<endl;
    }
}
void reader(int rfd)
{
    char buffer[N];
      while(true)
      {
        int n= read(rfd,buffer,sizeof(buffer));
        if(n<0)
        {
            perror("read");
            return;
        }
        buffer[n]='\0';
        cout<<"I am father:"<<buffer<<endl;
      }
}
int main()
{
    int ant[2];
     int n=pipe(ant);
     if(n<0)
     {
        perror("pipe");
        return EXIT_FAILURE;
     }
     int id=fork();
     if(id==0)
     {
        close(ant[0]);
        writer(ant[1]);
     }
         close(ant[1]);
         reader(ant[0]);
         int statues;
         int k=waitpid(id,&statues,0);
         if(k<0)
         {
            perror("waitpid");
         }else if(k==0)
         {
            cout<<"Waiting"<<endl;
         }else
         {
            if(WIFEXITED(statues))
            {
                 cout<<"Chiled EXIT NORMALLY.EXIT_CODE:"<<WEXITSTATUS(statues)<<endl;
            }else
            {
                cout<<"child Fail"<<endl;
            }
         }            
    return 0;
}

Linux运行截图:
在这里插入图片描述

匿名管道的相关细节补充

那么我们如果此时如果让父进程在读取之前,先sleep上4秒钟,我们看看有什么样的效果:
在这里插入图片描述

那么根据这个结果,我们发现子进程写入了第2293个字符串后便不再写入了,之后便是父进程在一个劲的读取,之后子进程再进行写入

那么通过这个现象我们便知道,如果子进程写入的内容过多或者速度过快,那么会让该管道文件的对应的页缓存给写满,那么此时操作系统便会让该进程给设置为阻塞状态,那么就得需要父进程来read读取该管道文件的页缓存来为该管道文件的页缓存来腾出空间

那么此时读者内心肯定有疑问:按照我理解的read接口的实现原理不就是是将该文件的页缓存中保存的数据给复制拷贝的到我的buffer数组中的吗,它怎么能做到清理页缓存来达到腾出空间的效果?其次就是系统怎么知道我该页缓存什么时候被写满什么时候是没写满呢?

那么读者之所以有这两个疑问,那么是因为对管道文件的file结构体的构成不熟悉,
普通文件的file结构体内部就会维护一个文件的读写偏移量,然后根据该偏移量去到基数树中定位对应的页缓存

而对于管道文件来说,它此时读写不再是共用一个偏移量,那么它读和写的偏移量则是分别用两个指针来记录,并且保存在struct pipe_inode_info中,那么这两个只读端以及只写端的结构体内部分别保存了一个指针,指向了该结构体,而其中的读指针head以及写指针tail就在这个结构体中而不是在file结构体中,而head指针则是记录下一次写入的起始位置而tail指针则是记录下一次读取的起始位置

struct pipe_inode_info {
    struct mutex mutex;
    wait_queue_head_t rd_wait, wr_wait;  // 读写等待队列[4][8]
    unsigned int head;                   // 读偏移量
    unsigned int tail;                   // 写偏移量
    unsigned int max_usage;              // 缓冲区最大容量
    struct pipe_buffer *bufs;            // 缓冲区页数组
};

而这里的读写指针和普通文件的读写指针不同的是,它进行了取模运算,那么该pipe_iode_info还记录一个字段也就是该管道文件的页缓存的总大小maxsize,而head以及tali是按照字节为单位来进行移动的,他们具体的计算方式则是:

首先得到要写入的长度len,然后从tail指针写入len字节的内容,写完之后的tail指针的位置则是:

(tail+len)%maxsize

对于head来说,那么它则是往后读取到tail指针所在的位置

而如果此时缓冲区为空,那么则是head==tail

而缓冲区为满,而由于head是当前读取页缓存的起始位置,而tail是当前的写入页缓存的起始位置,那么他们之间的间隔便是实际向管道文件的页缓存中写入的数据:

一旦(tail+1)%maxsize==head,那么即页缓存被写满

所以该管道文件的页缓存的读写实现采取的是一个循环队列的方式来读写,所以管道文件的页缓存被称之为环形页缓存

而具体对于管道文件的对应的页缓存的记录则是在struct pipe_info结构体中的buf结构体数组中记录,那么每一个元素是一个结构体其中内部封装了指向一个对应页的page结构体,也就意味着每一个buf结构体对应的页缓存是4KB,那么此时head写入的时候,就会按照位图的方式,先取模得到对应的页,在除以4096得到对应的页偏移量

struct pipe_buffer {
    struct page *page;       // 物理页指针
    unsigned int offset;     // 页内偏移
    unsigned int len;        // 有效数据长度
    unsigned int flags;      // 状态标志
};

结语

那么这就是本篇文章的全部内容,那么下一篇文章会继续讲解更多进程间通信的方式,那么本文讲的管道文件实现通信的方式只能就应用于父子进程或者带有血缘关系的进程,而非血缘关系的进程的实现方式又有所不同,那么我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请多多三连加关注哦,你的支持就是我创作的最大的动力!
在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐