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_struct的signal变量中, 该变量的类型为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
(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 "
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=1的init进程:
> (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不仅可以捕捉定义在中的信号名或者数值, 还支持以下用法:
trap -l: 类似于kill -l, 用于列出当前系统支持的所有信号.
trap -p或trap: 列出通过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中捕获SIGINT和SIGQUIT时最好按照如下方式:
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 将立即退出
参考内容