作业控制

shell中通过command &可以创建后台作业, 通过jobs -l命令可以查看当前shell中维护的作业列表, 包括他们的作业号, 进程号, 运行状态. 其中作业号(jobIDJOB_SPEC)是作业在当前shell中的唯一标识.

作业与进程

作业相比于进程是更高级的调度单位, 其定位类似于进程组, 但与进程组不同的是, 作业只维护其初始进程, 一旦所有的初始进程退出则代表作业执行完毕, 未结束的子进程不会被作业追踪, 如下例:

#!/usr/bin/bash

(sleep 20 & sleep 2) &
jobs -l
pstree -apg $$
ps -ejfH | awk '$10 ~ /^sleep/ {printf "%s %s -- PPID:%s, PGID:%s\n",$10,$11,$3,$4}'
sleep 2
echo
jobs -l
pstree -apg $$
ps -ejfH | awk '$10 ~ /^sleep/ {printf "%s %s -- PPID:%s, PGID:%s\n",$10,$11,$3,$4}'
pkill sleep

运行这个例子可以得到如下输出, 可以看到整个作业初始只包括一个没有waitbash进程19318, 运行2秒后19321退出导致19318退出, 因此作业结束, 此时19319由于父进程退出而变为孤儿进程, 但其进程组号未改变:

[1]+ 19318 Running                 ( sleep 20 & sleep 2 ) &
bash,19317,19317 pgroup.sh
  ├─bash,19318,19317 pgroup.sh  # 作业的初始进程
  │   ├─sleep,19319,19317 20  # 作业运行中创建的子进程
  │   └─sleep,19321,19317 2  # 作业运行中创建的子进程
  └─pstree,19320,19317 -apg 19317
sleep 20 -- PPID:19318, PGID:19317
sleep 2 -- PPID:19318, PGID:19317

# 进程 19321 退出 由于没有 wait 导致整个作业退出
[1]+ 19318 Done                    ( sleep 20 & sleep 2 )
bash,19317,19317 pgroup.sh
  └─pstree,19328,19317 -apg 19317
sleep 20 -- PPID:1, PGID:19317  # 变为孤儿进程 但进程组号未变

作业的存在是为了方便shell对前后台进程(进程组)进行管理, 一个shell进程在同一时刻只能存在一个前台作业但可以存在多个后台作业. 每个作业可以包含一个或多个进程, 具体体现为如下情况:

  • 作业中包含单个进程
> sleep 50 &
[1] 20536  # 作业号为 1, 包含的进程号为 20536
> jobs -l
[1]+ 20536 Running                 sleep 50 &
  • 作业中包含进程及其子进程
> (sleep 20 & sleep 10 & wait) &
[1] 21111
> pstree -ap $$
bash,21023
  ├─bash,21111  # 作业的初始进程
  │   ├─sleep,21112 20
  │   └─sleep,21113 10
  └─pstree,21116 -ap 21023
  • 作业中包含多个进程
> sleep 10 | sleep 8 &  # 管道两侧在 sub shell 中运行
[1] 20689
> jobs -l  # 作业中包含两个初始进程
[1]+ 20688 Running                 sleep 10
     20689                       | sleep 8 &

前台作业管理

在一个shell会话中直接输入命令执行的作业会在前台运行, 前台作业会一直阻塞shell直到其执行完毕或被挂起. 前台作业能够优先处理传递给进程组的信号, 并且优先占用输入文件描述符. 以外部命令cat命令为例, 在启动后, shell会把当前的前台进程组指定为新建立的cat进程组, 这样cat就接管了整个shell的标准输入, 标准输出和标准错误文件描述符. 命令的组合也可以作为前台任务被执行, 例如:

while true; do echo "Hello World"; sleep 10; done

要退出上面这个死循环需要给进程发送中断信号, 为了方便, 我们一般使用Ctrl + C直接给整个进程组发送SIGINT信号. 但如果我们要在不退出前台作业的基础上拿回终端控制权, 就需要给作业发送SIGTSTPSIGSTOP信号来让他暂时挂起.

> ping 127..0.0.1
^Z  # 按下 Ctrl + Z
[1]+  Stopped                 ping 127.0.0.1

通过Ctrl + Z可以给shell中的前台作业发送SIGTSTP, 这样ping命令就暂时停止了, 我们重新拿回了shell的控制权, 通过ps T命令可以查看当前shell终端关联的所有进程:

> ps T
  PID TTY      STAT   TIME COMMAND
 4731 pts/1    Ss     0:00 /bin/bash
 4738 pts/1    T      0:00 ping 127.0.0.1
 4891 pts/1    R+     0:00 ps T

可以看到ping命令并没有退出, 而且他的状态被改成了T, 通过查阅man ps可以知道, T状态代表进程被作业控制信号停止. 此外, 可以使用内置命令suspend挂起当前shell.

后台作业管理

除了让前台作业挂起将其变为后台作业之外, 通过command &的方式可以让命令直接在后台运行, 我们在刚才挂起的ping基础上再添加两个运行中的后台作业:

> xeyes &
> xload &

通过jobs -l可以查看当前shell中的后台作业:

> jobs -l
[1]+  6528 Stopped                 ping 127.0.0.1
[2]   7101 Running                 xeyes &
[3]-  7104 Running                 xload &

第一列[1]表示作业号JOB_SPEC, 后面跟随的+表示其为当前作业, 或者说最近的被调往前台的作业, 而-表示当前作业的前一个作业, 我们可以通过%JOB_SPEC %+ %-的方式在命令中访问这些作业:

> fg %2  # 或 fg 2, 因为 fg 只对作业有效所以可以省略百分号
xeyes
^Z  # 挂起
[2]+  Stopped                 xeyes
> jobs  # 不带 l 选项则不显示进程号
[1]-  Stopped                 ping 127.0.0.1  # 减号表示前一个作业
[2]+  Stopped                 xeyes  # 加号表示当前作业
[3]   Running                 xload &
> fg  # 等价于 fg + 或 fg %+ 或 fg %%

第二列数字6528为该作业包含的初始进程进程号, 初始进程可以为多个, 所有初始进程执行完毕则作业执行完毕. 第三列表示作业的状态, 包括以下几种:

> sleep 5 &
> kill %+  # 等价于 kill %2 百分号不可省略
> jobs
[1]+  Stopped                 ping 127.0.0.1  # 停止
[2]   Terminated              xeyes  # 被终止
[3]-  Running                 xload &  # 运行态
[4]   Done                    sleep 5  # 成功执行完毕并已经退出, 只会显示一次
[5]   Hangup                  ping 127.0.0.1 > /dev/null  # 挂起 无特殊处理则退出

最后一列是作业的运行命令文本, 在命令控制任务中可以使用%s%?s通过匹配作业文本的方式指定作业:

> kill -s int %ping  # 给作业文本以 ping 开头的作业发送 SIGINT
> fg %?eye  # 把作业文本包含 eye 的作业提到前台

我们还可以使用bg命令让挂起的后台作业直接运行, 而不必将其提到前台, 其用法类似于fg:

> sleep 100
^Z  # 挂起
[1]+  Stopped                 sleep 100
> bg 1  # 等价于 bg %1
[1]+ sleep 100 &
> jobs
[1]+  Running                 sleep 100 &

但要注意, 为了防止后台作业与前台作业争抢输入资源, 只要后台作业执行到需要读取输入的代码段, 就会导致该作业被挂起, 这时使用bg命令无效.

> while true; do read line; done &
[1] 20780
> bg 1
[1]+ while true; do
    read line;
done &
# 被输入挂起 仍处于停止状态
[1]+  Stopped                 while true; do
    read line;
done

作业控制命令与符号

作业控制命令及其用法可以总结为如下表格:

符号 描述 例子
& 把作业放到后台 command &
%n 作业列表中作业号为n的作业 kill %1
%s 作业列表中名称以字符串s开头的作业 kill %xe
%?s 作业列表中名称包括字符串s的作业 jobs %?ping
%% 或 %+ 表示当前作业 kill %% kill %+
%- 表示当前作业的上一个作业 bg %-
Ctrl + Z 挂起或停止作业 kill -s stop %ping
jobs -l 列出所有作业 jobs -l
jobs -r 列出所有正在运行的作业 jobs -r
jobs -s 列出所有被挂起的作业 jobs -s
bg 让作业在后台运行 bg %%
fg 把作业提到前台 fg %apt-get

处理 SIGHUP 信号

无论是前台作业还是后台作业, 他们都呈树状结构依附于shell进程, 在shell终端退出时如果存在后台作业, 则会提示There are stopped jobs., 如果忽略这个提示继续退出, shell会向所有作业发送SIGHUP信号来进行清理. 如果我们需要某些后台进程在终端退出时仍然继续运行, 就需要对SIGHUP信号进行处理.

外部命令 nohup

使用nohup命令启动作业可以让该作业忽略SIGHUP, 其用法如下:

> nohup ping 127.0.0.1 &
[1] 21222
nohup: ignoring input and appending output to 'nohup.out'
# exec ping 127.0.0.1 & 也可以实现类似的效果
# 但 nohup 会自动帮你处理输出重定向

退出当前终端并在另一个终端执行查找ping进程:

> ps -ef | awk '$8~/^ping/ {print "PID:"$2", PPID:"$3}'
PID:21222, PPID:1  # 变为孤儿进程继续运行

为了防止后台作业阻塞, nohup会让作业忽略输入, 并将所有输出默认重定向到~/nohup.out文件中, 我们可以手动进行输出重定向, 而且会自动帮我们将标准错误重定向到标准输出:

> nohup ping 127.0.0.1 > outfile &
[1] 22505
ignoring input and redirecting stderr to stdout

> nohup ping 127.0.0.1 &> outfile &
[2] 22523

但要注意, nohup命令后不可以通过(...)的方式在subshell中执行命令, 这样做会导致解释器将nohup视为函数从而导致语法错误:

> nohup (sleep 120; echo "job done") &  # 这么写会报错
> nohup bash -c 'sleep 120; echo "job done"' &  # 可以正常执行
[1] 25758
> pstree -ap $$
bash,23897
  ├─bash,25758 -c sleep 120; echo "job done"
  │   └─sleep,25759 120
  └─pstree,25764 -ap 23897

内置命令 disown

nohup的不足之处在于必须在程序运行前指定是否忽略SIGHUP信号, 如果我们希望对运行中的作业进行修改可以使用内置命令disown, 其用法如下:

  • disown %n: 把编号为n的作业从列表中剥离, 这回该作业所有的输出消失, 而且无法进行作业控制.
> ping 127.0.0.1 > /dev/null &
[1] 29748
> disown %1
> jobs  # 没有输出
> ps -f 29748
UID        PID  PPID  C STIME TTY      STAT   TIME CMD
remilia  29748 28309  0 15:10 pts/3    S      0:00 ping 127.0.0.1

# 退出终端并在另一个终端中查看该进程
> ps -f 29748  # 变为孤儿进程继续运行
UID        PID  PPID  C STIME TTY      STAT   TIME CMD
remilia  29748     1  0 15:10 ?        S      0:00 ping 127.0.0.1
  • disown -h %n: 让编号为n的作业忽略退出时产生的SIGHUP, 这种方法不会从作业列表中删除该作业, 因此可以继续使用作业控制命令进行管理.
  • disown -r: 从作业列表中剥离所有运行中的作业.
  • disown -a: 从作业列表中剥离所有作业.

该命令的一个缺点是不会自动进行输出重定向, 如果我们需要保存作业的输出可以使用gdb在程序运行时修改文件描述符的指向.

> python3 logerr.py &
> sudo gdb -p `pgrep python3`
(gdb) p close(2)  # 删除标准错误
$1 = 0
(gdb) p creat("/tmp/pyout", 0600)  # 创建文件自动连接到标准错误
$2 = 2
(gdb) q  # 选 yes

> sudo ls -l /proc/31586/fd/2
l-wx------ 1 remilia remilia 64 Mar 22 15:36 /proc/31586/fd/2 -> /tmp/pyout

选项 huponexit

huponexitbash中的选项, 其用法如下:

> shopt huponexit  # 查看选项是否开启
> shopt -s huponexit  # 开启选项

开启该选项则会让该shell会话中的所有后台作业忽略由该会话退出时执行exit所产生的SIGHUP, 其他方式传递来的SIGHUP则不会被忽略, SIGHUP的传播过程如下:

会话 -- SIGHUP --> bash ( huponexit ) -- SIGHUP --> 作业

如果bash中开启了huponexit, 则在会话退出时不会给子进程(作业)分发SIGHUP信号.

其他方式

我们也可以通过让后台作业变为孤儿进程的方式实现忽略父shell传递来的SIGHUP信号:

# subshell 没有 wait 他自己的后台任务因此提前退出
# 这种方式可以自定义重定向输出文件 但无法进行作业管理
> (ping 127.0.0.1 >/dev/null &)
> ps -f `pgrep ping`
UID        PID  PPID  C STIME TTY      STAT   TIME CMD
remilia    847     1  0 15:49 pts/4    S      0:00 ping 127.0.0.1

另外还有一些工具如screen, tmux, dtach等可以实现更高级的功能.

参考内容

高级Bash脚本编程指南
How To Use Bash's Job Control
Redirecting Output from a Running Process


1996scarlet
22 声望6 粉丝