bash中的信号处理机制

Linux 中的信号

信号(Signal)是操作系统中常用的进程通信手段, 主要用来描述特定事件的发生, 进程接收到信号时有以下几种处理方式:

  • 捕获并自定义处理函数: 给signal系统调用传递自定义回调函数, 进程在接收到信号时会执行该回调函数.
  • 忽略信号: 给signal系统调用传递SIG_IGN, 内核会直接丢弃该信号, 因此目标进程不会收到该信号.
  • 执行默认操作: 内核对每个信号定义了默认的处理方式, 如果已经给信号设置了忽略或自定义处理函数, 可以给signal系统调用传递SIG_DFL将该信号的处理方式恢复为默认.
signal(SIGINT, SIG_IGN);  // 忽略信号
signal(SIGTERM, SIG_DFL);  // 恢复信号
signal(SIGSTOP, m_handler);  // 自定义信号

Linux中, 信号的发送依赖于sigqueue, kill, raise系统调用, 信号的处理状态被记录在目标进程的task_structsignal变量中, 该变量的类型为sigset_t, 每一位存储一个信号的处理状态, 因此又被称为信号位图(signal bitmap). 处于产生(generate)和递送(delivery)之间的信号状态会被标记为未决(pending). 当目标进程短时间内接收到大量重复的信号或使用sigpending系统调用阻塞某个信号时, 目标进程信号位图中该信号的状态就会变为未决. 在Linux 5.3.0中对处于未决状态的信号有两种处理策略:

  • 丢弃: 如果目标进程中某种信号的状态为未决或忽略, 内核会直接丢弃之后产生的所有该种信号, 直到该信号的状态改变. 这是早期的Unix系统中的处理策略, 为了保证兼容性, Linux 5.3.0中编号为1-31的信号遵循丢弃处理策略, 即使用sigqueue发送这些信号, 它们也不会去排队. 因存在丢失信号的可能, 按丢失策略处理的信号被称为不可靠信号, 按照POSIX则被称为非实时信号.
  • 排队: 在Linux中对信号的处理方式做了改进, 内核会在目标进程的task_struct的中维护一个信号队列, 如果内核接收到了一个信号, 且目标进程中该信号的状态为未决, 则会将这个新产生的信号放入目标进程的信号队列中, 这样只要挂起的信号个数没有超过内核设定的上限, 理论上就不会丢失. Linux 5.3.0中编号为32-64的信号遵循排队处理策略, 因此又被称为可靠信号, 按照POSIX则被称为实时信号.

常用信号

大多数Linux发行版可以通过man 7 signal查看当前系统中支持的信号种类, kill -l可以查看所有信号及其对应的数字. 其中我们常用的有:

  • (2) SIGINT: 给处在前台的正在运行的进程发送的键盘中断信号, 终止(interrupt)其运行, 一般对应Ctrl + C.
  • (19) SIGSTOP: 不可忽略的暂停信号, 是一种以编程方式发送的信号.
  • (20) SIGTSTP: 暂停信号, 将当前任务stop并放到后台, 把控制权交给shell, 一般对应Ctrl + Z
  • (9) SIGKILL: 不可被被阻塞, 处理和忽略的信号, 一般用于强行杀死某个进程, kill -9 <pid>
  • (15) SIGTERM: 程序结束(terminate)信号, 与SIGKILL不同的是该信号可以被阻塞和处理. 通常用来要求程序自己正常退出. shell命令kill缺省产生这个信号。
  • (14) SIGALRM:
  • (1) SIGHUP: 本信号在用户终端连接(正常或非正常)结束时发出, 通常是在终端的控制进程结束时, 通知同一session内的各个作业, 这时它们与控制终端不再关联。 登录Linux时,系统会分配给登录用户一个终端(Session)。在这个终端运行的所有程序,包括前台进程组和后台进程组,一般都属于这个Session。当用户退出Linux登录时,前台进程组和后台有对终端输出的进程将会收到SIGHUP信号。这个信号的默认操作为终止进程,因此前台进程组和后台有终端输出的进程就会中止。对于与终端脱离关系的守护进程,这个信号用于通知它重新读取配置文件。

在 bash 中处理信号

bash中的一个典型的应用场景是通过内置命令kill向指定进程发送信号, 默认发送SIGTERM信号. 例如: 退出所有名称为chrome的进程:

> kill `pgrep chrome`
> killall chrome
> kill `ps -ef | grep chrome | awk '{ print $2 }'`
> kill `pidof chrome`

此外, 还可以使用trap对信号进行捕捉, 从而实现对特定信号的处理, 其语法如下.

trap [COMMANDS] [SIGNALS]

trap在捕捉到信号之后会执行设置的命令, 这里的命令可以是任何有效的Linux命令,或一个用户定义的函数. 在shell脚本中, trap可以被用于在退出时清除临时文件, 例如:

#!/bin/bash
tempfile=$(mktemp) || exit
trap 'rm -f "$tempfile"' EXIT

另一种经典的用法是在守护进程中, 捕捉SIGHUP并重读配置文件, 例如:

#!/usr/bin/bash

if [ ! -r "$1" ]; then
    echo "Usage: $0 <cfg-file-path>"
    exit
fi

echo "PID: $$"

CONFIG=$1

read_config () { 
    echo "reading cfg from $CONFIG"
    source "$CONFIG"
}

read_config
trap "read_config" HUP

while :
do
    echo "$var"
    sleep 15
done

接下来我们可以在两个终端中进行测试:

# 终端一
> bash remove_temp.sh ./config/cfg1 
PID: 8807
reading cfg from ./config/cfg1
from cfg1
reading cfg from ./config/cfg1
after change

# 终端二
> cat > cfg1 << EOF
var="after change"
EOF
> kill -s HUP 8807

通过trap命令也可以保存与重设信号, 例如:

> trap "printf BOOM" INT
> ^CBOOM
> traps=$(trap)  # 保存信号处理方式
> trap INT  # 重设信号为默认处理方式
> ^C
> eval $traps  # 加载信号处理方法
> ^CBOOM

trap还支持捕捉多个信号, 例如:

#!/usr/bin/bash

trap "echo Boom!" SIGINT SIGTERM

echo $PPID $$
while : # 冒号永远为真 也可作为占位符
do
    sleep 60
done

trap对大小写不敏感,而且可以忽略前缀SIG, 以下写法是等价的:

trap "echo 123" SIGINT
trap "echo 123" INT 
trap "echo 123" 2
trap "echo 123" int 
trap "echo 123" Int

特殊情况

bash在执行外部命令时, 会提高前台任务的信号处理优先级, 当前台任务执行完毕或被终止时, bash才会处理刚才收到的信号, 如下例:

# 终端一
> sleep 100  # 这是个外部命令
# 在阻塞中...

>  # 100秒之后打印了一个空行并显示提示符

# 终端二
# 查看终端一的进程树
> pstree -ap 16003
bash,16003
  `-sleep,17249 100  # 外部命令在子进程中执行
> kill -s INT 16003  # 让终端一的 bash 打印空行

但要注意, 在shell中使用Ctrl + C会对整个进程组发送SIGINT, 因此在上例中如果在终端一中使用Ctrl + C会导致sleep立即结束, 并打印一个空行. 我们也可以把前台任务放到后台来让bash优先处理信号, 但如果bash退出时仍有未完成的后台任务, 则这些任务会变为孤儿进程, 它们的父进程会变为PID=1init进程:

> (sleep 50 & sleep 50 & wait)
# 进程树
bash,15158
  `-bash,7629
      |-sleep,7630 50
      `-sleep,7631 50
> kill 7629
# 进程树
bash,15158
> ps -ef | grep "sleep 50"
remilia  11168     1  0 16:56 pts/2    00:00:00 sleep 50
remilia  11169     1  0 16:56 pts/2    00:00:00 sleep 50
# 第三列是 PPID, 已经变成 1 了

如果需要在进程退出时将后台任务也终止, 则需要记录后台任务的PID并在退出前将这些进程kill:

#!/usr/bin/bash

BPIDARRAY=()

for i in {0..9}; do
  sleep 20 &
  BPIDARRAY[$i]=$!
done
sleep 3
trap "kill `echo ${BPIDARRAY[@]}`" EXIT
wait

其他用法

trap不仅可以捕捉定义在<signal.h>中的信号名或者数值, 还支持以下用法:

  • trap -l: 类似于kill -l, 用于列出当前系统支持的所有信号.
  • trap -ptrap: 列出通过trap设置的信号处理命令.
> trap -p
trap -- 'code' EXIT
trap -- 'echo SIGINT' SIGINT
  • trap "some code" EXIT: 仅在shell中可用的编号为0的特殊信号, 在bash中代表处理所有的退出情况, 而在标准shell中则用于捕捉exit.
> trap "code" EXIT
> ^D  # 退出终端的同时会打开 vscode
  • trap "some code" ERR: 捕捉执行出现错误的命令, 在命令执行出现错误时执行预定义的代码块.
  • trap "some code" DEBUG: 以调试模式执行命令时, 将在每个命令执行前执行预定义的代码块.

注意事项

trap设置只作用于当前进程, 因此要注意除了通过.source执行的脚本都会产生child shell, 这些脚本中不会继承当前shell设置的trap:

> trap "printf book" 2
> ^Cbook
> bash -c "trap -p"  # 无输出
> bash
> ^C  # 对子进程无效

在函数中设置的trap也是全局生效的, 重复设置同一个信号则只有最后一次trap有效. 需要特别注意的是, 在bash脚本中或者非交互式bash shell中捕获SIGINTSIGQUIT时最好按照如下方式:

trap 'rm -f "$tempfile"; trap - INT; kill -s INT "$$"' INT

bash在接收到退出信号时按照WCE (wait and cooperative exit)原则进行处理, 按下Ctrl + C时, 当前进程组都会收到SIGINT, 这样就有以下几种情况(不考虑忽略信号的情况):

  • 前台子进程处理SIGINT:

    • 处理信号然后自己kill, 这样父bash(调用者)就会接收到子进程通过信号非正常退出, 则立即退出当前脚本.
    • 处理信号并用exit正常退出, 这样父bash(调用者)就认为子进程正常执行完毕, 从而继续解释脚本.
  • 前台子进程不处理SIGINT: 这种情况与处理信号并自己kill相似, 父bash(调用者)会立即退出当前脚本.

举例来说, 考虑下面这个脚本:

> cat ping_loop.sh
for i in  `seq 254`;  do
    ping -c 2 "192.168.1.$i"
done

> bash ping_loop.sh
# 如果这样执行, 你需要按 254 次 Ctrl + C 才能完全退出
# 注意这个是 bash 的特性

在上例中, 按下Ctrl + C时, ping先收到SIGINT并处理然后正常退出, 之后bash也会收到SIGINT和上一条命令(ping 192.168.0.1)的退出状态, 发现正常退出因此继续解释之后的命令. 而对于sleep这种对SIGINT信号进行默认处理的命令, 情况则完全不同:

> cat sleep_loop.sh
i=1
while [ "$i" -le 100 ]; do
  printf "%d " "$i"
  i=$((i+1))
  sleep 10
done
echo

> bash sleep_loop.sh
# 如果这样执行, 按一次 Ctrl + C 就可以退出脚本

在上例中, 按下Ctrl + C时, sleep先收到SIGINT并按照默认方式处理, 之后bash也会收到SIGINT和上一条命令(sleep 10)的退出状态, 发现sleep非正常退出因此bash立即退出. 我们可以通过更简单的命令加深理解:

> (ping 192.168.0.1; ping 192.168.0.2)
bash,7744  
 `-bash,24002  
     `-ping,24011 192.168.0.1

> kill -2 24011 24002  # 在另一个终端执行
# 由于 ping 会处理 SIGINT 并返回 0, 表示自己正常退出
# bash 24002 认为 ping 24026 正常退出就解释下一条命令
# 进程树会变成下面这样
bash,7744  
 `-bash,24002
     `-ping,24026 192.168.0.2

> (sleep 50; sleep 50)
bash,7744
  `-bash,26053
      `-sleep,26054 50
> kill -2 26054 26053
# sleep 会以默认方式处理 SIGINT 信号
# 因此 bash 26053 会收到子进程因为 SIGINT 非正常退出的信息
# bash 26053 将立即退出

参考内容

Signal Trap
How "Exit Traps" Can Make Your Bash Scripts Way More Robust And Reliable

阅读 253

推荐阅读
Bash高级编程
用户专栏

结合源码与示例深度剖析bash中的各种复杂机制, 需要读者掌握C语言和操作系统的相关知识.

2 人关注
9 篇文章
专栏主页
目录