← 中文资源

简体中文版本

Python — 解释器退出时因非守护线程挂起

找出 Python 在停机时等待非守护线程的原因,定位永不结束的线程,并在程序退出时带超时 join。

阅读英文原文

症状

  程序每件事都做对了,除了停下来。问运行时它在等谁: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")