症状
程序每件事都做对了,除了停下来。问运行时它在等谁:threading.enumerate() 在 main 结束后仍列出非守护 worker,那正是停机序列在 join 的线程。再用 py-spy dump 看它歇在哪一行——无超时的 recv、无超时的 queue.get、无退出条件的 sleep 循环是三大经典。Ctrl-C 到达主线程但进程仍不终止,是另一个高辨识度信号:KeyboardInterrupt 只中断主线程的 join 等待,worker 依旧阻塞。
诊断思路
先枚举线程看 daemon 标志与名字;再用 py-spy dump 拿到每个线程的驻留行(无需改动程序,这是它最大的价值);最后 grep 所有不带 daemon 的 Thread(target=…) 构造,它们就是解释器会一直等的对象。轮询循环没有接到停机状态的退出条件、worker 循环里吞掉 KeyboardInterrupt,都是让“永不结束”固化的写法。
根因
CPython 在解释器收尾前会 join 所有非守护线程。一个无限期阻塞在 I/O 或无退出条件循环里的 worker,会在主线程完成之后把进程无限期留住——所有真实工作都已完成,进程却表现为挂死。
修复与验证
让停机变成协作式:取消通过 threading.Event 传播,阻塞调用全部有界——queue.get(timeout=1) 每秒醒来复查 stop 标志,socket 设超时或用 selectors 让读操作能醒来重查;退出时 stop.set() 后 join(timeout=10),超时仍存活就告警而不是无限等。真正辅助性的线程(心跳、指标刷写)才标 daemon=True——守护线程在解释器退出时被当场击杀、不释放锁不完成写入,有副作用的工件绝不能走这条路。验证时触发每条退出路径(正常完成、Ctrl-C、SIGTERM 处理器),断言进程在固定时限内终止且退出码正确。
代码示例
import threading
for t in threading.enumerate():
print(t.name, t.daemon, t.native_id)
#MainThread True 4211
#worker-poller False 4264 <- the interpreter is joining this
py-spy dump --pid 4211
# Thread worker-poller: queue.get (blocking, no timeout)
stop = threading.Event()
def worker():
while not stop.is_set():
try:
item = q.get(timeout=1.0)
except queue.Empty:
continue # re-checks stop every second
process(item)
# at exit, in the main thread:
stop.set()
t.join(timeout=10)
if t.is_alive():
log.warning("worker did not stop in 10s")