1.3 管道
管道是作为一对文件描述符公开给进程的小型内核缓冲区,一个用于读取,一个用于写入。将数据写入管道的一端使得这些数据可以从管道的另一端读取。管道为进程提供了一种通信方式。
下面的示例代码使用连接到管道读端的标准输入来运行程序wc
。
int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
if (fork() == 0) {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
exec("/bin/wc", argv);
} else {
close(p[0]);
write(p[1], "hello world\n", 12);
close(p[1]);
}
程序调用pipe
,创建一个新的管道,并在数组p中记录读写文件描述符。在fork
之后,父子进程都有指向管道的文件描述符。子进程调用close
和dup
使文件描述符0指向管道的读取端(前面说过优先分配最小的未使用的描述符),然后关闭p中所存的文件描述符,并调用exec
运行wc
。当wc
从它的标准输入读取时,就是从管道读取。父进程关闭管道的读取端,写入管道,然后关闭写入端。
如果没有可用的数据,则管道上的read
操作将会进入等待,直到有新数据写入或所有指向写入端的文件描述符都被关闭,在后一种情况下,read
将返回0,就像到达数据文件的末尾一样。事实上,read
在新数据不可能到达前会一直阻塞,这是子进程在执行上面的wc
之前关闭管道的写入端非常重要的一个原因:如果wc的文件描述符之一指向管道的写入端,wc将永远看不到文件的结束。
Xv6 shell以类似于上面代码(user/sh.c:100)的方式实现了诸如grep fork sh.c | wc -l
之类的管道。子进程创建一个管道将管道的左端和右端连接起来。然后对管道的左端调用fork
和runcmd
,对管道的右端调用fork
和runcmd
,并等待两者都完成。管道的右端可能是一个命令,该命令本身包含一个管道(例如,a | b | c
),该管道本身fork
为两个新的子进程(一个用于b,一个用于c)。因此,shell可以创建一个进程树。这个树的叶子是命令,内部节点是等待左右两个子进程完成的进程。
原则上,可以让内部节点在管道的左端运行,但是正确地这样做会使实现复杂化。考虑进行以下修改:将sh.c更改为不对p->left
进行fork
,并在内部进程中运行runcmd(p->left)
。然后,例如,echo hi | wc
将不会产生输出,因为当echo hi
在runcmd
中退出时,内部进程将退出,而不会调用fork
来运行管道的右端。这个不正确的行为可以通过不调用内部进程的runcmd
中的exit
来修复,但是这个修复使代码复杂化:现在runcmd
需要知道它是否是一个内部进程。同样的,当没有对(p->right)
执行fork
时也会更加复杂。例如,只需进行上述的修改,sleep 10 | echo hi
将立即打印“hi”,而不是在10秒后,因为echo
将立即运行并退出,而不是等待sleep
完成。因为sh.c的目标是尽可能的简单,所以它不会试图避免创建内部进程。
管道看起来并不比临时文件更强大:下面的管道命令行
echo hello world | wc
可以不通过管道实现,如下
echo hello world > /tmp/xyz; wc < /tmp/xyz
在这种情况下,管道相比临时文件至少有四个优势
首先,管道会自动清理自己;在文件重定向时,shell使用完
/tmp/xyz
后必须小心删除其次,管道可以任意传递长的数据流,而文件重定向需要磁盘上足够的空闲空间来存储所有的数据。
第三,管道允许并行执行管道阶段,而文件方法要求第一个程序在第二个程序启动之前完成。
第四,如果实现进程间通讯,管道的阻塞式读写比文件的非阻塞语义更高效。