一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情。
原文链接 xv6-riscv文档
管道
管道是一对内核留出的暴露给进程的一个写一个读的文件描述符对。在管道的一头写入的数据可以在管道的另一头读取到。管道给进程提供了通信的方式。
下面的用连接到读端的标准输入运行程序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通过类似的方式实现像grep fork sh.c | wc -l
的管道。子进程创建管道连接命令的左边与右边,然后它分别在命令的左侧和右侧调用fork
和runcm
,并且等待他们结束。右边的命令可能自身也是一个管道调用(如a | b | c
),那么它会fork
出两个新的子进程,一个给b
一个给c
。因此,shell可能会创建一棵进程树。树叶是命令而中间节点是哪些等待左子进程和右子进程结束的进程。
事实上,你也可以让管道左端实现在内部节点中,但是这样实现起来比较困难。考虑以下的修改:让sh.c
(译者注:也即shell)不fork
左节点并且直接在中间节点运行runcmd(p->left)
。那么,比如echo hi | wc
就无法产生输出,因为echo hi
会在runcmd
中退出,中间节点退出并且永不调用管道右边。不正确的行为也许可以通过不在runcmd
中调用exit
来进行修正,但是问题又会出现:runcmd
并不知道它是不是一个中间进程。当我们不fork
右节点就运行runcmd(p->right)
时也会出现问题。例如,考虑一下命令: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
。第二,管道可以随意的传递长的数据流,但是文件重定向需要在磁盘上有足够的空间来存储所有的数据。第三,管道允许管道命令的并行执行,但是文件方法需要第一个程序在第二个程序开始之前结束。第四,如果你正在实现一个内部进程通信,管道阻塞式的读写比文件非阻塞式的运行更有效。
也许你常常在shell中使用管道,但是你却不一定能知道他们是如何实现的,今天的文章大概的进行了一些讲述,也许可以让你多一些了解。
感谢阅读。