1.3 管道

管道是作为一对文件描述符公开给进程的小型内核缓冲区,一个用于读取,一个用于写入。将数据写入管道的一端使得这些数据可以从管道的另一端读取。管道为进程提供了一种通信方式。

下面的示例代码使用连接到管道读端的标准输入来运行程序wc

  1. int p[2];
  2. char *argv[2];
  3. argv[0] = "wc";
  4. argv[1] = 0;
  5. pipe(p);
  6. if (fork() == 0) {
  7. close(0);
  8. dup(p[0]);
  9. close(p[0]);
  10. close(p[1]);
  11. exec("/bin/wc", argv);
  12. } else {
  13. close(p[0]);
  14. write(p[1], "hello world\n", 12);
  15. close(p[1]);
  16. }

程序调用pipe,创建一个新的管道,并在数组p中记录读写文件描述符。在fork之后,父子进程都有指向管道的文件描述符。子进程调用closedup使文件描述符0指向管道的读取端(前面说过优先分配最小的未使用的描述符),然后关闭p中所存的文件描述符,并调用exec运行wc。当wc从它的标准输入读取时,就是从管道读取。父进程关闭管道的读取端,写入管道,然后关闭写入端。

如果没有可用的数据,则管道上的read操作将会进入等待,直到有新数据写入或所有指向写入端的文件描述符都被关闭,在后一种情况下,read将返回0,就像到达数据文件的末尾一样。事实上,read在新数据不可能到达前会一直阻塞,这是子进程在执行上面的wc之前关闭管道的写入端非常重要的一个原因:如果wc的文件描述符之一指向管道的写入端,wc将永远看不到文件的结束。

Xv6 shell以类似于上面代码(user/sh.c:100)的方式实现了诸如grep fork sh.c | wc -l之类的管道。子进程创建一个管道将管道的左端和右端连接起来。然后对管道的左端调用forkruncmd,对管道的右端调用forkruncmd,并等待两者都完成。管道的右端可能是一个命令,该命令本身包含一个管道(例如,a | b | c),该管道本身fork为两个新的子进程(一个用于b,一个用于c)。因此,shell可以创建一个进程树。这个树的叶子是命令,内部节点是等待左右两个子进程完成的进程。

原则上,可以让内部节点在管道的左端运行,但是正确地这样做会使实现复杂化。考虑进行以下修改:将sh.c更改为不对p->left进行fork,并在内部进程中运行runcmd(p->left)。然后,例如,echo hi | wc将不会产生输出,因为当echo hiruncmd中退出时,内部进程将退出,而不会调用fork来运行管道的右端。这个不正确的行为可以通过不调用内部进程的runcmd中的exit来修复,但是这个修复使代码复杂化:现在runcmd需要知道它是否是一个内部进程。同样的,当没有对(p->right)执行fork时也会更加复杂。例如,只需进行上述的修改,sleep 10 | echo hi将立即打印“hi”,而不是在10秒后,因为echo将立即运行并退出,而不是等待sleep完成。因为sh.c的目标是尽可能的简单,所以它不会试图避免创建内部进程。

管道看起来并不比临时文件更强大:下面的管道命令行

  1. echo hello world | wc

可以不通过管道实现,如下

  1. echo hello world > /tmp/xyz; wc < /tmp/xyz

在这种情况下,管道相比临时文件至少有四个优势

  • 首先,管道会自动清理自己;在文件重定向时,shell使用完/tmp/xyz后必须小心删除

  • 其次,管道可以任意传递长的数据流,而文件重定向需要磁盘上足够的空闲空间来存储所有的数据。

  • 第三,管道允许并行执行管道阶段,而文件方法要求第一个程序在第二个程序启动之前完成。

  • 第四,如果实现进程间通讯,管道的块读写比文件的非块语义更有效率。