PAYATHON 2026

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)

执行顺序如下:

  1. 用户按下 Ctrl-C,终端向前台进程组发送 SIGINT。
  2. Python 默认把 SIGINT 转换为 KeyboardInterrupt。
  3. 程序捕获异常,输出提示并完成清理。
  4. 程序将 SIGINT 的处理方式恢复为系统默认值。
  5. signal.raise_signal(signal.SIGINT) 再次向当前进程发送信号。
  6. 进程由 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 脚本。

备注:内容仅供参考。