「程序结束时会做清理」这句话在很多退出方式下并不成立。这篇用 10 种方式真的把进程结束掉,每次都检查四件事有没有发生:atexit 回调、finally、对象的 __del__、以及缓冲区里还没刷出去的 print。
t.py(节选) atexit.register(lambda: mark("atexit")) class Res: def __del__(self): mark("__del__") r = Res() # 模块级对象 print("缓冲区里的一行 print (没有 flush)", end="") # stdout 是管道: 块缓冲 try: ... # 按参数:正常结束 / 抛异常 / sys.exit / os._exit / os.abort / 睡眠等信号 finally: mark("finally")
mark() 直接用 os.write 往文件里写一行(不经过缓冲),这样能可靠判断清理代码有没有跑。信号类的场景由父进程等脚本睡起来之后再发信号。「缓冲输出」列看的是父进程有没有收到那行没 flush 的 print。
退出方式 退出码 atexit finally __del__ 缓冲输出 正常执行到末尾 0 ✓ ✓ ✓ ✓ 未捕获的异常 1 ✓ ✓ ✓ ✓ sys.exit(3) 3 ✓ ✓ ✓ ✓ os._exit(4) 4 ✗ ✗ ✗ ✗ os.abort() 信号6 ✗ ✗ ✗ ✗ 子线程里未捕获的异常 0 ✓ ✓ ✓ ✓ 收到 SIGINT(Ctrl+C) 信号2 ✓ ✓ ✓ ✓ 收到 SIGTERM(默认处理) 信号15 ✗ ✗ ✗ ✗ 收到 SIGTERM, 自己注册了 handler → sys.exit 0 ✓ ✓ ✓ ✓ 收到 SIGKILL 信号9 ✗ ✗ ✗ ✗
| 退出方式 | 退出码 | atexit | finally | __del__ | 缓冲输出 |
|---|---|---|---|---|---|
| 正常执行到末尾 | 0 | ✓ | ✓ | ✓ | ✓ |
| 未捕获的异常 | 1 | ✓ | ✓ | ✓ | ✓ |
| sys.exit(3) | 3 | ✓ | ✓ | ✓ | ✓ |
| os._exit(4) | 4 | ✗ | ✗ | ✗ | ✗ |
| os.abort() | 信号 6 | ✗ | ✗ | ✗ | ✗ |
| 子线程里未捕获的异常 | 0 | ✓ | ✓ | ✓ | ✓ |
| 收到 SIGINT(Ctrl+C) | 信号 2 | ✓ | ✓ | ✓ | ✓ |
| 收到 SIGTERM(默认处理) | 信号 15 | ✗ | ✗ | ✗ | ✗ |
| 收到 SIGTERM, 自己注册了 handler → sys.exit | 0 | ✓ | ✓ | ✓ | ✓ |
| 收到 SIGKILL | 信号 9 | ✗ | ✗ | ✗ | ✗ |
列表按「能不能干净收尾」可以分成两类:
sys.exit、子线程异常、SIGINT、被 handler 接住的 SIGTERM。它们最终都经过 Python 的解释器关闭流程,atexit、finally、__del__ 和缓冲区刷新都发生了。os._exit、os.abort、默认处理的 SIGTERM、SIGKILL。进程被系统直接终结,连那一行缓冲的 print 也丢了。KeyboardInterrupt 而走完清理;但 kill 进程号(默认发的就是 SIGTERM)不会——它直接终止进程,finally 和 atexit 都不执行。所以想在服务被 kill 时收尾,必须自己 signal.signal(SIGTERM, ...) 并调用 sys.exit——本次「带 handler」那一行证明这样做后清理全部恢复。子线程的异常不会让进程失败。一个线程里除零,回溯被打印,但主线程继续、进程退出码是 0——依赖退出码判断成败的调度系统会被骗过。
SIGINT 的退出状态。清理都执行了,但进程最终是「被信号 2 终止」的(退出状态 -2),而不是普通的退出码——subprocess 报告的返回码是 -2,即「被信号 2 终止」。
atexit、signal、os、subprocessSIGHUP / SIGQUIT、多线程非守护线程运行时收到信号、被 OOM killer 杀掉(与 SIGKILL 同类,没有单独测)