Framework—进程间通信(IPC) 系列之管道(pipe)

551 阅读11分钟

1.什么是管道 ?

所谓管道,是指用于连接一个读进程和一个写进程,以实现它们之间通信的共享文件,又称 pipe 文件。

向管道(共享文件)提供输入的发送进程(即写进程),以字符流形式将大量的数据送入管道;而接收管道输出的接收进程(即读进程),可从管道中接收数据。由于发送进程和接收进程是利用管道进行通信的,故又称管道通信。

为了协调双方的通信,管道通信机制必须提供以下3 方面的协调能力。

  • 互斥。当一个进程正在对 pipe 进行读/写操作时,另一个进程必须等待。
  • 同步。当写(输入)进程把一定数量(如4KB)数据写入 pipe 后,便去睡眠等待,直到读(输出)进程取走数据后,再把它唤醒。当读进程读到一空 pipe 时,也应睡眠等待,直至写进程将数据写入管道后,才将它唤醒。
  • 对方是否存在。只有确定对方已存在时,才能进行通信。

2.pipe()函数创建管道

2.1 函数原型

包含头文件

#include <unistd.h>
  • 函数原型
int pipe(int pipefd[2]);
​
#define _GNU_SOURCE
#include <unistd.h>int pipe2(int pipefd[2], int flags);
  • 函数功能
pipe() creates a pipe, a unidirectional data channel that can be used for interprocess communication.
  • 函数参数

pipefd[2]:读端和写端的文件描述符 函数返回值

On success, zero is returned.
On error, -1 is returned, and errno is set appropriately.

2.2 工作原理

一般来说,要在子进程创建之前使用pipe()来创建管道,这样子进程才能共享这两个文件描述符fd[1]和fd[2]。pipe()函数创建一个管道就相当于打开了一个伪文件(这个伪文件实际上是内核缓冲区,像管道文件读写数据其实是在读写内核缓冲区,因为这个缓冲区只能单向流通数据,所以形象的称为管道),所以调用成功会返回两个文件描述符给参数pipefd[2],其中fd[0]代表读端,fd[1]代表写端,就像0代表标准输入1代表标准输出一样作为一种规定。并且这两个文件描述符在使用的时候不需要open()打开,但是需要我们手动的close()关闭。

管道创建成功后,父进程同时拥有读写两端,因为子进程是对父进程的复制,所以子进程也会拥有读写两端。下面通过图示来说明进程间是如何通过管道通信的。

① 父进程调用pipe()函数创建管道,并得到指向管道读端和写端的文件描述符fd[0]和fd[1]。创建出来的管道实际上是内核的一块缓冲区,我们可以像读写文件一样来操作这个缓冲区,所以也可以把他理解为一个伪文件。

② 父进程调用fork()创建子进程,子进程将共享这两个指向管道读写端的文件描述符。

③ 如果父进程关闭管道读端,子进程关闭管道写端,此时父进程可以向管道中写入数据,子进程将管道中的数据读出,反之同理。由于管道是利用环形队列实现的,数据从写端流入管道,从读端流出,这样就实现了进程间通信。

2.3 通过实战分析管道的特性

示例1:父子进程读写管道

/************************************************************
  >File Name  : pipe_test.c
  >Author     : Mindtechnist
  >Company    : Mindtechnist
  >Create Time: 2022年05月21日 星期六 17时53分56秒
  >************************************************************/
  >#include <stdio.h>
  >#include <stdlib.h>
  >#include <unistd.h>
​
int main(int argc, char* argv[])
{
    int fd[2];
    pipe(fd);
    pid_t pid = fork();
    
    if(pid == 0)
    {
        /*子进程向管道写*/
        /*sleep(3); read读设备的时候,默认是会阻塞等待的,写进程睡眠的时候,读进程会阻塞等待,直到读取到数据*/
        char str[] = "hello pipe...\n";
        write(fd[1], str, sizeof(str));
    }
    if(pid > 0)
    {
        char buf[15] = {0}; /*创建一个缓冲区来缓存读出的数据*/
        /*read读设备的时候,默认是会阻塞等待的*/
        int ret = read(fd[0], buf, sizeof(buf));
        if(ret > 0)
        {
            write(STDOUT_FILENO, buf, ret);
        }
    }
    return 0;
}
​

由于resd()函数读设备时默认阻塞等待的特性,即使写进程没有立即写,读进程也能读到数据,因为它会阻塞等待。

❀示例2:使用管道实现 ps | grep 命令

/************************************************************
  >File Name  : mpsgrep.c
  >Author     : Mindtechnist
  >Company    : Mindtechnist
  >Create Time: 2022年05月21日 星期六 18时08分56秒
  >************************************************************/
  >#include <stdio.h>
  >#include <stdlib.h>
  >#include <unistd.h>int main(int argc, char* argv[])
{
    int fd[2];
    pipe(fd);
    pid_t pid = fork(); /*一个进程执行ps一个进程执行grep来实现 ps | grep*/
    
    if(pid == 0) /*子进程执行ps*/
    {/*把ps的执行结果传给grep,所以子进程写,父进程读*/
        /*首先把ps命令的执行结果重定向到管道的写端(默认将执行结果输出到stdout)*/
        dup2(fd[1], STDOUT_FILENO);
        /*拉起ps进程*/
        execlp("ps", "ps", "aux", NULL);
    }
    
    if(pid > 0) /*父进程执行grep*/
    {
        /*把grep读取重定向到fd[0],因为默认grep是在stdin获取输入的*/
        /*如果在shell命令行使用grep,模式是在标准输入中匹配*/
        dup2(fd[0], STDIN_FILENO);
        /*拉起grep进程*/
        execlp("grep", "grep", argv[1], NULL);
    }
    
    return 0;
}
​

上面的程序执行后,可以看到输出结果,确实显示了bash相关的进程信息

我们再起一个终端,使用 ps aux 命令查看进程会发现,子进程中拉起的ps进程变成了僵尸进程,并且父进程没有退出。(实际上,如果父进程退出了,子进程就会被init进程收养并回收)

ps进程变成僵尸进程是因为,我们在父进程中并没有回收子进程,因为execlp()函数拉起一个进程后,如果执行成功,就不会再返回了,那么我们也没办法去回收这个子进程ps。但是我们知道,如果父进程终止了,子进程就会被init进程收养并回收,所以我们只要让父进程(也就是程序中的grep进程)退出,就可以解决子进程回收问题了。

下面,我们分析下父进程为什么没有退出,正常情况下,父进程执行完grep命令就应该正常退出的。实际上,这是管道的特性引起的,我们知道,pipe()创建管道后会在内核分配一个缓冲区,并返回两个文件描述符,父进程和子进程都持有读写这两个文件描述符。我们在进程间通信的时候,因为管道是单向数据流通,所以只有一个进程写一个进程读,比如上面的程序,我们让子进程写,让父进程读,但这并不代表父进程不持有写端文件描述符。问题就在这里,虽然子进程已经变成了僵尸进程,但是父进程依然持有写端文件描述符,所以父进程就会认为还存在其他进程来写入管道,于是父进程就会等待写入,而不退出。

解决方法就是,我们在进程间通信时,要保证数据单向流通,在读进程中关闭管道的写端文件描述符,在写进程中关闭管道的读端文件描述符。我们依据这个原则来改造一下上面的程序即可。

/************************************************************
  >File Name  : mpsgrep_02.c
  >Author     : Mindtechnist
  >Company    : Mindtechnist
  >Create Time: 2022年05月21日 星期六 18时08分56秒
  >************************************************************/
  >#include <stdio.h>
  >#include <stdlib.h>
  >#include <unistd.h>int main(int argc, char* argv[])
{
    int fd[2];
    pipe(fd);
    pid_t pid = fork(); /*一个进程执行ps一个进程执行grep来实现 ps | grep*/
    
    if(pid == 0) /*子进程执行ps*/
    {/*把ps的执行结果传给grep,所以子进程写,父进程读*/
        /*关闭读端文件描述符,保证数据单向流通*/
        close(fd[0]);
        /*首先把ps命令的执行结果重定向到管道的写端(默认将执行结果输出到stdout)*/
        dup2(fd[1], STDOUT_FILENO);
        /*拉起ps进程*/
        execlp("ps", "ps", "aux", NULL);
    }
    
    if(pid > 0) /*父进程执行grep*/
    {
        /*关闭写端文件描述符,保证数据单向流通,防止读进程阻塞*/
        close(fd[1]);
        /*把grep读取重定向到fd[0],因为默认grep是在stdin获取输入的*/
        /*如果在shell命令行使用grep,模式是在标准输入中匹配*/
        dup2(fd[0], STDIN_FILENO);
        /*拉起grep进程*/
        execlp("grep", "grep", argv[1], NULL);
    }
    
    return 0;
}
​

这样,父进程就不会阻塞等待,而是直接退出,而子进程也不会产生僵尸进程。

3.管道的读写行为

使用管道进行进程间通信的时候,假设没有设置O_NONBLOCK标志(也就是说都是阻塞I/O操作),有以下几种特殊情况

  • 如果所有指向管道写端的文件描述符都关闭了(管道写端引用计数为0),而仍然有进程从管道的读端读数据,那么管道中剩余的数据都被读取后,再次read会返回0,就像读到文件末尾一样。
  • 如果有指向管道写端的文件描述符没关闭(管道写端引用计数大于0),而持有管道写端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次read会阻塞,直到管道中有数据可读了才读取数据并返回。
  • 如果所有指向管道读端的文件描述符都关闭了(管道读端引用计数为0),这时有进程向管道的写端write,那么该进程会收到信号SIGPIPE,通常会导致进程异常终止。当然也可以对SIGPIPE信号实施捕捉,不终止进程。(在讲信号的时候会细说)
  • 如果有指向管道读端的文件描述符没关闭(管道读端引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再次write会阻塞,直到管道中有空位置了才写入数据并返回。

其实,总的来说可以分为读管道和写管道两种的情况

读管道

  1. 如果管道中有数据,read返回实际读到的字节数。
  2. 如果管道中无数据:
  • 如果管道写端被全部关闭,read返回0,相当于读到文件结尾。
  • 如果写端没有全部被关闭,read阻塞等待(不久的将来可能有数据递达,此时会让出cpu),如果不想让read阻塞,可以使用fcntl设置非阻塞。

写管道

  1. 如果管道读端全部被关闭,会产生一个信号SIGPIPE,进程异常终止(也可使用捕捉SIGPIPE信号,使进程不终止)。
  2. 如果管道读端没有全部关闭
  • 如果管道已满,write阻塞,(管道实际上是内核中的一个缓冲区,它是有大小的)。
  • 如果管道未满,write将数据写入,并返回实际写入的字节数。
/************************************************************
  >File Name  : pipe_test2.c
  >Author     : Mindtechnist
  >Company    : Mindtechnist
  >Create Time: 2022年05月21日 星期六 17时53分56秒
  >************************************************************/
  >#include <stdio.h>
  >#include <stdlib.h>
  >#include <unistd.h>
  >#include <sys/types.h>
  >#include <sys/wait.h>
​
int main(int argc, char* argv[])
{
    int fd[2];
    pipe(fd);
    pid_t pid = fork();
    
    if(pid == 0)
    {
        sleep(3); 
        close(fd[0]); /*关闭读端*/
        char str[] = "hello pipe...\n";
        write(fd[1], str, sizeof(str));
        close(fd[1]); /*关闭写端*/
        while(1)
        {
            sleep(1);
        }
    }
    if(pid > 0)
    {
        close(fd[1]); /*关闭写端*/
        close(fd[0]); /*关闭读端*/
        char buf[15] = {0}; 
        
        int status;
        wait(&status);
        if(WIFSIGNALED(status))
        {
            printf("kill: %d\n", WTERMSIG(status));
        }
        while(1)
        {
            int ret = read(fd[0], buf, sizeof(buf));
            if(ret > 0)
            {
                write(STDOUT_FILENO, buf, ret);
            }
        }
    }
    return 0;
}
​

4.管道(缓冲区)大小

使用命令查看

ulimit -a

管道大小是8个512byte的大小。

也可以使用函数fpathconf()查看

#include <unistd.h>long fpathconf(int fd, int name); 
/*fd可以是fd[0]或fd[1],name是一个选项*/

实际上使用 ulimit -a 看到的是内核给管道的大小,但是管道的容量实际上可能要比这个值大。

5.管道的优缺点

优点:

  • 简单,相比信号,套接字实现进程间通信,简单很多。(其实要想实现父进程和子进程双向通信,可以创建两个管道)

缺点:

  • 只能单向通信,双向通信需建立两个管道。
  • 只能用于有血缘关系的进程间通信(父子、兄弟等有共同祖先的进程),有名管道可解决该问题。

以上就是Android Framework中的IPC通信协议中的管道(pipe)解析;关于framework的通信机制或更多framework的进阶,可以> 以下链接:

传送直达↓↓↓ :link.juejin.cn/?target=htt…

总结

  • 管道也称无名管道,是 UNIX 系统中进程间通信(IPC)中的一种。
  • 管道由于是无名管道,因此只能在有亲缘关系的进程间使用。
  • 管道不是普通的文件,它是基于内存的。
  • 管道属于半双工,数据只能从一方流向另一方,也即数据只能从一端写,从另一端读。
  • 管道中读数据是一次性的操作,数据读取后就会释放空间,让出空间供更多的数据写。
  • 管道写数据遵循先入先出的原则。