Bash内置命令的特殊性、前台、后台任务的本质
Bash内置命令的特殊性、前台、后台任务的本质
本文解释bash内置命令的特殊性、前台、后台任务的”本质”,以及前、后台任务和bash进程、终端的关系。网上没类似的资料,所以都是自己的感悟和总结,如有错误,期待你的指正。
因为要详细分析每一个涉及到的内容,我用了很多示例,所以结论比较分散。因此在文章的结尾,我将这些结论大概做了个总结。
引子:一个示例
首先通过一个示例做个引子。
当直接在当前bash环境下执行一个普通命令,这个普通命令的进程会挂在当前bash进程之下(即父进程为当前bash进程)。
例如:
1 | # 在窗口1执行: |
如果,在当前bash环境下将普通命令放入后台执行,这个命令的进程还是会挂在当前bash进程下。
如果是在当前bash环境下执行一个内置命令呢?因为是内置命令,它不会有自己的进程(原因后文解释)。
1 | # 窗口1查询当前bash进程信息 |
发现bash进程没有任何变化。
再如果一次,将bash内置命令放入后台执行呢?
例如:
1 | # 当前bash进程信息 |
结果发现,多了一个bash进程。为什么会如此?
bash进程和bash内置命令
当登陆Linux系统时,会为用户分配一个shell。如果在/etc/passwd
中该用户配置的shell为/bin/bash
,那么就为用户分配一个bash shell。
1 | [root@xuexi ~]# head -1 /etc/passwd |
当登陆用户的身份审核通过后,就会加载bash进程,bash进程再加载它的各个配置文件(/etc/profile
、/etc/profile.d/*.sh
、~/.bashrc
等),从而配置好bash的执行环境。注意”执行环境”这个词,它将贯穿本文。
再来说bash内置命令。
bash内置命令和普通的命令都能在bash环境下执行,并实现它们对应的功能。但它们却有很大区别,最典型的一个区别是ps等工具能捕捉到普通命令的进程,却捕捉不到bash内置命令的进程。
那么哪些是bash内置命令呢?只要查它的man文档时,给出bash文档的都是bash内置命令,如cd、declare、read等。但不代表man时不是bash手册的就不是内置命令,例如kill、echo、pwd、test等,它们通常是功能等价的外部命令。
那么bash内置命令和bash进程有什么关系?
bash内置命令和普通命令不一样。普通命令可以直接执行,不依赖于某种执行环境。例如,sleep命令,可以直接以pid=1的init/systemd为父进程而执行。那些daemon类的服务进程更是如此,它们不依赖于终端,也不依赖于执行环境,只要给它们配置好,就可以直接找init/systemd当爹。
而bash内置命令,既然称之为”bash内置命令”,顾名思义是bash内置的。当我们在当前bash环境下执行bash内置命令,经过shell的一轮解析之后,发现这是个bash内置命令,于是直接在当前bash进程的内部调用执行它们。所以bash内置命令自身是没有进程的。
换句话说,bash内置命令的执行是由它们的bash爹带着它们执行的。这个bash爹是一个负责任的好爹,什么都帮它们准备好,还带着它们一起浪。但正因为爹太负责,把孩子们给宠坏了,这些bash内置命令无论什么时候执行都必须先找好bash爹为它们提供执行环境。
于是问题出现了,如果它们的bash爹死了怎么办(即bash进程被杀或者已经结束)?这个问题并不像想象中的那么简单。下面会非常详细地结合后台任务来分析它。
前台任务和后台任务的本质
后台任务,是专业术语”作业”的一种。作业是指”能选择性地停止、暂停、继续运行某个进程的能力”,通俗地说就是作业用来控制谁可以获得终端(前台进程)、谁不能获得终端(后台进程)。这和我们理解的”放进看不见的后台默默地执行”好像有点区别啊?这无关紧要。
关于后台任务,首先要说明的是后台任务是怎么实现的:通过bash和系统终端的驱动共同提供的交互式界面来实现后台作业能力。在bash手册中是如此解释的:
1 | A user typically employs this facility via an interactive interface supplied jointly by the operating system kernel’s terminal driver and Bash. |
其实,当一个进程的进程组号和当前终端的进程组号相同,则这个进程是前台进程,受键盘影响,可以读、写终端。当一个进程的进程组号和当前终端的进程组号不同时,则这个进程是后台进程,它们不受键盘影响,读、写终端时需要发送特定的信号。
换句话说,后台任务依赖于当前所在的终端,因为它可能会恢复到前台去运行。但当前终端往往会和一个bash进程关联绑定,该bash进程具有当前终端的控制权,所以杀掉终端进程和杀掉当前所在bash进程都能结束一个终端,但是它们却有本质上的区别,后文我用了军营、将军来比喻当前终端、当前bash进程的关系,这就是它们的区别,详细内容见后文一步一步的分析。
当一个进程是前台进程时,它是在当前bash进程下执行的,此时该bash进程失去控制权,无法再进行交互操作,也就是被阻塞。当进程进入后台,意味着它会离开当前bash环境,进入后台执行环境,因为只有离开当前bash环境,才能立刻将终端的控制权还给当前bash进程,bash进程才能为用户提供交互能力。
于是,可以将当前终端进程、当前bash进程、前台进程、后台进程的关系大概理解为下图形式:
更具体地分析,当登录系统时系统会分配一个终端,例如通过ssh登录时会分配终端给一个登录会话(login session),这个终端中的所有进程都属于这个session,登录时的bash进程是session leader,具有这个终端的控制权,或者说这个bash进程拥有被它控制的终端(注意拥有这个动词和被控制的终端这个名词controlling terminal,如果一个进程有它控制的终端,说明它一定是session leader)。实际上,进程具有的一个(属性)能力就是控制终端,每个被fork出来的进程都会继承父进程的终端控制权(例如都能通过终端驱动读、写终端),但只有session leader才拥有这个被控制的终端,只要session leader死了,终端就会被释放,这个终端内的进程组就会成为孤儿进程组,正常情况下孤儿进程组会收到终端释放时发送的SIGHUP信号而死,除非某个进程成立了自己的session leader(man 2 setsid、man 3 setsid)脱离了这个终端的session,通过setsid函数可以让一个进程成立自己的session和进程组,但它不拥有它自己的终端,也就是说,这个进程成了脱离终端的进程,即daemon进程。
普通命令和bash内置命令放入后台的区别
回头看本文开头的引子,为什么sleep 30 &
的sleep进程是在当前bash进程下的,而if true;then sleep 30;fi &
则会新开一个bash进程?
上一小节中说过:bash内置命令的执行依赖于bash进程提供的执行环境,而普通命令则没有依赖性。
sleep 30 &
的sleep是普通命令,不依赖于bash进程,所以它可以直接进入后台,但它毕竟是后台任务,它暂时还依赖于当前终端,且受当前bash进程的控制(例如能放回前台,能被bash查看后台任务信息),所以它暂时还必须挂在当前bash进程下。之所以是暂时,稍后就解释。
1 | [root@xuexi ~]# sleep 30 & |
而if true;then sleep 30;fi &
是bash内置命令要放入后台,放入后台意味着它要离开当前bash环境,所以它在进入后台开始执行前,必须新找一个bash爹为它提供执行环境,所以它新生成了一个bash进程。
1 | # 当前bash进程信息 |
补充一个小知识:其实这个新的bash爹和当前bash进程是不一样的,这个新bash爹是非交互式的shell,可以直接使用
kill -15
杀掉这个新bash进程。而交互式shell下,在没有设置任何陷阱(trap)时,默认是忽略TERM信号的,无法直接kill -15
杀掉一个当前活动的bash进程。
所以,完善一下上面的图:
杀掉后台任务的父进程
还是围绕普通命令的后台和bash内置命令的后台任务来说明。
上一小节在解释普通命令放入后台执行时,后台进程会挂在当前bash进程下,还特地加上了”暂时“两个字。其实,普通命令的进程进入后台时,它不是一定要挂在当前bash进程下的,甚至它不再依赖于终端,之所以还暂时挂在当前bash进程下,是因为它还是个后台任务,还需要被当前bash管理。例如将其放回前台,查看后台任务列表,如果不在当前bash进程下,当前bash进程必然无法管理它。
如果将当前bash进程或者当前终端进程杀掉,对普通命令的后台任务会造成什么影响?试试看。
1 | # 在窗口1执行: |
从结果中不难发现,杀掉后台sleep进程的父进程bash(因为和终端绑定,所以也是杀掉终端)后,sleep进程没有随之中止,而是挂在init/systemd下。前面分析过,它是暂时挂在bash进程下的,它不依赖于bash进程,也不依赖于终端。
再来分析bash内置命令放入后台时,杀掉它的父进程会如何。
以if true;then sleep 55;fi &
为例,这里还要再细致一点地分析它的父进程。
1 | [root@xuexi ~]# if true;then sleep 55;fi & |
这里的sleep有两个父bash进程,其中pid=6520的是新生成的bash爹,pid=6478的是当前bash进程。是否注意到上面if放入后台时返回的进程号为6520,这个进程号对应的是新bash爹。换句话说,pid=6520的bash进程对应的是if命令,而这才是后台进程,但因为sleep在if所在bash进程的进程组内,所以sleep也是后台进程。
所以,当杀掉pid=6520的bash进程后,表示杀掉的是普通命令sleep后台的父进程,也就是if结构,所以sleep进程会直接挂在init/systemd下;当杀掉pid=6478的bash进程后,表示杀掉内置命令if(对应的是pid=6520的bash进程)的父进程,所以if命令的bash爹将带着整个进程组挂在init/systemd下。
分别验证它们。
1 | # 杀掉新生成的bash爹 |
1 | # 杀掉新生成的bash爹的父bash进程,也就是左边那个bash进程 |
如果此时把pid=6563的bash爹杀了,会如何呢?如果前面都理解了的话,这里很容易知道答案。这个进程组是个后台进程组,包括其中的sleep进程,把sleep的父进程杀掉,sleep当然是直接挂在init/systemd下。
过程参考下图:
还没完呢。上面杀的都是它们的直系爹bash进程,如果把它们的爷爷杀掉呢?或者直接把终端关掉呢?这时又不一样了。
杀掉后台任务所在的终端
杀掉终端进程和杀掉当前bash进程对后台任务的影响不一样:杀掉当前bash进程后,后台任务会挂在init/systemd下,而杀掉终端后,后台任务也会中止。
来一个示例。
1 | # 窗口1执行 |
前面说过,后台任务依赖于当前所在的终端。但当前终端往往会和一个bash进程关联绑定,该bash进程具有当前终端的控制权,所以杀掉终端进程和杀掉当前所在bash进程都能结束一个终端,但是它们却又本质上的区别。
其实,可以将当前终端、当前bash进程(更确切地说是后台任务的父进程)、后台的关系看作军营、将军、小兵的关系。当军营中分配了一个将军后,军营为将军和小兵提供休息、商量等环境,将军具有军营的控制权,负责管理军营中的一切,包括小兵。如果杀掉军营中的将军,小兵们发现”营中无大王”,于是立刻收拾行李就走,投奔皇帝去了(init/systemd)。但如果直接把军营给炸了,那么将军和小兵将无一幸免,全军覆没。
实际上,杀掉终端进程时,终端进程会给自己进程组内的所有进程包括bash进程发送一个SIGHUP信号,正是因为收到这个信号,进程组内的所有进程才会中止。
如何让后台进程不依赖于终端?不考虑借助nohup、screen、tmux等第三方工具实现,bash其实也提供了多种解决方案,介绍其中两种方法:
1.将后台任务放入子shell。
这算是对bash深刻认识后才能想到或真正理解的方法。因为将后台任务放入子shell(子shell下一节说明),当子shell结束后,其内后台任务会立即挂到init/systemd下,这样就脱离了终端。
1 | [root@xuexi ~]# (sleep 30 &) |
或者,放进一个脚本(执行脚本也是进入一个子shell)。例如以下是a.sh的内容。
1 | #!/bin/bash |
在执行该脚本的20秒内,两个sleep进程都是在a.sh进程下的。
1 | [root@xuexi ~]# pstree -p | grep sleep |
20秒后,脚本结束,也就是子shell退出,该子shell中的后台sleep将挂在init/systemd下。
1 | [root@xuexi ~]# pstree -p | grep sleep |
如果把脚本中的sleep 20
去掉,那么后台sleep也将是瞬间就挂到init/systemd下的。
2.利用bash内置命令disown将任务移出后台或设置为忽略SIGHUP信号。
1 | # 在窗口1执行 |
当进程disown移出后台后,虽然暂时还挂在bash进程下,但结束终端进程时,该进程将挂到init/systemd下。所以,这样做也将脱离终端。
或者,disown -h
设置后台作业忽略SIGHUP信号。前文说过,当终端进程退出时,将会向终端进程组中的所有进程发送SIGHUP信号,收到这些信号,终端下的所有进程都会终止。
1 | [root@xuexi ~]# sleep 60 & |
然后关闭终端,会发现sleep进程也将挂在init/systemd下。
“奇怪”的问题以及解决方案
前面验证过杀掉if true;then sleep 55;fi &
的bash爹的父进程(也就是sleep的爷爷进程)后,bash爹将带着sleep一起挂在init/systemd进程下。
但是考虑一个”极端”一点的问题。如果这里不是if,而是while/for/until循环,如果这个命令不是在当前bash下执行,而是在一个脚本中执行,杀掉脚本进程后,会如何?
比如,某脚本test.sh内容如下:
1 | #!/bin/bash |
或者如下:
1 | #!/bin/bash |
执行这个脚本时,将有两个test.sh进程,其中一个是test.sh进程自身(称之为进程A),一个是为while提供bash环境的子shell进程(称之为进程B)。
当在脚本运行时,杀掉进程A时或者按下CTRL+C(第二个脚本情况)时,如果查看进程的话,会发现后台永远有一个 “test.sh进程+sleep进程” 在运行,如果在while循环中有输出语句的话(比如echo),那么还会时不时地向终端输出点东西。这不是我们想要的结果,我们想要的是脚本结束时,里面的进程也一起结束,而不是有个进程在后台”偷偷地”运行。
之所以出现这样的问题,是因为进程A终止后,while的bash爹(进程B)带着while结构里的进程sleep一起挂到init/systemd下了,而且很不幸,这是个while循环,会一直不断地在后台运行。
应该怎样解决这种问题?可以将所有的test.sh进程都杀掉,比如使用killall sleep test.sh
命令。还有更好的方法,见另一篇文章:如何让shell脚本自杀。
本文的总结
- 其实本文讲的全是子shell的内容,尽管文中很少出现子shell的字眼。。
- bash内置命令的执行依赖于bash进程提供的执行环境。
- 当前bash环境下执行bash内置命令不会新生成bash进程,除非将它放进后台。
- 杀掉后台任务的父进程,后台任务会挂到pid=1的init/systemd进程下。
- 终端进程、bash进程和后台任务之间的关系:军营、将军、小兵。
- (1).终端进程为bash进程和其他进程提供生存环境。
- (2).终端进程往往会和一个bash进程绑定,这个bash进程具有终端的控制权,也就是管理军营。
- (3).杀掉终端的管理bash进程,终端进程也会随之终止。
- (4).bash进程是后台任务的暂时管理者。当bash进程终止时,后台任务就会挂到pid=1的进程下接受init/systemd的管理。
- 杀掉终端进程,会发送SIGHUP信号给终端进程组中的所有进程。收到SIGHUP信号后,这些进程都会终止,包括bash进程和后台任务。
- 让后台任务脱离终端的方法,除了nohup、screen、tmux等三方工具,bash自身也能实现。只需将后台任务放进子shell中执行即可(最简单的方法
(sleep 30 &)
),或者用disown命令将后台任务移除后台,或disown -h
设置后台进程忽略SIGHUP信号。 - 分析下面的脚本,为什么执行过程中按下 CTRL+C 后,还会时不时地向终端上输出点东西,如何解决这个问题?
1
2
3
4
5
6#!/bin/bash
while true;do
sleep 3
echo "hello world! hello world! "
done &
sleep 60
最后,本文的姊妹篇: