Linux 中 Python 正确处理 Ctrl-C 的方式
结论
在 Linux 上,如果要让 Bash 把 Python 进程识别为“被 SIGINT 终止”,进程最终必须收到 SIGINT 并因此结束。调用 sys.exit() 仍属于正常退出。
Python 3.8 及以上可以使用 signal.raise_signal(),它比直接调用 os.kill() 更直观:
signal.signal(signal.SIGINT, signal.SIG_DFL)
signal.raise_signal(signal.SIGINT)
这两行代码先恢复 SIGINT 的默认处理方式,再向当前进程发送 SIGINT。Python 不会输出 KeyboardInterrupt 的 traceback,Bash 也能正确识别这次中断。
推荐写法
在程序最外层捕获 KeyboardInterrupt,输出提示并完成清理后,再次发送信号:
#!/usr/bin/env python3
import signal
import sys
import time
def main():
while True:
print("Running...", flush=True)
time.sleep(10)
try:
main()
except KeyboardInterrupt:
print("Ctrl-C pressed!", file=sys.stderr, flush=True)
# 在这里执行必要的清理操作
signal.signal(signal.SIGINT, signal.SIG_DFL)
signal.raise_signal(signal.SIGINT)
执行顺序如下:
- 用户按下 Ctrl-C,终端向前台进程组发送
SIGINT。 - Python 默认把
SIGINT转换为KeyboardInterrupt。 - 程序捕获异常,输出提示并完成清理。
- 程序将
SIGINT的处理方式恢复为系统默认值。 signal.raise_signal(signal.SIGINT)再次向当前进程发送信号。- 进程由
SIGINT终止,Python 不会输出 traceback。
对于普通的 Bash 前台作业,Bash 可以据此判断子进程确实收到了 SIGINT,并停止当前循环。
为什么 sys.exit(130) 不等价
sys.exit() 会抛出 SystemExit。进程仍是正常退出,只是返回了非零退出码:
sys.exit(130)
操作系统会区分以下两种状态:
- 进程正常退出,退出码为
130 - 进程被
SIGINT终止
Bash 通常把进程被信号 N 终止后的状态显示为 128 + N,因此 SIGINT 对应的 $? 往往也是 130。不过,Bash 在等待子进程时仍能区分“正常返回 130”和“被 SIGINT 终止”。
下面的循环没有检查命令的退出码:
while true; do
./python_script.py
done
无论 Python 调用 sys.exit(1)、sys.exit(130),还是返回其他非零退出码,循环条件始终是 true,所以还会继续执行下一轮。
如果可以修改 Bash,可以显式检查退出码:
while true; do
./python_script.py
status=$?
if (( status == 130 )); then
break
fi
done
也可以让循环在命令返回任何非零状态时停止:
while true; do
./python_script.py || break
done
这两种写法都由调用方决定如何处理中断,而且仅凭退出码无法严格区分 sys.exit(130) 和真正的 SIGINT。
为什么直接 raise 会显示 traceback
在 except KeyboardInterrupt 中单独使用 raise,会重新抛出当前的 KeyboardInterrupt:
except KeyboardInterrupt:
raise
异常传播到 Python 顶层后,解释器会把它当作未处理异常并输出 traceback。外层执行流程会停止,但终端上会留下异常堆栈。
重新发送 SIGINT 时,信号处理方式已经恢复为 SIG_DFL,因此 KeyboardInterrupt 不会再次进入 Python 的异常处理流程。
兼容旧版 Python
signal.raise_signal() 从 Python 3.8 开始提供。需要兼容更早的版本时,可以改用 os.kill():
import os
import signal
signal.signal(signal.SIGINT, signal.SIG_DFL)
os.kill(os.getpid(), signal.SIGINT)
这段代码的含义相同:实际向进程发送信号,让操作系统把进程记录为由信号终止。signal.raise_signal() 为这个操作提供了更直接的 Python 接口。
注意事项
应在程序最外层捕获 KeyboardInterrupt。如果业务代码在内部捕获并吞掉这个异常,上层调用者就无法知道中断已经发生。
保存数据、关闭连接和删除临时资源等清理操作,要在重新发送 SIGINT 之前完成。在正常的 Linux 信号配置下,调用 signal.raise_signal(signal.SIGINT) 后进程会立即终止,后续代码不应再负责清理。
如果程序修改了线程信号掩码、运行在进程管理器中,或者 Bash 配置了自定义 trap,行为可能有所不同。这里的写法适用于普通 Linux 终端中的前台 Python 进程和 Bash 脚本。
备注:内容仅供参考。
