欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  科技

bash内置命令的特殊性,后台任务的"本质"

程序员文章站 2022-12-03 10:09:19
本文解释bash内置命令的特殊性、前台、后台任务的"本质",以及前、后台任务和bash进程、终端的关系。网上没类似的资料,所以都是自己的感悟和总结,如有错误,120分的期待盼请指正。 因为要详细分析每一个涉及到的内容,我用了很多示例,所以结论比较分散。因此在文章的结尾,我将这些结论大概做了个总结。 ......

本文解释bash内置命令的特殊性、前台、后台任务的"本质",以及前、后台任务和bash进程、终端的关系。网上没类似的资料,所以都是自己的感悟和总结,如有错误,120分的期待盼请指正。

因为要详细分析每一个涉及到的内容,我用了很多示例,所以结论比较分散。因此在文章的结尾,我将这些结论大概做了个总结。

1.引子:一个示例

首先通过一个示例做个引子。

当直接在当前bash环境下执行一个普通命令,这个普通命令的进程会挂在当前bash进程之下(即父进程为当前bash进程)。

例如:

# 在窗口1执行:
[root@xuexi ~]# sleep 30

# 在窗口2查看sleep进程信息:
[root@xuexi ~]# pstree -p | grep slee[p]
           |-sshd(1145)---sshd(5230)-+-bash(5232)---sleep(5599)

如果,在当前bash环境下将普通命令放入后台执行,这个命令的进程还是会挂在当前bash进程下。

如果是在当前bash环境下执行一个内置命令呢?因为是内置命令,它不会有自己的进程(原因后文解释)。

# 窗口1查询当前bash进程信息
[root@xuexi ~]# pstree -p | grep bas[h]
           |-sshd(1145)---sshd(5230)-+-bash(5232)---sleep(5642)
           |                         `-bash(5557)-+-grep(5644)

# 窗口2执行bash内置命令if
[root@xuexi ~]# if true;then sleep 30;fi

# 回到窗口1查询当前bash进程信息,和前面是一样的
[root@xuexi ~]# pstree -p | grep bas[h]
           |-sshd(1145)---sshd(5230)-+-bash(5232)---sleep(5642)
           |                         `-bash(5557)-+-grep(5644)

发现bash进程没有任何变化。

再如果一次,将bash内置命令放入后台执行呢?

bash内置命令的特殊性,后台任务的"本质"

例如:

# 当前bash进程信息
[root@xuexi ~]# pstree -p | grep bas[h]
           |-sshd(1145)---sshd(5230)-+-bash(5232)
           |                         `-bash(5557)-+-grep(5634)

[root@xuexi ~]# if true;then sleep 30;fi &
[1] 5635

# 再次查看bash进程信息
[root@xuexi ~]# pstree -p | grep bas[h]
           |-sshd(1145)---sshd(5230)-+-bash(5232)---bash(5635)---sleep(5636)
           |                         `-bash(5557)-+-grep(5638)

结果发现,多了一个bash进程。为什么会如此?

2.bash进程和bash内置命令

当登陆Linux系统时,会为用户分配一个shell。如果在/etc/passwd中该用户配置的shell为 /bin/bash ,那么就为用户分配一个bash shell。

[root@xuexi ~]# head -1 /etc/passwd
root:x:0:0:root:/root:/bin/bash

当登陆用户的身份审核通过后,就会加载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, :, ., [, alias, bg, bind, break, builtin, caller, cd, command, compgen, complete, compopt, continue, declare, dirs, disown, echo, enable, eval, exec, exit, export, false, fc, fg, getopts, hash, help, history, jobs, kill, let, local, logout, mapfile, popd, printf, pushd, pwd, read, readonly, return, set, shift, shopt, source, suspend, test, times, trap, true, type, typeset, ulimit, umask, unalias, unset, wait

除此之外,还有一些保留关键字也是bash内置命令。包括:

! case do done elif else esac fi for function if in select then until while { } time [[ ]]

那么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进程被杀或者已经结束)?这个问题并不像想象中的那么简单。下面会非常详细地结合后台任务来分析它。

3.前台任务和后台任务的本质

后台任务,专业术语是"作业"。它是指"能选择性地停止、暂停、继续运行某个进程的能力"。这和我们理解的"放进看不见的后台默默地执行"好像有点区别啊?这无关紧要。

关于后台任务,首先要说明的是后台任务是怎么实现的:通过bash和系统终端的驱动共同提供的交互式界面来实现后台作业能力。在bash手册中是如此解释的:

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内置命令的特殊性,后台任务的"本质"

3.1 普通命令和bash内置命令放入后台的区别

回头看本文开头的引子,为什么sleep 30 &的sleep进程是在当前bash进程下的,而if true;then sleep 30;fi &则会新开一个bash进程?

上一小节中说过:bash内置命令的执行依赖于bash进程提供的执行环境,而普通命令则没有依赖性。

sleep 30 &的sleep是普通命令,不依赖于bash进程,所以它可以直接进入后台,但它毕竟是后台任务,它暂时还依赖于当前终端,且受当前bash进程的控制(例如能放回前台,能被bash查看后台任务信息),所以它暂时还必须挂在当前bash进程下。之所以是暂时,稍后就解释。

[root@xuexi ~]# sleep 30 &
[1] 6300

[root@xuexi ~]# pstree -p | grep bas[h]
           |-sshd(1145)-+-sshd(5230)---bash(5557)-+-grep(6302)
           |            `-sshd(6047)---bash(6049)---sleep(6300)

if true;then sleep 30;fi &是bash内置命令要放入后台,放入后台意味着它要离开当前bash环境,所以它在进入后台开始执行前,必须新找一个bash爹为它提供执行环境,所以它新生成了一个bash进程。

# 当前bash进程信息
[root@xuexi ~]# pstree -p | grep bas[h]
           |-sshd(1145)---sshd(5230)-+-bash(5232)
           |                         `-bash(5557)-+-grep(5634)

[root@xuexi ~]# if true;then sleep 30;fi &
[1] 5635

# 再次查看bash进程信息
[root@xuexi ~]# pstree -p | grep bas[h]
           |-sshd(1145)---sshd(5230)-+-bash(5232)---bash(5635)---sleep(5636)
           |                         `-bash(5557)-+-grep(5638)

补充一个小知识:其实这个新的bash爹和当前bash进程是不一样的,这个新bash爹是非交互式的shell,可以直接使用kill -15杀掉这个新bash进程。而交互式shell下,在没有设置任何陷阱(trap)时,默认是忽略TERM信号的,无法直接kill -15杀掉一个当前活动的bash进程。

所以,完善一下上面的图:

bash内置命令的特殊性,后台任务的"本质"

3.2 杀掉后台任务的父进程

还是围绕普通命令的后台和bash内置命令的后台任务来说明。

上一小节在解释普通命令放入后台执行时,后台进程会挂在当前bash进程下,还特地加上了"暂时"两个字。其实,普通命令的进程进入后台时,它不是一定要挂在当前bash进程下的,甚至它不再依赖于终端,之所以还暂时挂在当前bash进程下,是因为它还是个后台任务,还需要被当前bash管理。例如将其放回前台,查看后台任务列表,如果不在当前bash进程下,当前bash进程必然无法管理它。

如果将当前bash进程或者当前终端进程杀掉,对普通命令的后台任务会造成什么影响?试试看。

# 在窗口1执行:
[root@xuexi ~]# sleep 67 & 
[1] 6464

# 在窗口2查看sleep进程的父进程
[root@xuexi ~]# pstree -p | grep sleep
           |            `-sshd(6047)---bash(6049)---sleep(6464)

# 杀掉父进程:bash进程。因为是交互式shell,所以必须使用SIGKILL信号
[root@xuexi ~]# kill -9 6049

# 再查看sleep进程
[root@xuexi ~]# pstree -p | grep sleep
           |-sleep(6464)

从结果中不难发现,杀掉后台sleep进程的父进程bash(因为和终端绑定,所以也是杀掉终端)后,sleep进程没有随之中止,而是挂在init/systemd下。前面分析过,它是暂时挂在bash进程下的,它不依赖于bash进程,也不依赖于终端。

再来分析bash内置命令放入后台时,杀掉它的父进程会如何。

if true;then sleep 55;fi &为例,这里还要再细致一点地分析它的父进程。

[root@xuexi ~]# if true;then sleep 55;fi &
[1] 6520
[root@xuexi ~]# pstree -p | grep sleep
           |         `-sshd(6476)---bash(6478)-+-bash(6520)---sleep(6521)

这里的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下。

分别验证它们。

# 杀掉新生成的bash爹
[root@xuexi ~]# if true;then sleep 55;fi &
[1] 6551

[root@xuexi ~]# pstree -p | grep sleep     
           |        `-sshd(6476)---bash(6478)-+-bash(6551)---sleep(6552)

[root@xuexi ~]# kill 6551

# 查看sleep进程,发现确实已经挂在Init/systemd下了
[root@xuexi ~]# pstree -p | grep sleep
           |-sleep(6552)
# 杀掉新生成的bash爹的父bash进程,也就是左边那个bash进程

# 在窗口1执行
[root@xuexi ~]# if true;then sleep 55;fi &
[1] 6563

# 在窗口2执行
[root@xuexi ~]# pstree -p | grep sleep    
           |            `-sshd(6476)---bash(6478)-+-bash(6563)---sleep(6564)
[root@xuexi ~]# kill -9 6478

# 窗口2查看sleep进程,发现bash爹带着整个进程组都挂在init/systemd下
[root@xuexi ~]# pstree -p | grep sleep
           |-bash(6563)---sleep(6564)

如果此时把pid=6563的bash爹杀了,会如何呢?如果前面都理解了的话,这里很容易知道答案。这个进程组是个后台进程组,包括其中的sleep进程,把sleep的父进程杀掉,sleep当然是直接挂在init/systemd下。

过程参考下图:

bash内置命令的特殊性,后台任务的"本质"

还没完呢。上面杀的都是它们的直系爹bash进程,如果把它们的爷爷杀掉呢?或者直接把终端关掉呢?这时又不一样了。

3.3 杀掉后台任务所在的终端

杀掉终端进程和杀掉当前bash进程对后台任务的影响不一样:杀掉当前bash进程后,后台任务会挂在init/systemd下,而杀掉终端后,后台任务也会中止。

来一个示例。

# 窗口1执行
[root@xuexi ~]# sleep 65 &
[1] 7108

# 窗口2查看
[root@xuexi ~]# pstree -p | grep sleep
           |            `-sshd(7014)---bash(7016)---sleep(7108)

# 窗口2杀掉pid=7014,或者直接关掉窗口1
[root@xuexi ~]# kill 7014

# 窗口2再查看sleep进程信息,啥也没有
[root@xuexi ~]# pstree -p | grep sleep

前面说过,后台任务依赖于当前所在的终端。但当前终端往往会和一个bash进程关联绑定,该bash进程具有当前终端的控制权,所以杀掉终端进程和杀掉当前所在bash进程都能结束一个终端,但是它们却又本质上的区别。

其实,可以将当前终端、当前bash进程(更确切地说是后台任务的父进程)、后台的关系看作军营、将军、小兵的关系。当军营中分配了一个将军后,军营为将军和小兵提供休息、商量等环境,将军具有军营的控制权,负责管理军营中的一切,包括小兵。如果杀掉军营中的将军,小兵们发现"营中无大王",于是立刻收拾行李就走,投奔皇帝去了(init/systemd)。但如果直接把军营给炸了,那么将军和小兵将无一幸免,全军覆没。

实际上,杀掉终端进程时,终端进程会给自己进程组内的所有进程包括bash进程发送一个SIGHUP信号,正是因为收到这个信号,进程组内的所有进程才会中止。

如何让后台进程不依赖于终端?不考虑借助nohup、screen、tmux等第三方工具实现,bash其实也提供了解决方案,有两种方法:

1.将后台任务放入子shell。

这算是对bash深刻认识后才能想到或真正理解的方法。因为将后台任务放入子shell(子shell下一节说明),当子shell结束后,其内后台任务会立即挂到init/systemd下,这样就脱离了终端。

[root@xuexi ~]# (sleep 30 &)
[root@xuexi ~]# pstree -p | grep sleep
           |-sleep(2392)

或者,放进一个脚本(执行脚本也是进入一个子shell)。例如以下是a.sh的内容。

#!/bin/bash

sleep 60 &

sleep 20

在执行该脚本的20秒内,两个sleep进程都是在a.sh进程下的。

[root@xuexi ~]# pstree -p | grep sleep
           |---sshd(2317)-+-bash(2348)---a.sh(2449)-+-sleep(2450)
           |              |                         `-sleep(2451)

20秒后,脚本结束,也就是子shell退出,该子shell中的后台sleep将挂在init/systemd下。

[root@xuexi ~]# pstree -p | grep sleep
           |-sleep(2450)

如果把脚本中的sleep 20去掉,那么后台sleep也将是瞬间就挂到init/systemd下的。

2.利用bash内置命令disown将任务移出后台或设置为忽略SIGHUP信号。

# 在窗口1执行
[root@xuexi ~]# sleep 60 &   # jobs的作业号码:%1
[root@xuexi ~]# disown %1    # 将后台作业%1移出后台
[root@xuexi ~]# jobs         # 返回空

当进程disown移出后台后,虽然暂时还挂在bash进程下,但结束终端进程时,该进程将挂到init/systemd下。所以,这样做也将脱离终端。

或者,disown -h设置后台作业忽略SIGHUP信号。前文说过,当终端进程退出时,将会向终端进程组中的所有进程发送SIGHUP信号,收到这些信号,终端下的所有进程都会终止。

[root@xuexi ~]# sleep 60 &
[root@xuexi ~]# disown -h %1  # 为%1后台作业打上忽略SIGHUP的标记

然后关闭终端,会发现sleep进程也将挂在init/systemd下。

4."奇怪"的问题以及解决方案

前面验证过杀掉if true;then sleep 55;fi &的bash爹的父进程(也就是sleep的爷爷进程)后,bash爹将带着sleep一起挂在init/systemd进程下。

但是考虑一个"极端"一点的问题。如果这里不是if,而是while/for/until循环,如果这个命令不是在当前bash下执行,而是在一个脚本中执行,杀掉脚本进程后,会如何?

比如,某脚本test.sh内容如下:

#!/bin/bash

while true;do
    sleep 10
done &

或者如下:

#!/bin/bash

while true;do
    sleep 10
done &

sleep 50

执行这个脚本时,将有两个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命令。还有更好的方法,见我的另一篇文章,专门写这个问题:。

5.本文的总结

  1. 其实本文讲的全是子shell的内容,尽管文中很少出现子shell的字眼。。
  2. bash内置命令的执行依赖于bash进程提供的执行环境。
  3. 当前bash环境下执行bash内置命令不会新生成bash进程,除非将它放进后台。
  4. 杀掉后台任务的父进程,后台任务会挂到pid=1的init/systemd进程下。
  5. 终端进程、bash进程和后台任务之间的关系:军营、将军、小兵。
    • (1).终端进程为bash进程和其他进程提供生存环境。
    • (2).终端进程往往会和一个bash进程绑定,这个bash进程具有终端的控制权,也就是管理军营。
    • (3).杀掉终端的管理bash进程,终端进程也会随之终止。
    • (4).bash进程是后台任务的暂时管理者。当bash进程终止时,后台任务就会挂到pid=1的进程下接受init/systemd的管理。
  6. 杀掉终端进程,会发送SIGHUP信号给终端进程组中的所有进程。收到SIGHUP信号后,这些进程都会终止,包括bash进程和后台任务。
  7. 让后台任务脱离终端的方法,除了nohup、screen、tmux等三方工具,bash自身也能实现。只需将后台任务放进子shell中执行即可(最简单的方法(sleep 30 &)),或者用disown命令将后台任务移除后台,或disown -h设置后台进程忽略SIGHUP信号。
  8. 分析下面的脚本,为什么执行过程中按下 CTRL+C 后,还会时不时地向终端上输出点东西,如何解决这个问题?

    #!/bin/bash
    while true;do
    sleep 3
    echo "hello world! hello world! "
    done &
    sleep 60

最后,本文的姊妹篇: