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 将立即退出
参考内容
Signal Trap
How "Exit Traps" Can Make Your Bash Scripts Way More Robust And Reliable