容器被停止、服务被重启,收到的第一个信号通常是 SIGTERM。第 90 篇讲了「哪些退出方式不会清理」;这篇更进一步:一个真的在处理任务的服务,收到 SIGTERM 之后到底该怎么做,才能既不丢在途任务、又不会「怎么也停不下来」?六种做法各跑一遍,把结局摆在一起。
svc.py(核心) def worker(): while not stop.is_set(): # 每做完一个任务检查一次停止标志 try: i = jobs.get(timeout=0.05) except queue.Empty: return work(i) # 每个 0.3 秒 atexit.register(cleanup) # 关闭连接 / 刷盘 # 六种模式对 SIGTERM 的处理: # default 不注册处理函数 # flag handler 里 stop.set() # exit_nondaemon handler 里 sys.exit(0),工作线程是普通线程 # exit_daemon handler 里 sys.exit(0),工作线程是守护线程 # flag_stuck handler 里 stop.set(),但 job 3 永远卡住 # flag_watchdog handler 里 stop.set() + Timer(1.0, os._exit(1))
父进程在服务处理 job 3 的时候(0.75 秒)发出 SIGTERM,记录进程什么时候、以什么状态结束,以及日志里留下了什么。日志由服务自己用 os.write 直接写文件(不经缓冲),时间是相对服务启动的秒数。
SIGTERM 发出后 0.00 秒进程结束 | 被信号 15 终止 | 自己退出
0.000 job 1 开始
0.305 job 1 完成
0.306 job 2 开始
0.610 job 2 完成
0.610 job 3 开始没有注册任何处理函数时,SIGTERM 直接终止进程(被信号 15 终止):job 3 只有「开始」没有「完成」,cleanup 没有执行(见第 90 篇)。服务什么都来不及做。
SIGTERM 发出后 0.18 秒进程结束 | 退出码 0 | 自己退出
0.000 job 1 开始
0.305 job 1 完成
0.306 job 2 开始
0.608 job 2 完成
0.609 job 3 开始
0.764 收到 SIGTERM: 只设置停止标志
0.913 job 3 完成
0.914 worker 线程结束, main 返回
0.914 cleanup: 关闭连接 / 刷盘 (已完成 3 个, 队列里还剩 17 个未处理)信号处理函数里只做一件最小的事:设置一个 threading.Event。工作线程做完 job 3(0.914 秒完成),下一轮循环检查到标志就退出;main 返回,atexit 里的 cleanup 执行,进程以 退出码 0 退出,从收到信号到退出 0.18 秒。已完成的 3 个任务是完整的,队列里剩下的 17 个可以在下次启动时继续。注意:信号处理函数只在主线程执行,所以工作线程要靠共享的 Event 得知要停。
SIGTERM 发出后 3.01 秒进程结束 | 被信号 9 终止 | 由父进程 SIGKILL(等了 3.0 秒仍未退出)
0.000 job 1 开始
0.305 job 1 完成
0.306 job 2 开始
0.611 job 2 完成
0.611 job 3 开始
0.753 收到 SIGTERM: 处理函数里直接 sys.exit(0)
0.913 job 3 完成
0.914 job 4 开始
1.219 job 4 完成
1.219 job 5 开始
1.521 job 5 完成
1.521 job 6 开始
1.826 job 6 完成
1.827 job 7 开始
2.132 job 7 完成
2.132 job 8 开始
2.437 job 8 完成
2.437 job 9 开始
2.741 job 9 完成
2.742 job 10 开始
3.043 job 10 完成
3.043 job 11 开始
3.347 job 11 完成
3.347 job 12 开始
3.652 job 12 完成
3.652 job 13 开始一种看起来很自然的写法:在 handler 里直接 sys.exit(0)。但 SystemExit 只在主线程里抛出;解释器退出时要等待所有非守护线程结束,而工作线程完全不知道要停,继续处理 job 4、5、6……。日志显示 SIGTERM 之后它一直在干活;最后是父进程等了 3.0 秒后发 SIGKILL 才终结它(被信号 9 终止),cleanup 没有执行,job 13 被中途杀掉。这就是很多服务「stop 之后还得等超时被强杀」的原因之一。
SIGTERM 发出后 0.02 秒进程结束 | 退出码 0 | 自己退出
0.000 job 1 开始
0.303 job 1 完成
0.304 job 2 开始
0.609 job 2 完成
0.609 job 3 开始
0.760 收到 SIGTERM: 处理函数里直接 sys.exit(0)
0.760 cleanup: 关闭连接 / 刷盘 (已完成 2 个, 队列里还剩 17 个未处理)把工作线程设成守护线程后,同样的 sys.exit(0) 让进程在 0.02 秒内退出、退出码 0,cleanup 也执行了——但 job 3 被丢弃(只有「开始」),cleanup 里的「已完成 2 个」也能印证。守护线程在解释器退出时被直接丢掉,不会有机会做完手头的事。
SIGTERM 发出后 3.01 秒进程结束 | 被信号 9 终止 | 由父进程 SIGKILL(等了 3.0 秒仍未退出)
0.000 job 1 开始
0.305 job 1 完成
0.305 job 2 开始
0.611 job 2 完成
0.611 job 3 开始
0.765 收到 SIGTERM: 只设置停止标志标志设置了,但 job 3 卡在下游调用里(这里用 sleep(60) 模拟),工作线程根本走不到「检查标志」那一步。进程一直不退出,直到父进程等了 3.0 秒后发 SIGKILL(被信号 9 终止),cleanup 没有执行。优雅关闭不能只靠对方的配合。
SIGTERM 发出后 1.06 秒进程结束 | 退出码 1 | 自己退出
0.000 job 1 开始
0.300 job 1 完成
0.301 job 2 开始
0.606 job 2 完成
0.606 job 3 开始
0.754 收到 SIGTERM: 设置停止标志, 并启动 1.0 秒的看门狗
1.760 看门狗超时: os._exit(1)在 handler 里除了设置标志,再启动一个 threading.Timer(1.0, os._exit(1)):在这 1 秒的宽限期里,能做完的任务会做完;卡死的话,宽限期一到就强制退出——SIGTERM 发出 1.06 秒后进程结束,退出码 1(表明「不是正常收尾」),而不是等到编排系统的 SIGKILL。代价是 os._exit 不会执行 cleanup;要在强退前做最关键的收尾,得在 Timer 的回调里显式写。宽限期应当小于编排系统的强杀等待时间——这一点我没有验证具体的编排系统默认值。
Event),不要在里面做重活,也不要直接 sys.exit。atexit/finally,并且要知道 os._exit 和 SIGKILL 会跳过它们。signal、threading、atexit、subprocess;所有时间取自服务自己的 time.perf_counter,数字每次略有不同loop.add_signal_handler、多进程 worker 的信号传递、Docker / Kubernetes 的宽限期默认值与发送信号的具体行为、SIGINT 与 SIGTERM 同时处理