匿名管道
读者在Linux的命令行中进行操作时曾接触过类似“ls | wc -l”之类的组合命令,这种命令由管道符号“|”连接的两个或多个命令组成,其中前一个命令的输出会作为后一个命令的输入,藉由管道从前面命令的输出端流向后一个命令的输入端,这里使用的管道就是匿名管道。
在程序中使用匿名管道时,需要先创建一个管道,Linux系统中创建匿名管道的函数为pipe(),该函数存在于函数库unistd.h中,其函数声明如下:
int pipe(int pipefd[2]);
pipe()的参数是一个传入参数,由其参数名pipefd可推测,其实质是一个文件描述符。Linux系统中的管道被抽象为一种特殊文件,即管道文件。虽然管道的实质是内核缓存区,但它借助文件系统中的file结构与VFS中的索引结点来实现。pipe()函数的pipefd是一个数组,当在程序中使用pipe()创建管道时,程序可以通过传参的方式,获取两个文件描述符,分别交给需要通信的两个进程,内核再将这两个进程中文件描述符对应file结构中的inode指向同一个临时的VFS索引结点,并使这个索引结点指向同一个物理页面。管道实现机制如图2所示。
图2 管道实现机制
虽然两个进程file结构中的f_inode指向系统中的同一个inode,但文件操作地址f_op不同,一个进程执行写操作,另一个进程执行读操作。受管道实现机制的限制,匿名管道只能在有亲缘关系的进程间使用。匿名管道利用fork机制建立,刚创建出的管道,读写两端都连接在同一个进程上,当进程中调用fork()创建子进程后,父子进程共享文件描述符,因此子进程拥有与父进程相同的管道。pipe()创建管道后读端对应的文件描述符为fd[0],写端对应的文件描述符为fd[1],fork后父子进程中文件描述符与管道的关系如图3所示。
图3 父进程fork出子进程
管道作为一种文件,除创建方式外,其它操作与普通文件相同。管道通过pipe()创建时自动打开,但关闭应由用户实现。管道两端的进程只能进行读写操作中的一种,因此应各自关闭父子进程中的一个文件描述符。假设在父进程中进行写操作,在子进程中进行读操作,那么应使用close()函数关闭父进程中的读端与子进程中的写端。此后父子进程中文件描述符与管道的关系如图4所示。
图4 父子进程文件描述符与管道间的关系
下面通过一个案例来展示pipe()函数的使用方法。
案例1:使用pipe()实现父子进程间通信,要求父进程作为写端,子进程作为读端。
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <string.h>
4 #include <unistd.h>
5 #include <sys/types.h>
6 #include <sys/wait.h>
7 int main()
8 {
9 int fd[2]; //定义文件描述符数组
10 int ret=pipe(fd); //创建管道
11 if(ret==-1)
12 {
13 perror("pipe");
14 exit(1);
15 }
16 pid_t pid=fork();
17 if(pid>0)
18 {
19 //父进程—写
20 close(fd[0]); //关闭读端
21 char *p="hello,pipe\n";
22 write(fd[1],p,strlen(p)+1); //写数据
23 close(fd[1]);
24 wait(NULL);
25 }
26 else if(pid==0)
27 {
28 //子进程—读
29 close(fd[1]); //关闭写端
30 char buf[64]={0};
31 ret=read(fd[0],buf,sizeof(buf)); //读数据
32 close(fd[0]);
33 write(STDOUT_FILENO,buf,ret); //将读到的数据写到标准输出
34 }
35 return 0;
36 }
编译案例1,执行程序,执行结果如下:
hello,pipe
结合案例代码,由执行结果可知,父进程在管道中写入字符串“hello pipe”,子进程从管道将该字符串读出,并打印到了终端,案例实现成功。
有亲缘关系的进程,除父子外,还有兄弟进程等具备其它联系的进程,这些进程都依靠fork()创建,因此每个进程初始时都会有两个指向管道文件的文件描述符,实现这些进程间通信的实质,是关闭多个进程中多余的文件描述符,只为待通信进程各自保留读端或写端。假设要实现兄弟进程间的通信,那么系统中进程文件描述符与管道的关系如图5所示,其中实线所示的箭头为编程中需要保留的文件描述符。
图5 兄弟进程间通信的管道实现
案例2:使用管道实现兄弟进程间通信,使兄弟进程实现命令“ls | wc –l”的功能。在实现本案例时会用到重定向函数dup2(),该函数存在于函数库unistd.h中,函数声明如下:
int dup2(int oldfd, int newfd);
其功能是将参数oldfd的文件描述符复制给newfd,若函数调用成功则返回newfd,否则返回-1,并设置errno。
案例实现如下:
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 int main()
5 {
6 int fd[2];
7 int ret=pipe(fd);
8 if(ret==-1){
9 perror("pipe err");
10 exit(1);
11 }
12 int i;
13 pid_t pid,wpid;
14 for(i=0;i<2;i++){ //创建两个子进程
15 if((pid=fork())==0)
16 break;
17 }
18 if(2==i){ //父进程
19 close(fd[0]);
20 close(fd[1]);
21 wpid=wait(NULL);
22 printf("wait child 1 success,pid=%d\n",wpid);
23 pid=wait(NULL);
24 printf("wait child 2 success,pid=%d\n",pid);
25 }
26 else if(i==0){ //子进程—写
27 close(fd[0]);
28 dup2(fd[1],STDOUT_FILENO); //将fd[1]所指文件内容定向到标准输出
29 execlp("ls","ls",NULL);
30 }
31 else if(i==1){ //子进程—读
32 close(fd[1]);
33 dup2(fd[0],STDIN_FILENO);
34 execlp("wc","wc","-l",NULL);
35 }
36 return 0;
37 }
需要注意,匿名管道不可共用,因此父进程中管道的文件描述符必须要关闭,否则父进程中的读端会使进程阻塞。编译案例2,执行程序,执行结果如下:
wait child 1 success,pid=2810
101
wait child 2 success,pid=2811
其中101为兄弟进程对“ls | wc -l”命令的实现结果,在终端输入该命令,得到的结果与程序相同,可知案例成功实现。
管道是最简单的进程通信方式,但受自身数据传输机制的限制,使用管道时有以下几种情况需要注意:
(1)管道采用单工通信方式,只能进行单向数据传递,虽然多余的读写端口不一定会对程序造成影响,但为严谨起见,还是应使用close()函数关闭除通信端口之外的端口;
(2)管道只能进行单工通信,若要实现双向通信,需要为通信的进程创建两个管道;
(3)只有指向管道读端的文件描述符打开时,向管道中写入数据才有意义,否则写端的进程会收到内核传来的信号SIGPIPE,默认情况下该信号会导致进程终止;
(4)若所有指向管道写端的文件描述符都被关闭后仍有进程从管道的读端读取数据,那么管道中剩余的数据都被读取后,再次read会返回0;
(5)若有指向管道写端的文件描述符未关闭,而管道写端的进程也没有向管道中写入数据,那么当进程从管道中读取数据,且管道中剩余的数据都被读取时,再次read会阻塞,直到写端向管道写入数据,阻塞才会解除;
(6)若有指向管道读端的文件描述符没关闭,但读端进程没有从管道中读取数据,写端进程持续向管道中写入数据,那么管道缓存区写满时再次write会阻塞,直到读端将数据读出,阻塞才会解除。
(7)管道中的数据以字节流的形式传输,这要求管道两端的进程事先约定好数据的格式。