$ ls
Shell本质上是一个用户态程序,运行于内核之上,可以与用户进行交互。当用户输入一些命令后,shell会对这个输入进行解析,找到对应的可执行文件并执行,然后显示输出。
shell基本工作原理
命令执行
shell的基本工作原理可以用下图概括:当shell运行后,会处在一个死循环中,等待用户输入命令;而一旦用户有命令输入,会调用fork产生一个子进程,父进程等待子进程完成,子进程调用exec替换内核镜像执行用户命令,结束后执行exit。
管道命令
管道命令模型
利用管道组合而成的命令,实际上是构成了一棵命令树,例如当sh
解析命令ls | (sort|tail -1)
时,得到的命令树如下:
实现
任务划分
现在我们来考虑如何实现管道功能,我们把整个任务划分为三个部分:
- 创建管道
- 左侧命令向管道中写入
- 右侧命令从管道中读取
创建管道
我们使用pipe()
函数创建管道,如果失败触发一个perror
1 | if(pipe(p) < 0){ |
处理左侧命令
创建管道后,我们开始处理左侧命令,我们首先创建了一个新的进程,然后将标准输出重定向至写管道,最后执行左侧命令
1 | if(fork1() == 0){ |
处理右侧命令
右侧命令类似,只不过方向是从读管道中读取
1 | if(fork1() == 0){ |
等待左右命令完成
最后我们的线程做一些收尾工作,等待左右命令完成
1 | close(p[0]); |
思考
根据上面的管道实现的过程,思考下面的问题:
- 为何需要多次关闭pipe?
- 为何要为左右命令都
fork
?能不能只为左侧或右侧命令创建进程? - 为何要在两个子进程都创建好之后才开始wait,能不能先创建一个wait一个再创建另一个wait另一个?
- 当左右两侧读写速度不一致时会发生什么?如果左侧快会怎样?右侧快又会怎样?
- 左右两侧的命令是如何决定何时退出的?
- 如果右侧命令没有关闭写端,会发生什么?左侧命令没有关闭读端,又会发生什么?
- 内核如何决定何时释放
pipe
的buffer
?
为何需要多次关闭pipe?
每个进程的文件描述符表是相互独立的,所以每个进程都需要单独关闭自己的文件描述符。
为何要为左右命令都fork
?能不能只为左侧或右侧命令创建进程?
根据管道命令构成的命令树我们可知,如果只为右侧命令创建进程,那么构成的命令树如下:
这个显然是有问题的,shell会调用exec
去执行左侧命令,如果不fork,那么意味着进程会执行runcmd(pcmd->left);
,也不会返回,more
命令不会有机会被执行,所以此时只有ls
向管道中写入了命令,而没有命令从管道中读取。
而如果我们只为左侧命令创建进程,那么执行过程如下:
- 先创建一个进程,子进程执行左侧命令
- 父进程执行右侧命令,如果右侧命令还是带管道的命令,那么重复上述过程
看似没什么问题,执行起来也确实如此,但是这只是表象,如果我们执行下列命令
1 | sleep 10 | echo hi |
正常情况下,这个命令应该先打印hi
,然后等待约10秒后退出,但是如果没有给右侧命令创建进程,那么会打印hi然后直接显示终端,没有睡眠10秒的过程,是因为右侧命令占用了我们的进程,改变了进程的执行分支,导致主进程没有办法执行wait指令,从而无法对子进程进行回收,直接抛弃了子进程继续执行而不是等待子进程结束后再显示终端。此时如果用ps ax
查看,可以看到一个sleep 10
的进程,说明左侧命令确实执行了,但是是以孤儿进程的方式执行的,父进程不再等待这个进程,任由其自生自灭。从上面两个实验我们可知,使用管道时,必须对左右命令单独创建子进程,然后主进程等待这两个命令执行完毕。
为何要在两个子进程都创建好之后才开始wait
如果我们先创建一个wait一个,再创建另一个wait另一个,这意味着我们明确规定管道左侧的命令先执行,等管道左侧命令完成后再执行管道右侧。如果左侧命令向管道中写入的数据不多,这是可以的。但是如果左侧命令向管道中写入大量数据直到写满了,而没有进程消耗这些数据,就会导致写堵塞,既写不进去,有没有进程读取数据,会导致程序被阻塞。
左右两侧读取速度不一致会发生什么?
根据pipe(7) - Linux manual page (man7.org)可知,如果试图往一个满的pipe写入,那么write
会被阻塞,直到pipe
被读取后有空间写入后才会继续执行;同理,如果试图读取空的pipe,read
也会被阻塞,直到有数据可读。
左右两侧的命令如何决定何时退出?
管道只是起了重定向的作用,我认为命令的退出还是和命令本身的退出机制有关,例如ls,如果读取的文件结束并且输出也结束,那么ls就会退出。(这里还不是特别清楚)
如果右侧命令没有关闭写端,会发生什么?左侧命令没有关闭读端,又会发生什么?
- 如果右侧(读)命令没有关闭写端,那么左侧命令执行完后会退出,右侧不会
因为右侧命令还会等待其他命令对管道的写入,直到发现管道写端也关闭后,才会结束读取的动作并退出。但是左侧命令没关写管道就结束了,留下右侧命令以为还有进程会写pipe,所以它会傻傻地等待。
- 如果左侧(写)命令没有关闭读端,会发生什么?
这个情况比较复杂,首先要了解一个概念broken pipe
一个程序如果试图向一个没有任何读者的管道中写入内容,那么这个程序会收到一个SIGPIPE 信号,告诉这个程序别瞎忙活了,没有人读管道,然后这个程序就会结束写操作。
一个经典的例子如下:
1 | yes | true |
yes命令会无限地向管道中写’y’,而true命令判断管道中内容是否为真,为真就退出。按照我们的理解,true会判断yes写入的内容为真,立刻退出,然后yes会一直向管道中写内容,直到写阻塞。但是实际并不是这样,yes会在true结束后立刻结束。因为true结束后,管道没人读了,yes如果继续写,会导致broken pipe
并收到一个SIGPIPE 信号,然后导致yes退出。现在来理解一下问题:
如果左侧(写)命令没有关闭读端,会发生什么?
如果写命令没有关闭读端,那么他会以为管道有读者(实际上这个读者就是他自己),从而不会触发broken pipe,写命令会傻傻地将管道写满至堵塞,然后等那个不存在的读者读取,所以会导致阻塞的发生。
IO重定向
解释
在解释IO重定向前,我们先了解一下shell中最终要的三个文件描述符:
- 标准输入:stdin 0
- 标准输出:stdout 1
- 标准错误:stderr 2
shell一般从标准输入获取命令,然后将运行结果发送至标准输出或错误,而一般标准输出就是终端。shell一般保证这三个描述符总是打开的。现在我们想要重定向输入输出,从其他的文件中读取,再发送到普通文件,这就需要进行IO重定向。
命令列表
命令 | 解释 |
---|---|
command > file | 将输出重定向至file |
command < file | 将输入重定向至file |
command >> file | 将输出追加至file |
实现
函数原型
- open
1 | int open (const char* Path, int flags [, int mode ]); // flags表示了文件处理方式 |
代码
shell对命令解析后,为了实现重定向,我们需要做下面几件事:
- 关闭原有的fd
- 打开新文件,替换原有fd
- 执行命令
所以我们的代码如下:
1 | case '>': |
现在当我们执行下面语句是,就会将给定文件重定向至标准输入/输出
1 | 6.828$ /bin/echo "Helloworld" > result |
其他运算符
这里总结一下其他常见的一些shell中的运算符:
&&运算符
格式
1 | cmd1 && cmd2 |
功能
只有在左侧命令返回true
时,才执行右侧命令
||运算符
格式
1 | cmd1 || cmd2 |
功能
只有在左侧命令返回false
时,才执行右侧命令
()运算符
格式
1 | (cmd1; cmd2; cmd3) |
功能
命令组,括号中的命令会新开一个子shell顺序执行。
参考文献
- 1.MIT6828第四课 ↩