一个小小的 Shell 管道符,内部实现可真不简单!

8,196 阅读1分钟

管道命令我们经常使用,将一个指令的输出导入另一个指令的输入,也就是屁股对上嘴,这个原理连编程小学生都知道。但是如果要深入问进去,一个指令的输出是如何导入到另一个指令的输入,管道又起到什么角色,估计能回答这个问题的人不足 1%。下面我们来深入分析一下管道指令的实现原理,对于下面的这条指令,shell 到底干了些什么

$ cmd1 | cmd2

首先我用下面这张图来描述最终形态,然后再一步一步来分解最终形态的形成过程

图片

上图我们看到了进程描述符表、管道、进程的父子关系。

fork 和 exec

shell 每次执行指令, 需要 fork 出一个子进程来执行,然后将子进程的镜像替换成目标指令,这又会用到 exec 函数。比如下面这条简单的指令

$ cmd

exec 函数不会改变当前进程的进程号,不会改变进程之间的父子关系。可以将进程看成一个带壳的球体,exec 之后,外面的壳不会变,球里面的东西被完全替换了。而输入输出文件描述符默认在壳上面,这意味着指令 cmd 的输入输出继承了 shell 进程的输入输出。

$ cmd1 | cmd2

当指令里面包含一个管道符,意味着需要并行执行两个指令,这时候 shell 需要 fork 两次生成两个子进程,然后分别 exec 换成目标指令。

我们注意到图里面还有一个 pipe,它就是负责父子进程通信的管道。

pipe

管道用于父子进程的通信,在 fork 之前创建 pipe,pipe将成为 fork 之后父子进程之间的纽带。pipe 函数会返回两个描述符(pipe_in, pipe_out),一个用于读,一个用于写。

dup2

下面我们就需要调整图中描述符的尖头,将 cmd1 进程的 stdout 描述符指向管道写,将 cmd2 进程的 stdin 描述符指向管道读,这就需要神奇的 dup2(fd1, fd2) 函数,它的作用是将 fd1 描述符关联 fd2 指向的内核对象,之前 fd1 指向的内核对象引用计数减一,如果减到零就销毁。注意平时我们调用 close 方法本质上只是递减引用计数,同一个内核对象是可以被多个进程共享的。当引用计数减到零时就会正式关闭。

下面我们将 dup2 函数的规则应用一下,对两个进程分别调用 dup2 方法得到

然后再将不需要的描述符关闭掉,就得到了下面的终极图,完美!

如果是两个管道符三个命令如下,就会生成两个管道

$ cmd1 | cmd2 | cmd3

如果任意一端的进程突然挂掉了会发生什么?

假设 cmd1 先挂掉,管道写被动关闭,cmd2 在读取管道内容时会遭遇 EOF,然后正常结束。 假设 cmd2 先挂掉,管道读被动关闭,cmd1 继续写管道,这时候进程会收到一个 SIGPIPE 信号,默认动作是进程直接退出。

下一篇我们将使用酷炫的代码来实现上面的整个过程,我们不仅要知道其中的原理,而且还需要通过亲手实验来了解其中更多的细枝末节。

阅读更多深度技术文章,扫一扫上面的二维码关注微信公众号「码洞」