管道是UNIX系统IPC的最古老的形式,并且所有UNIX系统都提供此种通信机制。管道有下面两种局限性:
(1)历史上,它们是半双工的(即数据只能在一个方向上流动)。现在,某些系统提供全双工管道,但是为了最佳的可移植性,我们决不应预先假定系统使用此特性。
(2)它们只能在具有公共祖先的进程之间使用。通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
(FIFO没有第二种局限性,UNIX域套接字和命名流管道则没有这两种局限性。)
尽管有这两种局限性,半双工管道仍是最常用的IPC形式。每当你在管道线中键入一个由shell执行的命令序列时,shell为每一条命令单独创建一进程,然后将前一条命令进程的标准输出用管道与后一条命令的标准输入相连接。
管道是由调用pipe函数而创建的:
#include <unistd.h>
int pipe(int filedes[2]);
返回值:若成功则返回0,若出错则返回-1
经由参数filedes返回的两个文件描述符:filedes[0]为读而打开,filedes[1]为写而打开。filedes[1]的输出是filedes[0]的输入。
POSIX.1允许实现支持全双工管道。对于这些实现,filedes[0]和filedes[1]以读/写方式打开。
有两种方式来描绘一个半双工管道,见图15-1。左半图显示了管道的两端在一个进程中相互连接,右半图则说明数据通过内核在管道中流动。
图15-1 观察半双工管道的两种方法
fstat函数(见javascript:void(0))对管道的每一端都返回一个FIFO类型的文件描述符,可以用S_ISFIFO()宏来测试管道。
POSIX.1规定stat结构的st_size成员对于管道是未定义的。但是当fstat函数应用于管道读端的文件描述符时,很多系统在st_size中存放管道中可用于读的字节数。但是,这是不可移植的。
单个进程中的管道几乎没有任何用处。通常,调用pipe的进程接着调用fork,这样就创建了从父进程到子进程(或反向)的IPC通道。图15-2显示了这种情况。
图15-2 调用fork之后的半双工通道
调用fork之后做什么取决于我们想要有的数据流的方向。对于从父进程到子进程的管道,父进程关闭管道的读端(fd[0]),子进程则关闭写端(fd[1])。图15-3显示了在此之后描述符的安排。
图15-3 从父进程到子进程的管道
为了构造从子进程到父进程的管道,父进程关闭fd[1],子进程关闭fd[0]。
当管道的一端被关闭后,下列两条规则其作用:
(1)当读一个写端已被关闭的管道时,在所有数据都被读取后,read返回0,以指示达到了文件结尾处。(从技术方面考虑,管道的写端还有进程时,就不会产生文件的结束。可以复制一个管道的描述符,使得有多个进程对它具有写打开文件描述符。但是,通常一个管道只有一个读进程、一个写进程。而对于一个单一的FIFO常常有多个写进程。)
(2)如果写一个读端已被关闭的管道,则产生信号SIGPIPE。如果忽略该信号或者捕捉该信号并从其处理程序返回,则write返回-1,errno设置为EPIPE。
在写管道(或FIFO)时,常量PIPE_BUF规定了内核中管道缓冲区的大小。如果对管道调用write,而且要求写的字节数小于等于PIPE_BUF,则此操作不会与其他进程对同一管道(或FIFO)的write操作穿插进行(当然,其他进程要求写的字节数也都小于等于PIPE_BUF)。但是,若有多个进程同时写一个管道(或FIFO),而且有进程(一个或几个)要求写的字节数超过PIPE_BUF字节数时,则写操作的数据可能相互穿插。用pathconf或fpathconf函数(见javascript:void(0)中表6)可以确定PIPE_BUF的值。
实例
程序清单15-1创建了一个从父进程到子进程的管道,并且父进程经由该管道向子进程传送数据。
程序清单15-1 经由管道父进程向子进程传送数据
#include "apue.h"
int
main(void)
{
int n;
int fd[2];
pid_t pid;
char line[MAXLINE];
if(pipe(fd) < 0)
err_sys("pipe error");
if((pid = fork()) < 0)
{
err_sys("fork error");
}
else if(pid > 0) /* parent */
{
close(fd[0]);
write(fd[1], "hello world\n", 12);
}
else /* child */
{
close(fd[1]);
n = read(fd[0], line, MAXLINE);
write(STDOUT_FILENO, line, n);
}
exit(0);
}
在上面的例子中,直接对管道描述符调用read和write。更好的方法是将管道描述符复制为标准输入和标准输出。在此之后通常子进程执行另一个程序,该程序或者从标准输入(已创建的管道)读数据,或者将数据写至其标准输出(该管道)。
实例
试编写一个程序,其功能是每次一页显示已产生的输出。已经有很多UNIX系统实用程序具有分页功能,因此无需再构造一个新的分页程序,而是调用用户最喜爱的分页程序。为了避免先将所有数据写到一个临时文件中,然后在调用系统中有关程序显示该文件,我们希望将输出通过管道直接送到分页程序。为此,先创建一个管道,调用fork产生一个子进程,使子进程的标准输入成为管道的读端,然后调用exec,执行用户喜爱的分页程序。程序清单15-2显示了如何实现这些操作。(本例要求在命令行中有一个参数说明要显示文件的名称。通常,这种类型的程序要求在终端上显示的数据已经在存储器中。)
程序清单15-2 将文件复制到分页程序
#include "apue.h"
#include <sys/wait.h>
#define DEF_PAGER "/bin/more" /* default pager program */
int
main(int argc, char *argv[])
{
int n;
int fd[2];
pid_t pid;
char *pager, *argv0;
char line[MAXLINE];
FILE *fp;
if(argc != 2)
err_quit("usage: a.out <pathname>");
if((fp = fopen(argv[1], "r")) == NULL)
err_sys("can't open %s", argv[1]);
if(pipe(fd) < 0)
err_sys("pipe error");
if((pid = fork()) < 0)
{
err_sys("fork error");
}
else if(pid > 0) /* parent */
{
close(fd[0]); /* close read end */
/* parent copies argv[1] to pipe */
while(fgets(line, MAXLINE, fp) != NULL)
{
n = strlen(line);
if(write(fd[1], line, n) != n)
err_sys("write error to pipe");
}
if(ferror(fp))
err_sys("fgets error");
close(fd[1]); /* close write end of pipe for reader */
if(waitpid(pid, NULL, 0) < 0)
err_sys("waitpid error");
exit(0);
}
else /* child */
{
close(fd[1]); /* close write end */
if(fd[0] != STDIN_FILENO)
{
if(dup2(fd[0], STDIN_FILENO) != STDIN_FILENO)
err_sys("dup2 error to stdin");
close(fd[0]); /* don't need this after dup2 */
}
/* get arguments for execl() */
if((pager = getenv("PAGER")) == NULL)
pager = DEF_PAGER;
if((argv0 = strrchr(pager, '/')) != NULL)
argv0++; /* step past rightmost slash */
else
argv0 = pager; /* no slash in pager */
if(execl(pager, argv0, (char *)0) < 0)
err_sys("execl error for %s", pager);
}
exit(0);
}
在调用fork之前先创建一个管道。fork之后父进程关闭其读端,子进程关闭其写端。子进程然后调用dup2,使其标准输入成为管道的读端。当执行分页程序时,其标准输入将是管道的读端。
当我们将一个描述符复制到另一个时(在进程中,fd[0]复制到标准输入),应当注意在复制之前该描述符的值并不是所希望的值。如果该描述符已经具有所希望的值,并且我们先调用dup2,然后调用close则将关闭此进程中只有该单个描述符所代表的打开文件。(回忆javascript:void(0)中所述,当dup2中的两个参数值相等时的操作。)
请注意,我们是如何使用环境变量PAGER试图获得用户分页程序名称的。如果这种操作没有成功,则使用系统默认值。这是环境变量的常见用法。
实例
回忆javascript:void(0)中的5个函数:TELL_WAIT、TELL_PARENT、TELL_CHILD、WAIT_PARENT以及WAIT_CHILD。javascript:void(0)中的程序清单10-17提供了一个使用信号的实现。程序清单15-3则是一个使用管道的实现。
程序清单15-3 使父、子进程同步的例程
#include "apue.h"
static int pfd1[2], pfd2[2];
void
TELL_WAIT(void)
{
if(pipe(pfd1) < 0 || pipe(pfd2) < 0)
err_sys("pipe error");
}
void
TELL_PARENT(pid_t pid)
{
if(write(pfd2[1], "c", 1) != 1)
err_sys("write error");
}
void
WAIT_PARENT(void)
{
char c;
if(read(pfd1[0], &c, 1) != 1)
err_sys("read error");
if(c != 'p')
err_quit("WAIT_PARENT: incorrect data");
}
void
TELL_CHILD(pid_t pid)
{
if(write(pfd1[1], "p", 1) != 1)
err_sys("write error");
}
void
WAIT_CHILD(void)
{
char c;
if(read(pfd2[0], &c, 1) != 1)
err_sys("read error");
if(c != "c")
err_quit("WAIT_CHILD: incorrect data");
}
如图15-4所示,在fork之前创建了两个管道。父进程在调用TELL_CHILD时,写一个字符“p”至上一个管道,子进程在调用TELL_PARENT时,经由下一个管道写一个字符“c”。相应的WAIT_xxx函数调用read读这个字符,没有读到字符时阻塞(睡眠等待)。
图15-4 用两个管道实现父子进程同步
请注意,每一个管道都有一个额外的读取进程,这没有关系。也就是说,除了子进程从pfd1[0]读取,父进程也有上一个管道的读端。因为父进程并没有执行对该管道的读操作,所以这不会产生任何影响。