进程通信之管道(PIPE)

时间:2021-11-14 01:51:56

在前面进程通信概念和进程通信方式,我们了解了进程通信的简单概念以及4种进程通信的方式,今天我们将要通过具体实例来学习,理解进程通信方式中的管道(PIPE)

本文所有代码都在Ubuntu16.04测试。


我们在前面已经了解了常用的进程间通信方式,它们大致可以以如下方式分类:

A. 传统的进程间通信方式
无名管道(pipe)、有名管道(fifo)和信号(signal)

B. System v IPC对象
共享内存(share memory)、消息队列(message queue)和信号量(semaphore)

C. BSD
套接字(socket)

截取网上的一张图:

进程通信之管道(PIPE)

今天,我们主要聊聊无名管道,以及有名管道。


定义和特性

首先什么叫管道?

在聊管道之前,我们先看看管道是干嘛的?管道是用于进程通信的一种方式,说白了就是有两个或者多个进程要相互发送,接收信息,那么问题来了,我们知道进程的地址空间是各自独立的,(数据段写时拷贝,代码段共享),如果想对两进程AB里面数据共享操作,那么必须要有一种机制来保证,而管道就是这样的一种机制。

管道就是操作系统在内核中开辟的一段缓冲区,进程1可以将需要交互的数据拷贝到这段缓冲区,进程2就可以读取了,类似于下面这张图。

进程通信之管道(PIPE)

说白了,管道就有点像文件,将需要的数据放在这样一个文件里面,进程1向文件中写,进程2从文件中读。

管道有两种:

  1. 匿名管道
  2. 命名管道

说实在点,这两者区别不是很大,如果把管道理解为文件,命名管道就是知道名字的文件,匿名就是不知道名字的文件,当然了,它俩还是有区别的,最大的区别就是命名管道支持不相关进程通信,而匿名管道只支持有血缘关系的进程通信,比如父子进程,兄弟进程等等。。。由于区别不大,我们先讲述匿名管道,命名管道基本类似。

匿名管道有如下特性:

1、半双工通信(单向通信),数据在同一时刻只能在一个方向上流动。数据只能从管道的一端写入,从另一端读出。从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据。
2、写入管道中的数据遵循先入先出(FIFO)的规则。
3、面向字节流。管道所传送的数据是无格式的,这要求管道的读出方与写入方必须事先约定好数据的格式,如多少字节算一个消息等。
4、依赖于文件系统,生命周期随进程结束而结束
5、匿名管道没有名字,只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用。(常用于父子进程通信)
6、同步机制

在Linux下,管道是由pipe这个函数创建:

int pipe(int filedes[2]);
功能: 创建无名管道。
参数: filedes为 int 型数组的首地址,其存放了管道的文件描述符 filedes[0]、filedes[1]。
当一个管道建立时,它会创建两个文件描述符 fd[0] 和 fd[1]。其中 fd[0] 固定用于读管道,而 fd[1] 固定用于写管道。
返回值:成功:0 ; 失败:-1


1 . 当我们在父进程中创建了一个管道,它大概就是这样的:

进程通信之管道(PIPE)

当一个管道建立时,它会创建两个文件描述符 fd[0] 和 fd[1]。其中 fd[0] 固定用于读管道,而 fd[1] 固定用于写管道。

2 . 我们fork出子进程后,子进程对它进行了一次拷贝。

进程通信之管道(PIPE)

3 . 由于管道是单向通信,如果是子进程写,父进程读取的话,需要关闭子进程的读端和父进程的写端。数据从写端流入从读端流出,这样就实现了进程间通信。

下面我们看一个例子:子进程想管道写10次i am child,父进程从中读取。

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>

#define TIMES 10


int main()
{
//create a pipe
int pe[2] = {0};
if (pipe(pe) == -1)
{
perror("pipe failure\n");
exit(1);
}

int pid = fork();

if (pid < 0)
{
perror("fork failure\n");
exit(2);
}
else if (pid == 0) //child
{
close(pe[0]); //close reading
int i = 0;

const char* message = "i am child\n";

for (; i < TIMES; ++i)
{
write(pe[1], message, strlen(message));
sleep(1);
}

}
else // father
{
close(pe[1]); //close writing

char buffer[100] = {'\0'};
int i = 0;
for (; i < TIMES; ++i)
{
read(pe[0], buffer, sizeof(buffer));
printf("#child : %s", buffer);
}

}

return 0;
}

结果如下:

进程通信之管道(PIPE)

注意到,我在这里让子进程一秒一写,而父进程会等待子进程,直到管道中有数据才读取,这样子保证同步机制。借别人图一用~如下:

进程通信之管道(PIPE)

对于读进程
当read一个写端已经关闭的管道时,在所有的数据都被读取之后,read返回0标识管道读取完毕。从技术上来讲,如果管道的写端还有进程存在,就不会产生文件的结束。如果写端存在,但是管道中没有数据,此时读端会被阻塞。
如果读取一个写端还存在的进程,如果一次读取的数据字节数大于管道能够容纳的最大字节数(PIPE _ BUF表示管道能够容纳的最大字节数,可以通过pathconf和fpathconf函数获取该值),则返回管道中现有的字节数;如果请求的数据量不大于PIPE_BUF,则返回请求读取的字节数(请求的字节数小于管道现有的字节数)或者管道中现有的字节数(管道中现有的字节数小于请求的字节数)。

对于写进程
向管道中写数据的时候,PIPE_ BUF规定了内核中管道缓冲区的大小。如果对管道调用write且所写的字节数小于等于PIPE _ BUF,则此操作不会与其他进程对同一管道的的写操作交叉进行。但是如果有多个进程对管道同时写一个管道,且所写的字节数大于PIPE_BUF,那么缩写的数据就会与其他的进程交叉。如果管道已经满了,且没有读进程读取管道中的数据,此时写操作将会被阻塞。

我们测试看一下匿名管道有多大??

#include<stdio.h> 
#include<sys/types.h>
#include<unistd.h>
#include<fcntl.h>
#include<errno.h>
#include<stdlib.h>
#include<string.h>
int main()
{
int _pipe[2];
if(pipe(_pipe)==-1)
{
printf("pipe error\n");
return 1;
}
int ret;
int count=0;
int flag=fcntl(_pipe[1],F_GETFL);
fcntl(_pipe[1],F_SETFL,flag|O_NONBLOCK);
while(1)
{
ret=write(_pipe[1],"A",1);
if(ret==-1)
{
printf("error %s\n",strerror(errno));
break;
}
count++;
}
printf("count=%d\n",count);
return 0;
}

结果如下:

error Resource temporarily unavailable
count=65536

可见pipe_buff有64k大小。这篇文章对于管道大小有着说明。

另一个需要注意的问题就是,只有在读端存在的情况下,向管道中写数据才有意义。如果读端不存在了(压根就没有或者已经被关闭),那么会产生信号SIGPIPE,应用程序可以处理该信号或者忽略(默认动作是使得应用程序终止)。

在我们之前,例子中,我们是一秒一写,假设写10次也就是至少10秒才能写完,我们在10s之前将读端关闭验证我们所说的。

代码我们只在父进程中修改:

   if (pid < 0)
{
perror("fork failure\n");
exit(2);
}
else if (pid == 0) //child
{
close(pe[0]); //close reading
int i = 0;

const char* message = "i am child\n";

for (; i < TIMES; ++i)
{
write(pe[1], message, strlen(message));
sleep(1);
}

}
else // father
{
close(pe[1]); //close writing

char buffer[100] = {'\0'};
int i = 0;
for (; i < TIMES / 3; ++i) //只读取3次
{
read(pe[0], buffer, sizeof(buffer));
printf("#child : %s", buffer);
}
close(pe[0]);//关闭父进程的读端

int status = 0;
int ret = waitpid(pid, &status, 0);
if (ret > 0)
{
if (WIFSIGNALED(status))
{
printf("signal is %d\n", WTERMSIG(status));
}
if (WIFEXITED(status))
{
printf("exitCode is %d\n", WEXITSTATUS(status));
}
}

}

进程通信之管道(PIPE)

匿名管道

我们在前面讲过,匿名管道有一点不足就是只能用于血缘关系的进程通信,在命名管道(named pipe或FIFO)提出后,该限制得到了克服。FIFO不同于管道之处在于它提供⼀个路径名与之关联,以FIFO的文件形式存储于⽂文件系统中。

命名管道是一个设备文件,因此,即使进程与创建FIFO的进程不存在亲缘关系,只要可以访问该路径,就能够通过FIFO相互通信。值得注意的是,FIFO(first input first output)总是按照先进先出的原则工作,第⼀个被写入的数据将首先从管道中读出。

命名管道也被称为FIFO文件,它是一种特殊类型的文件,它在文件系统中以文件名的形式存在,但是它的行为却和之前所讲的没有名字的管道(匿名管道)类似。

由于Linux中所有的事物都可被视为文件,所以对命名管道的使⽤用也就变得与文件操作非常的统一,也使它的使用非常方便,同时我们也可以像平常的文件名一样在命令中使用。

Linux下有两种方式创建命名管道。一是在Shell下交互地建⽴立⼀一个命名管道,二是在程序中使用系统函数建立命名管道。我们主要介绍第二种,关于系统编程方面在程序中使用。

进程通信之管道(PIPE)

通过mkfifo创建命名管道,path为创建的命名管道的全路径名:mod为创建的命名管道的模式,指明其存取权限;创建成功返回0,否则发挥-1

#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
#include<stdio.h>
#include<stdlib.h>

int main()
{
umask(0);
int ret = mkfifo("named_pipe", 0666 | S_IFIFO);
if (ret == -1)
{
printf("mkfifo error\n");
exit(-1);
}

return 0;
}

在上面代码中,我们创建了命名管道,而它的使用方法和匿名管道基本类似。只是使用命名管道时,必须先调用open()将其打开。因为命名管道是一个存在于硬盘上的文件,而管道是存在于内存中的特殊文件。

需要注意的是:调用open()打开命名管道的进程可能会被阻塞。

但如果同时用读写方式(O_ RDWR)打开,则一定不会导致阻塞;
如果以只读方式(O_ RDONLY)打开,则调用open()函数的进程将会被阻塞直到有写方打开管道;同样以写方式(O_WRONLY)打开也会阻塞直到有读方式打开管道。