进程间通信之管道(初学者必备)

x33g5p2x  于2022-05-20 转载在 其他  
字(10.0k)|赞(0)|评价(0)|浏览(201)

进程间通信的介绍

进程间通信的概念

进程间通信 (Inter-Process Communication,IPC)则是多进程协作的基础。 一般而言,IPC至少需要两方 (如两个进程)参与。 根据信息流动的方向,这两方通常被称为发送者和接收者。

进程间通信的目的

  • 数据传输:一个进程需要将它的数据交给另外一个进程
  • 资源共享:多个进程之间共享同样的资源
  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
  • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变

进程间通信的几种方式

进程间通信的本质

进程间通信的本质其实是:让不同的进程看到了同一份资源。

通过进程的学习我们知道进程之间是具有独立性的,各自进程的数据对方是看不到的,就算是父子进程随意他们的数据是共享的但一旦方式写入。就会发生写时拷贝,数据各自私有一份,所以了进程间进行通信是很困难的。
这也就意味着进程之间要想进行通信,一定要借助第三方资源。这个第三方资源不属于这些进行通信进程中的任何一个。有了这个第三方资源这些进程可以向这个资源里面写入数据或者读取数据,进而实现进程间通信。而这个第三方资源通常是OS提供的内存区域。因此我们可以得出结论:进程间通信的本质是让不同进程看到同一份资源。

管道

管道的概念

管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
例如:我们使用统计一个文件中代码的行数。

其中cat指令和wc指令是两个程序当他们运行起来时就变成进程了cat通过标准输出将数据放到管道中wc再从管道中拿数据,至此就完成了进程间通信。
注意:用wc指令我们可以计算文件的Byte数、字数、或是列数,若不指定文件名称、或是所给予的文件名为"-",则wc指令会从标准输入设备读取数据。

匿名管道

匿名管道是用于具有血缘关系的进程之间进行通信(多用于父子进程通信)

我们在上面说进程间通信的本质是让不同的进程看到同一份资源,那么使用匿名管道也一定要让父子进程看到同一份资源。匿名管道其实是让父子进程看到了同一份被打开的文件资源,本质其实是一段内核缓冲区。然后了父子进程就可以对这个资源进行读写操作了,进而实现了进程间通信。

注意:

  • 这里父子进程看到的同一份文件资源是由OS进行维护的。当父子进程对该文件进行写入操作时,数据不会进行写时拷贝
  • 管道虽然是文件但是OS不会将进程之间通信的数据刷新到磁盘当中。如果这样做的话效率会变得非常的低

pipe函数

pipe函数是用于创建匿名管道,pipe函数原型如下:

int pipe(int pipefd[2]);

pipe函数的参数是一个输出型参数,数组pipefd是用来获取读端和写端的文件描述符

数组元素含义
pipefd[0]管道读端的文件描述符
pipefd[1]管道写端的文件描述符

pipe函数调用成功返回0失败返回-1.

匿名管道的使用步骤

在创建匿名管道实现父子进程通信的过程中需要pipe函数和fork创建子进程搭配起来。具体步骤如下:
1.父进程调用pipe函数创建匿名管道

2.父进程创建子进程

上面我们说了管道是内核里面的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。
看到这,你可能会有疑问了,这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?
我们可以使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0] 与 fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。
3.管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和子进程都可以同时写入也都可以读出。那么,为了避免这种情况,通常的做法是:父进程关闭写端,子进程关闭读端。

下面我们通过一段代码进行演示父子进程进行通信

#include <iostream>
#include <unistd.h>
#include <cstring>
#include <stdlib.h>
#include <cstdio>
using namespace std;
//进程间通信

> 这里是引用

int main()
{

    // pipefd[2]是一个输出型参数,我们想通过这个输出型参数得到打开的两个fd
    // int pipe(int pipefd[2]);
    int pipefd[2] = {0}; //让两个进程看到同一份资源
    //注意0下标代表的是读取端,1下标代表的写入端
    if (pipe(pipefd) != 0)
    {
        cerr << "pipe erro" << endl;
    }
    //目的让父进程进行读取,子进程进行写入
    if (fork() == 0)
    { //子进程
        close(pipefd[0]);
        const char *msg = "hello ksy";
        int cnt=0;
        while (true) //父进程没有sleeep
        {
            //只要pipe里面有空间就一直写入
            // write(pipefd[1], msg, strlen(msg)); //注意不要写入\0;
            write(pipefd[1], msg, strlen(msg));
        }

    }
    //父进程
    close(pipefd[1]);
    while (true)
    {
       
        char buffer[64] = {0};
        
        //只要pipe里面有数据就读
        ssize_t s = read(pipefd[0], buffer, sizeof(buffer) - 1); //如果read的返回值为0说明子进程将文件描述符给关闭了
        if (s > 0)
        {
            buffer[s] = 0;
            printf("child process say to :%s\n", buffer);
        }
        else if (s == 0)
        {
            break;
        }
        else
        {
            cout << "read fail" << endl;
        }
    }

    //管道是一个只能单向通信的通信信道
    //管道是面向字节流的
    //仅限于具有亲缘关系的进程
    //管道自带同步机制原子性写入

    return 0;
}

管道的特点

1.管道的生命周期是随进程的
管道的本质是通过文件进行通信,也就是说管道依赖于文件系统,打开文件的进程退出后该打开的文件会被释放掉。也就是管道的生命周期随进程

2.管道自带同步和互斥机制

我们将多个执行流共同看到的资源叫做临界资源,管道在一个时刻只允许一个进程对其写入和读取。临界资源需要保护,如果我们不对临界资源进行保护,就可能出现一个时刻有多个执行流对同一个管道进行操作。会导致同时读或者写和交叉问题以及数据不一致。

为了避免这些问题OS会对管道的操作进行同步和互斥

  • 互斥:一个公共资源一个时刻只能被一个进程访问不允许多个进程同时访问这个公共资源
  • 同步:在保证数据安全的情况下,让多个执行流访问临界资源具有一定的顺序性。

3.管道是流式服务

进程A向管道写入数据,进程B向管道里面读取数据是任意的。这就叫做流式服务,与之对应的有数据服务:数据服务有明确的分割拿数据按照报文段拿

匿名管道的特殊情况

1.读端进程将读端关闭而写端进程还一直在向管道写入数据,这个时候OS会将写端进程杀死。
2.写端进程不向管道中写入了,而读端进程一直在读那么此时读端进程会因为管道内没有数据被挂起直到管道内有数据了读端进程才会被唤醒。
3.读端进程不读而写端进程一直在往管道里面写入数据当管道被写满时写端进程会被挂起直到读端进程读取一定量的数据之后写端进程才会向管道中写入数据。
4.写端进程将写端关闭那么读端进程将管道里面的数据读取完毕之后会继续执行后续代码不会被挂起。

下面我们来看一下上面四种情况中的情况一:
读端进程已经将读端给关闭了也就意味着没有进程读取了,此时写端进程都写入也就变得没有什么意义了。OS将其杀死也是非常合理的所以了此时写端进程被异常终止属于异常退出说明写端进程必然收到了某种信号。下面我们来验证一下写端进程收到了什么信号

对应测试代码:

#include <iostream>
#include <unistd.h>
#include <cstring>
#include <stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include <cstdio>
using namespace std;
//进程间通信
int main()
{

    // pipefd[2]是一个输出型参数,我们想通过这个输出型参数得到打开的两个fd
    // int pipe(int pipefd[2]);
    int pipefd[2] = {0}; //让两个进程看到同一份资源
    //注意0下标代表的是读取端,1下标代表的写入端
    if (pipe(pipefd) != 0)
    {
        cerr << "pipe erro" << endl;
    }
    //目的让父进程进行读取,子进程进行写入
    if (fork() == 0)
    { //子进程
        close(pipefd[0]);
        const char *msg = "hello ksy";
        int cnt=0;
        while (true) //父进程没有sleeep
        {
            //只要pipe里面有空间就一直写入
            // write(pipefd[1], msg, strlen(msg)); //注意不要写入\0;
            write(pipefd[1], msg, strlen(msg));
            sleep(1);
        }

    }
    //父进程
    close(pipefd[1]);
    sleep(1);
    close(pipefd[0]);//父进程关闭读端
    
    

    //管道是一个只能单向通信的通信信道
    //管道是面向字节流的
    //仅限于具有亲缘关系的进程
    //管道自带同步机制原子性写入
    int status=0;//获取子进程的退出状态
     waitpid(-1,&status,0);
    printf("child process get signal:%d\n",status&0x7F);

    return 0;
}

//  sleep(10);
//         char c[1024*4+1]={0};
//         ssize_t s=read(pipefd[0],c,sizeof(c));
//         c[s]=0;
//         cout<<c[0]<<endl;

下面我们将程序跑起来:

我们发现它收到了13号信号,下面我们使用kill -l查看13号信号。

因此当发生情况一时OS会向子进程发生SIGPIPE信号将子进程终止

管道的大小

方法一:使用 ulimit -a 查看

方法二:通过man 手册
man 7 pipe

方法三:自己测试
通过上面两种方法我么看到了管道的容量,下面我们自己测试一下管道的容量是多少。下面我么让写端进程一直写入而读端进程不读,这样一来当管道满了写端进程就会被挂起。这样我们就能测试出管道容量的大小了。
对应代码:

#include <iostream>
#include <unistd.h>
#include <cstring>
#include <stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include <cstdio>
using namespace std;
//进程间通信
int main()
{

    // pipefd[2]是一个输出型参数,我们想通过这个输出型参数得到打开的两个fd
    // int pipe(int pipefd[2]);
    int pipefd[2] = {0}; //让两个进程看到同一份资源
    //注意0下标代表的是读取端,1下标代表的写入端
    if (pipe(pipefd) != 0)
    {
        cerr << "pipe erro" << endl;
    }
    //目的让父进程进行读取,子进程进行写入
    if (fork() == 0)
    { //子进程
        close(pipefd[0]);
      
        int cnt=0;
        while (true) //父进程没有sleeep
        {
            //只要pipe里面有空间就一直写入
            // write(pipefd[1], msg, strlen(msg)); //注意不要写入\0;
            char ch='k';
            write(pipefd[1], &ch, 1);
            cnt++;
            cout<<cnt<<endl;
        }
        close(pipefd[1]);
        exit(0);

    }
    //父进程
    close(pipefd[1]);
  
   

    //管道是一个只能单向通信的通信信道
    //管道是面向字节流的
    //仅限于具有亲缘关系的进程
    //管道自带同步机制原子性写入
    int status=0;//获取子进程的退出状态
     waitpid(-1,&status,0);
    printf("child process get signal:%d\n",status&0x7F);

    return 0;
}

通过测试可以发现我这个云服务器上写端进程最多写入65536个字节就会被OS挂起也就是说当前这个云服务器上管道的最大容量是65536字节

命名管道

命名管道的由来

上面我们说匿名管道只能用于具有血缘关系的进程之间进行通信通常是用于父子进程。一般都是一个进程创建管道然后使用fork创建子进程然后父子进程就可以通信了。但是这具有很强的局限性,如果我们想要两个毫不相关的进程之间进行通信,就要使用命名管道。命名管道是一种特殊的文件,进程打开同一个命名管道文件,此时打开这个文件的进程就看到了同一份资源,所以了他们就可以进程通信了。
注意:
普通文件很难做到通信即使做到了也解决不了一些安全问题
命名管道和匿名管道是一样的都是内存文件并将都不会将数据刷新到磁盘中。

使用系统命令创建命名管道

我们可以使用mkfifo创建一个命名管道:

mkfifo fifo

我们可以发现此时创建出来的文件类型是p类型代表该文件是命名管道,下面我们使用这个命名管道实现进程A和进程B直接的通信(其中进程A和进程B是毫不相关的两个进程)

我们在一个终端下每隔一秒将字符串“hello ksy"重定向到管道中,然后新建一个中端让其从管道中读取数据完成毫不相关的两个进程之间进行通信。
同样的我们之前在匿名管道中说过如果读端进程退出写端进程再向管道中写入数据就会变得毫无意义,此时再命名管道这里也是成立的。

使用系统调用创建命名管道

在程序中创建命名管道我们可以使用mkfifo函数。mkfifo函数原型如下:

int mkfifo(const char*pathname,mode_t mode);

函数解释:第一个参数

其中mkfifo的第一个参数pathname表示的是要创建命名管道的文件。
注意:
如果pathname以路径的方式给出那么命名管道将在pathnaem路径下创建
如果pathname以文件名的形式给出那么命名管道将会在当前路径下创建(当前路径在文件哪里已经详细解释过了)

第二个参数:

mkfifo的第二个参数mode是管道文件的权限比如我们创建管道文件时将管道文件的权限设置为0666

此时我们发现管道文件对应的权限并不是0666而是0644.这是为什么了。这其实和我们学习指令时的权限掩码有关即umask。实际创建出来的文件的权限其实是mode&(~umask).而umask在普通用户一般是0002所以了实际创建出来的管道文件的权限是0644.如果我们想要创建出来的文件的权限不受umask的影响我们可以在创建管道文件之前使用umask函数将权限掩码设置为0即umask(0)。注意:如果是在某个进程中使用umask(0)只在当前进程有效。不会影响系统的权限掩码

函数返回值:
1.创建成功返回0
2.失败返回-1

使用命名管道实现server和client之间的通信

下面我们来实现一下服务端和客户端之间的通信,注意进行通信之前先要启动服务端,因为我们要服务端把命名管道创建出来并且以读的方式打开这个文件,而客户端以写的方式打开管道文件并且以写的方式打开。这样服务端就可以收到客户端发过来的数据了。

服务端对应代码如下:

#include"comm.h"
int main(){
       //umask(0);//注意尽在当前进程有效不会影响系统的权限掩码
   if(mkfifo(MY_FIFO,0666)<0){
       cerr<<"mkfifo fail"<<endl;
       return 1;
   }
   //只需要文件操作即可

   int fd=open(MY_FIFO,O_RDONLY);
   if(fd<0){
       cerr<<"open fail"<<endl;
       return 2;
   }

    //业务逻辑可以进行对应的读写
    while(true){
    char buffer[64]={0};
    ssize_t s=read(fd,buffer,sizeof(buffer)-1);
    if(s>0){
        //会多一个/n
    
     buffer[s]=0;
     printf("client:%s\n",buffer);
    }
    else if(s==0){//对方关闭
      cout<<"client quit"<<endl;
      break;
    }

    else{
        cout<<"读取失败"<<endl;
        break;
    }

    }
   close(fd);
    return 0;
}

此时我们将客户端跑起来之后命名管道就已经被创建。所以客户端只需要用写的方式打开这个命名管道即可,这样就可以实现客户端和服务端之间的通信
对应客户端代码:

#include"comm.h"
int main(){
  //管道文件已经被创建只需要打开就可以了
  int fd=open(MY_FIFO,O_WRONLY);
  //不需要创建了
  if(fd<0){
      cerr<<"open fail"<<endl;
  }
  //业务逻辑
  while(true){
     printf("请输入:###########################");
    fflush(stdout);
     char buffer[64]={0};
      //先把标准输入拿道client进程的内部
      ssize_t s=read(0,buffer,sizeof(buffer)-1);
      if(s>0){
          buffer[s-1]=0;
          cout<<buffer<<endl;
          write(fd,buffer,strlen(buffer));
      }
  }

   close(fd);
    return 0;
}

在这里客户端和服务端共有一个头文件,该头文件提供共用管道的文件名
客户端和服务端同时打开

对应comm.h

#pragma once
#include <iostream>
#include<string.h>
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
using namespace std;
#define MY_FIFO "./fifo"

代码编写完毕之后我们先让服务端先跑起来然后了我们就可以看到我们命名管道被创建出来,然后我们在让客户端允许起来。然后我们就可以进行通信了:

其中客户端和服务端的退出关系是:
当客户端退出时服务端将数据读取完毕之后就读不到数据此时服务端也就去执行其他代码去了,当服务端退出之后客户端的写入也就没有任何意义了此时操作系统会向客户端发送13号信号SIGPIPE信号将客户端终止。
注意:命名管道的文件的大小是不会变的因为不会将数据刷新到磁盘中

使用命名管道派发任务

两个进程之间进行通信并不是只能发送字符串服务端可以对客户端的发送过来的数据进行处理
在这里我们只需要将上面的代码稍微改一下就可以了,改变服务端处理信息的方式即可。
对应server.cc代码:

#include"comm.h"
#include<stdlib.h>
#include<wait.h>
int main(){
       //umask(0);//注意尽在当前进程有效不会影响系统的权限掩码
   if(mkfifo(MY_FIFO,0666)<0){
       cerr<<"mkfifo fail"<<endl;
       return 1;
   }
   //只需要文件操作即可

   int fd=open(MY_FIFO,O_RDONLY);
   if(fd<0){
       cerr<<"open fail"<<endl;
       return 2;
   }

    //业务逻辑可以进行对应的读写
    while(true){
    char buffer[64]={0};
    ssize_t s=read(fd,buffer,sizeof(buffer)-1);
    if(s>0){
        //会多一个/n
     buffer[s]=0;
     if(strcmp(buffer,"show")==0){

         if(fork()==0){
          //子进程
           execl("/usr/bin/ls","ls","-l",NULL);
           exit(1);
         }
          waitpid(-1,NULL,0);
     }
     else if(strcmp(buffer,"run")==0){
           if(fork()==0){
          //子进程
           execl("/usr/bin/sl","sl",NULL);
           exit(1);
         }
          waitpid(-1,NULL,0);
     }
    else {
     printf("client:%s\n",buffer);
    }
    
    }
    else if(s==0){//对方关闭
      cout<<"client quit"<<endl;
      break;
    }

    else{
        cout<<"读取失败"<<endl;
        break;
    }

    }
   close(fd);
    return 0;
}

对应client代码:

#include"comm.h"
int main(){
  //管道文件已经被创建只需要打开就可以了
  int fd=open(MY_FIFO,O_WRONLY);
  //不需要创建了
  if(fd<0){
      cerr<<"open fail"<<endl;
  }
  //业务逻辑
  while(true){
     printf("请输入:###########################");
    fflush(stdout);
     char buffer[64]={0};
      //先把标准输入拿道client进程的内部
      ssize_t s=read(0,buffer,sizeof(buffer)-1);
      if(s>0){
          buffer[s-1]=0;
          cout<<buffer<<endl;
          write(fd,buffer,strlen(buffer));
      }
  }

   close(fd);
    return 0;
}

comm.h

#pragma once
#include <iostream>
#include<string.h>
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
using namespace std;
#define MY_FIFO "./fifo"

相关文章