一个小小的 Shell 管道符,内部实现可真不简单!
管道命令我们经常使用,将一个指令的输出导入另一个指令的输入,也就是屁股对上嘴,这个原理连编程小学生都知道。但是如果要深入问进去,一个指令的输出是如何导入到另一个指令的输入,管道又起到什么角色,估计能回答这个问题的人不足 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 信号,默认动作是进程直接退出。