本文由云+社区发布作者:邹立伟版权声明:本文内容在非商业用途的前提下,未经授权不得转载发布。转载或发表请务必注明作者及作者微博、微信公众号地址,以便读者提问、排查错误,共同进步。微博ID:orroz微信公众号:Linux系统技术前言管道是UNIX环境下最古老的进程间通信方式。本文主要讲解如何在Linux环境下使用管道。阅读本文可以帮助您回答以下问题:什么是管道,为什么会有管道?管道是如何分类的?管道实现是什么样的?管道有多大?管道尺寸可以调整吗?如何调整?什么是管道?管道,英文是pipe。这是一个非常重要的概念,我们在学习Linux命令行的时候会引入。它的发明者是DouglasMcIlroy,他也是UNIX上早期shell的发明者。他发明shell后发现,系统运行执行命令时,往往需要将一个程序的输出交给另一个程序处理。这种操作可以通过使用输入输出重定向和添加文件来完成,例如:[zorro@zorro-pcpipe]$ls-l/etc/>etc.txt[zorro@zorro-pcpipe]$wc-letc.txt183etc.txt不过这样好像太麻烦了。因此,流水线的概念应运而生。目前,在任何shell中,你都可以使用“|”连接两个命令,shell会将两个进程的输入输出用管道连接起来,达到进程间通信的目的:[zorro@zorro-pcpipe]$ls-l/etc/|wc-l183对比上面两种方式,我们也可以了解到,管道本质上是一个文件,前一个进程以写方式打开文件,后一个进程以读方式打开文件。这样,前面写,后面读,就实现了通信。其实流水线的设计也遵循了UNIX“一切皆文件”的设计原则,本质上就是一个文件。Linux系统直接将流水线实现为文件系统,借助VFS为应用程序提供运行接口。尽管作为文件实现,管道本身并不占用磁盘或其他外部存储空间。在Linux实现上,它占用内存空间。因此,Linux上的管道是一个像文件一样操作的内存缓冲区。管道的分类和Linux上管道的使用分为两种:匿名管道命名管道这两种管道也称为命名管道或未命名管道。最常见的匿名管道形式是“|”我们最常用于shell操作。它的特点是只能在父子进程中使用。父进程在生成子进程之前必须打开一个pipe文件,然后fork生成子进程。这样,子进程通过复制父进程的进程地址空间来获取同一个管道文件的描述符,以达到使用同一个通道进行通信的目的。此时除了父子进程外,没有人知道管道文件的描述符,所以不能将这个管道中的信息传递给其他进程。这样保证了传输数据的安全性,当然也降低了管道的通用性,所以系统也提供了命名管道。我们可以使用mkfifo或mknod命令创建命名管道,这与创建文件没有区别:[zorro@zorro-pcpipe]$mkfifopipe[zorro@zorro-pcpipe]$ls-lpipeprw-r--r--1zorrozorro0Jul1410:44pipe可以看到创建的文件类型比较特殊,是p类型。表示这是一个管道文件。有了这个管道文件,一个管道在系统中就有了一个全局名称,任何两个不相关的进程都可以通过这个管道文件进行通信。比如我们现在让一个进程写这个管道文件:[zorro@zorro-pcpipe]$echoxxxxxxxxxxxxxx>pipe此时写操作会阻塞,因为管道的另一端没有人在读。这是内核为管道文件定义的默认行为。这时候如果有进程在读这个管道,那么这个写操作的阻塞就会被解除:另一头也回来了。这就是命名管道。不管Linux系统使用命名管道还是匿名管道,底层都使用相同的文件系统操作行为。这个文件系统叫做pipefs。您可以在/etc/proc/filesystems文件中查看您的系统是否支持此文件系统:[zorro@zorro-pcpipe]$cat/proc/filesystems|greppipefsnodevpipefs在观察如何在命令行中使用管道后那,让我们看看如何在系统编程中使用管道。PIPE我们可以分别称匿名管道和命名管道为PIPE和FIFO。这主要是因为在系统编程中,创建匿名管道的系统调用是pipe(),而创建命名管道的函数是mkfifo()。也可以使用mknod()系统调用并将文件类型指定为S_IFIFO来创建FIFO。使用pipe()系统调用创建匿名管道。这个系统调用的原型是:#includeintpipe(intpipefd[2]);该方法会创建两个文件描述符,可以使用pipefd数组来引用这两个描述符进行文件操作。pipefd[0]作为管道的读描述符以读模式打开。pipefd[1]为写入打开,作为管道的写入描述符。从管道的写入端写入的数据由内核缓存,直到有人从另一端读取。让我们看看如何在进程中使用管道,虽然这个例子没有意义:[zorro@zorro-pcpipe]$catpipe.c#include#include#include#include#defineSTRING"helloworld!"intmain(){intpipefd[2];charbuf[BUFSIZ];if(pipe(pipefd)==-1){perror("pipe()");退出(1);}if(write(pipefd[1],STRING,strlen(STRING))<0){perror("write()");退出(1);}if(read(pipefd[0],buf,BUFSIZ)<0){perror("write()");退出(1);}printf("%s\n",缓冲区);exit(0);}这个程序创建一个管道,将一个字符串写入管道,从管道中读取它,并在标准输出上打印它。用图来说明这个程序的状态是这样的:一个进程给自己发送消息,当然不叫进程间通信,所以在实际情况下我们不会在单个进程中使用管道。进程通过pipe创建管道后,往往需要fork生成子进程,如下图所示:如图所示,fork生成的子进程会继承父进程对应的文件描述符.利用这个特性,父进程先用pipe创建管道后,子进程也会得到同一个管道的读写文件描述符。这样父子两个进程就可以用一条管道完成半双工通信。这时父进程可以通过fd[1]向子进程发送消息,子进程可以通过fd[0]读取。子进程也可以通过fd[1]向父进程发送消息,父进程使用fd[0]读取。程序实例如下:[zorro@zorro-pcpipe]$catpipe_parent_child.c#include#include#include#include#include#include#defineSTRING"helloworld!"intmain(){intpipefd[2];pid_tpid;charbuf[BUFSIZ];if(pipe(pipefd)==-1){perror("pipe()");退出(1);}pid=fork();如果(pid==-1){perror("fork()");退出(1);}if(pid==0){/*这是孩子。*/printf("子pid是:%d\n",getpid());if(read(pipefd[0],buf,BUFSIZ)<0){perror("write()");退出(1);}printf("%s\n",缓冲区);bzero(buf,BUFSIZ);snprintf(buf,BUFSIZ,"来自孩子的消息:我的pid是:%d",getpid());if(write(pipefd[1],buf,strlen(buf))<0){perror("write()");退出(1);}}else{/*这是父级*/printf("父pid是:%d\n",getpid());snprintf(buf,BUFSIZ,"来自父母的消息:我的pid是:%d",getpid());if(write(pipefd[1],buf,strlen(buf))<0){perror("write()");退出(1);}睡眠(1);bzero(buf,BUFSIZ);if(read(pipefd[0],buf,BUFSIZ)<0){perror("write()");退出(1);}printf("%s\n",缓冲区);等待(空);}exit(0);}父进程先给子进程发送消息,子进程收到消息后打印消息,然后再发送消息给父进程,父进程打印从子进程收到的消息子进程程序执行效果:[zorro@zorro-pcpipe]$./pipe_parent_childParentpidis:8309Childpidis:8310Messagefromparent:Mypidis:8309Messagefromchild:Mypidis:8310从这个程序我们可以看到管道实际上可以实现半双工通信机制。父子进程使用同一个管道,可以分时互相发送消息。我们还可以看到对管道进行读写的一些特性,即如果管道中没有数据,那么对管道的读操作就会被阻塞,直到管道中有数据为止。当一次写入的数据量不超过管道的容量时,对管道的写操作一般不会阻塞,可以直接将要写入的数据写入管道缓冲区。当然,写操作不会在所有情况下都解锁。这里我们要先了解一下管道的内核实现。上面说了,管道其实就是一个由内核控制的内存缓冲区。既然是缓冲区,那么容量是有上限的。我们称管道一次可以缓存的最大数据量为PIPESIZE。内核在处理管道数据时,底层也会调用read、write等方法来拷贝数据。这种内核操作所能操作的数据量也是有限的。一般操作长度为一页,即默认为4k字节。我们把每次可以操作的数据长度称为PIPEBUF。在POSIX标准中,PIPEBUF有长度限制,要求最小长度不小于512字节。PIPEBUF的作用是内核在处理管道时,如果每次读写操作的数据长度不大于PIPEBUF,则保证该操作是原子的。PIPESIZE的作用是,大于其长度的写操作将被阻塞,直到读取当前管道中的数据。在Linux2.6.11之前,PIPESIZE和PIPEBUF实际上是一样的。之后Linux重新实现了一个pipelinecache,将其实现为与写操作的PIPEBUF不同的概念,形成了一个默认长度为65536字节的PIPESIZE,而PIPEBUF只影响相关读写操作的原子性。从Linux2.6.35开始,在fcntl系统调用方法中实现了F_GETPIPE_SZ和F_SETPIPE_SZ操作,分别查看当前管道容量和设置管道容量。管道容量的上限可以在/proc/sys/fs/pipe-max-size中设置。#defineBUFSIZE65536......ret=fcntl(pipefd[1],F_GETPIPE_SZ);if(ret<0){perror("fcntl()");exit(1);}printf("PIPESIZE:%d\n",ret);ret=fcntl(pipefd[1],F_SETPIPE_SZ,BUFSIZE);if(ret<0){perror("fcntl()");exit(1);}...PIPEBUF和PIPESIZE对管道操作的影响会根据管道描述符是否设置为非阻塞模式而改变。当n为要写入的数据量时,具体为:O_NONBLOCK关闭,n<=PIPE_BUF:nbytes写操作是原子操作,write系统调用可能会因为管道容量(PIPESIZE)做阻塞没有足够的空间来存储n个字节的长度。O_NONBLOCK开启,n<=PIPE_BUF:如果有足够的空间存储n个字节长度,write调用会立即返回成功,数据会被写入。如果空间不足,则立即返回错误,并将errno设置为EAGAIN。O_NONBLOCKoff,n>PIPE_BUF:n个字节的写操作不保证是原子的,也就是说这个写操作的数据可能会和其他进程写入这个管道的数据交织在一起。当管道容量的长度小于要写入的数据长度时,写入操作将被阻塞。O_NONBLOCKon,n>PIPE_BUF:如果管道已满。write调用返回错误并且errno设置为EAGAIN。如果未满,则可能会写入1到n个字节的长度,具体取决于当前管道的剩余空间长度,这些数据可能会与其他进程的数据相交。以上是使用半双工管道时需要注意的事情,因为在这种情况下,管道的两端可能会有多个进程进行读写。如果添加线程,事情会变得更复杂。其实我们在使用管道的时候,是不推荐这样做的。管道的推荐使用方式是它的单工模式:即只有两个进程通信,一个进程只写管道,另一个进程只读管道。实际为:[zorro@zorro-pcpipe]$catpipe_parent_child2.c#include#include#include#include#include#include#defineSTRING"helloworld!"intmain(){intpipefd[2];pid_tpid;charbuf[BUFSIZ];if(pipe(pipefd)==-1){perror("pipe()");退出(1);}pid=fork();如果(pid==-1){perror("fork()");退出(1);}if(pid==0){/*这是孩子。*/关闭(pipefd[1]);printf("子pid是:%d\n",getpid());if(read(pipefd[0],buf,BUFSIZ)<0){perror("write()");退出(1);}printf("%s\n",缓冲区);}else{/*这是父级*/close(pipefd[0]);printf("父pid是:%d\n",getpid());snprintf(buf,BUFSIZ,"来自父母的消息:我的pid是:%d",getpid());如果(写入(pipefd[1],buf,strlen(buf))<0){错误(“写()”);退出(1);}等待(NULL);}exit(0);}这个程序其实比上一个要简单,父进程关闭管道的读端,子进程只写管道关闭管道的写端,只读管道。整个管道的开启效果最终如下图所示:此时两个进程仅仅通过管道实现了一次单纯形通信,而在这种状态下,不需要考虑造成的数据交叉问题多个进程同时写入管道。是最经典的管道开启方式,也是我们推荐的管道使用方式。另外,作为程序员,即使了解了Linux管道的实现,我们的代码也不能依赖它的特性,所以在处理管道的时候,还是需要判断是否要进行越界判断,以及错误检查仍应检查,以便代码更健壮。FIFO命名管道的底层实现和匿名管道是完全一样的,不同的是命名管道会有一个全局可见的文件名供其他人打开使用。在程序中创建命名管道文件有两种方法,一种是使用mkfifo函数。另一种是使用mknod系统调用,例子如下:[zorro@zorro-pcpipe]$catmymkfifo.c#include#include#include#includeintmain(intargc,char*argv[]){if(argc!=2){fprintf(stderr,"参数错误!\n");退出(1);}/*if(mkfifo(argv[1],0600)<0){perror("mkfifo()");退出(1);}*/if(mknod(argv[1],0600|S_IFIFO,0)<0){perror("mknod()");退出(1);}exit(0);}我们使用第一个参数作为创建的文件路径。创建后,其他进程可以使用open()、read()、write()等方法进行标准文件操作。所有其他操作类似于使用匿名管道。需要注意的是,无论是命名管道还是匿名管道,其文件描述都没有偏移量的概念,因此不能使用lseek进行偏移量调整。关于管道的其他话题,比如popen和pclose的使用,《UNIX环境高级编程》的相关章节已经解释清楚了。如果你想学习补充这方面的知识,请参考本书。本文已由腾讯云+社区在各渠道发布获取更多新鲜技术干货,可以关注我们的腾讯云技术社区-云家社区公众号和知乎代理号