前言
管道是最早出现的进程间通信的手段,比如我们在shell执行命令时经常会将上一个命令的输入作为下一个命令的输入就是通过管道来实现的,如下图,进程who的标准输出,通过管道传递给下游的wc进程作为标准输入,从而通过相互配合完成了一个任务,读取管道内容是消耗型的行为,即一个进程读取管道内的一些内容后,这些内容就不会继续在管道之中了
管道的作用是在亲缘关系的进程之间传递信息,所谓有亲缘关系,是指有一个共同的祖先,所以管道并非只能用于父子进程之间,也可以用在兄弟进程之间,还可以用于祖孙之间甚至叔侄进程之间,总而言之,只要共同的祖先曾调用了pipe函数,打开的管道文件就会在fork之后,被各个后代进程共享,如下图所示,在父进程中创建一个管道的示意图,此时管道的写入端fd[1]和读取端fd[0]只在父进程可见
如下图所示,该模型为创建一个子进程后的模型,此时子进程从父进程中继承来的fd[0]和fd[1]也指向该管道,由于管道只能一端写入,另一端读出,所以上面的这种模式会造成混乱,因为父进程和子进程都可以写入,也都可以读出,通常的方法是父进程关闭读取的fd,只保留写入的 fd.而子进程关闭写入的fd,只保留读取的 fd,如果需要双向通行,则应该创建两个管道.
如下图所示为正确的父写子读管道通信模型,在父进程中往管道写入数据,所以要关闭读端,在子进程中想从管道中读取数据,所以要关闭写端,管道的实质是字节流,其本质是内核维护了一块缓冲区与管道文件相关联,对管道文件的操作,被内核转换成对这块缓冲区内存的操作,既然管道本质是一片内存区域,自然就有大小,其上限记录在/proc/sys/fs/pipe-max-size,在在使用管道的过程中要意识到:管道有大小,写入须谨慎,不能连续地写入大量的内容,一旦管道满了,写入就会被阻塞,对于读取端,要及时地读取,防止管道被写满,造成写入阻塞
深入分析
前面提到过,用管道通信的两个进程,各持有一个管道文件描述符,不相干的进程应自觉关闭掉,这些文件描述符.这么做不仅仅是为了让数据的流向更加清晰,也不仅仅是为了节省文件描述符,更重要的原因是:关闭未使用的管道文件描述符对管道的正确使用影响重大.
管道有如下三条性质:
1.只有当所有的写入端描述符都已关闭,且管道中的数据都被读出,对读取端描述符调用read函数才会返回0(即读到EOF标志).
2.如果所有读取端描述符都已关闭,此时进程再次往管道里面写入数据,写操作会失败,errno被设置为EPIPE,同时内核会向写入进程发送一个SIGPIPE的信号。
3.当所有的读取端和写入端都关闭后,管道才能被销毁。
对第一条性质进行实验
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
int main()
{
int pipefd[2];
pid_t pid;
char r_buf[4096];
char w_buf[4096];
int readnum;
int writenum;
memset(r_buf,'0',4096);
if(pipe(pipefd)<0)
{
printf("pipe create error\n");
return -1;
}
if((pid=fork())==0)
{
close(pipefd[1]);
while(1)
{
readnum = read(pipefd[0],r_buf,1000);
printf("child readnum is %d\n",readnum);
if(readnum == 0)
{
printf("read all and all the writer od pipe are closed\n");
break;
}
}
close(pipefd[0]);
exit(0);
}
else if(pid>0)
{
close(pipefd[0]);
writenum = write(pipefd[1],r_buf,1024);
printf("parent writenum is %d\n",writenum);
sleep(15);
printf("now close the writer pipe\n");
close(pipefd[1]);
sleep(2);
return 0;
}
}
结果:
从子进程读完父进程生产的1024字节开始,到父进程关闭管道写入端这段接近15 秒的时间内,子进程实际上是阻塞在read函数上的.当父进程关闭管道写入端,子进程调用的read函数才得以返回,返回值是0.子进程看到返回值0后,意识到硕果仅存的管道写入端也不复存在了,所以 它没必要再继续read了于是子进程就跳出了循环体.
对第二条性质进行实验
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <signal.h>
void sighandler(int signo)
{
printf("catch a SIGPIPE signal and signum = %d\n",signo);
}
int main()
{
int fd[2];
pid_t pid;
signal(SIGPIPE,sighandler);
if(pipe(fd)<0)
{
printf("pipe create error\n");
return -1;
}
if((pid=fork())==0)
{
close(fd[0]);
printf("i close the last read end of pipe\n");
}
else if(pid>0)
{
close(fd[0]);
sleep(1);
int ret = write(fd[1],"hello",5);
if(ret == -1)
{
fprintf(stderr,"write error(%s)\n",strerror(errno));
}
return 0;
}
}
结果:
fork之后,父子进程都立刻关闭了读取端,这时候,管道已经不存在任何读取端了.1秒钟之后,父进程尝试向管道写入.此时按照前面的分析,父进程应该会收到SIGPIPE信号,write返回失败,并且 errno为EPIPE.父进程为SIGPIPE安装了信号处理函数,如果收到SIGPIPE信号,会有打印提示.
Shell管道的实现
Shell编程会大量使用管道,我们经常看到前一个命令的标准输出作为后一个命令的标准输入,来协作完成命令,兄弟进程可以通过管道来传递消息这并不稀奇,关键是如何使得一个程序的标准输出被重定向到管道中,而另一个程序的输入从管道中读取呢?答案就是复制文件描述符-dup2
我们首先从shell 创建子进程 A,然后在shell和 A之间建立一个管道.其中shell保留读取端,A 进程保留写入端
然后shell再创建子进程B.这又是一次 fork,所以,shell里面保留的读取端的fd也被复制到了子进程 B 里面.这个时候,相当于 shell 和 B 都保留读 取端
shell 主动关闭读取端,就变成了一管道,写入端在 A 进程,读取端在B进程.
接下来我们要做的事情就是,将这个管道的两端和输入输出关联起来.**这就要用到 dup2 系统调用了,**在 A 进程中,写入端可以做这样的操作:dup2(fd[1],STDOUT_FILENO),将 STDOUT_FILENO(也即第一项)不再指向标准输出,而是指向创建的管道文件,那么以后往标准输出写入的任何东西,都会写入管道文件.在 B 进程中,读取端可以做这样的操作,dup2(fd[0],STDIN_FILENO),将 STDIN_FILENO 也即第零项不再指向标准输入,而是指向创建的管道文件,那么以后从标 准输入读取的任何东西,都来自于管道文件,如下图所示
测试代码
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main(void)
{
pid_t pid;
int fds[2];
if(pipe(fds) == -1)
{
printf("fork error");
}
if((pid=fork())==0)
{
dup2(fds[1],STDOUT_FILENO);
close(fds[1]);
close(fds[0]);
execlp("ps","ps","-ef",NULL);
}
else
{
dup2(fds[0],STDIN_FILENO);
close(fds[0]);
close(fds[1]);
execlp("grep", "grep", "systemd", NULL);
}
return 0;
}
结果: