有个现象第一次遇到时很容易把人看懵:

程序明明还活着,后台线程也还在跑,但 ThreadPoolExecutor.submit() 却突然报错:

RuntimeError: cannot schedule new futures after interpreter shutdown

直觉上,这像是在说“Python 解释器都要退出了,你还提交什么任务”。可问题是,进程并没有退出。如果此时还有别的非 daemon 线程在运行,整个进程甚至还能继续存活好一会儿。

这篇文章想讲清楚的,就是这个看上去很矛盾的问题背后到底发生了什么。

一句话结论

CPython 3.9+ 中,concurrent.futures.thread 的模块级全局变量 _shutdown 绑定的是主线程退出时机,而不是“整个进程何时真正结束”。一旦主线程先退出,后续再调用 ThreadPoolExecutor.submit(),就可能直接命中 interpreter shutdown 分支。

适用范围:本文讨论的是 CPython 3.9+ 在特定退出时序下的行为,不等同于所有 ThreadPoolExecutor shutdown 场景。


一、现象

程序启动后,poller 线程立刻报错:

RuntimeError: cannot schedule new futures after interpreter shutdown

矛盾点在于:进程明明还活着,为什么 ThreadPoolExecutor 认为“解释器已关闭”?


二、根因定位

CPython 3.9+ 中,concurrent.futures.thread 模块里有一个模块级全局变量 _shutdown

当主线程进入关闭路径时,threading._shutdown() 会执行已注册的 _python_exit(),将这个全局变量设为 True。此后,所有 executor 的 submit() 都会拒绝接受新任务。

核心认知只有一句:

这里的 _shutdown 绑定的是主线程生命周期,而不是进程生命周期。

也就是说,哪怕进程因为别的非 daemon 线程还活着,只要主线程先结束,ThreadPoolExecutor 也可能已经进入“全局拒绝提交”的状态。


三、触发条件

这个问题通常在下面三个条件同时成立时触发:

  1. main() 不阻塞,启动完后台服务就直接返回
  2. 进程里还有非 daemon 线程继续运行,比如某个 SDK 自己拉起的后台线程
  3. 某个 daemon 线程在主线程退出之后,才去调用 ThreadPoolExecutor.submit()

典型结构大致长这样:

def main():
    consumer.start()       # 非阻塞,内部拉起非 daemon 线程后就返回
    poller_thread.start()  # daemon=True

# main() return -> 主线程结束 -> _python_exit() -> _shutdown = True
# 但进程仍存活(SDK 的非 daemon 线程撑着)
# poller 再调用 submit() -> 发现 _shutdown == True -> 抛 RuntimeError

这个问题最容易误导人的地方就在于:

  • 从操作系统角度看,进程还在
  • 从业务角度看,后台线程也还在
  • 但从 ThreadPoolExecutor 的实现角度看,解释器已经进入了“不再接收新任务”的关闭状态

四、时序图

主线程              Poller(daemon)         SDK线程(非daemon)
  |                     |                        |
  | start poller        |                        |
  |-------------------->|                        |
  | consumer.start()    |                        |
  |--------------------------------------------->|
  |                     |                        |
  | return(主线程退出)|                        |
  |---X                 |                        |
  |                     |                        |
[threading._shutdown()] |                        |
[_python_exit()]        |                        |
[_shutdown = True]      |                        |
                        |                        |
                        | pool.submit(...)       |
                        | -> RuntimeError!       |
                        |                        |
                        |                   (进程仍存活)

从这个时序图就能看出问题的关键:

“主线程退出” 和 “整个进程退出” 在 Python 里并不是同一个时刻,而 ThreadPoolExecutor 的全局关闭判断锚定的是前者。


五、源码链路追踪(CPython 3.11)

5.1 全局 _shutdown 标志是如何被设上的

concurrent.futures.thread 里的关键逻辑如下:

# Lib/concurrent/futures/thread.py

_threads_queues = weakref.WeakKeyDictionary()
_shutdown = False
_global_shutdown_lock = threading.Lock()

def _python_exit():
    global _shutdown
    with _global_shutdown_lock:
        _shutdown = True
    items = list(_threads_queues.items())
    for t, q in items:
        q.put(None)   # 给每个 worker 发送退出信号
    for t, q in items:
        t.join()      # 等待 worker 退出

# 注意:这里不是标准 atexit,而是 threading 内部的注册机制
import threading
threading._register_atexit(_python_exit)

要点有两个:

  • _shutdown 是模块级全局变量,不是某个 executor 实例自己的状态
  • _python_exit() 是注册到 threading 关闭路径上的,不是“等所有线程都跑完以后再触发”

5.2 _register_atexit 的触发时机

threading.py 里可以看到这条链路:

# Lib/threading.py

_threading_atexits = []

def _register_atexit(func):
    _threading_atexits.append(func)

def _shutdown():
    # 在主线程关闭路径中被调用
    ...
    for atexit_call in reversed(_threading_atexits):
        atexit_call()   # _python_exit() 在这里执行
    ...

更稳妥的理解方式是:

threading._shutdown() 运行在解释器/线程关闭路径上,它绑定的锚点是主线程退出,而不是“进程里最后一个非 daemon 线程何时结束”。

5.3 submit() 的三层防御检查

ThreadPoolExecutor.submit() 的关键逻辑如下:

# Lib/concurrent/futures/thread.py

class ThreadPoolExecutor:
    def submit(self, fn, /, *args, **kwargs):
        with self._shutdown_lock, _global_shutdown_lock:
            if self._broken:
                raise BrokenThreadPool(...)
            if self._shutdown:
                raise RuntimeError('cannot schedule new futures after shutdown')
            if _shutdown:
                raise RuntimeError(
                    'cannot schedule new futures after interpreter shutdown')

三层检查分别对应三种不同失败原因:

层级变量含义
1self._brokenexecutor 实例自身损坏
2self._shutdown用户显式调用了 executor.shutdown()
3_shutdown模块级全局标志,主线程退出时被设为 True

本文里的报错,命中的正是第 3 层

也就是说,这和你有没有手动调用过 executor.shutdown(),其实没有关系。

5.4 一个更隐蔽的变种

还有一种更容易误导人的情况:

def poller_loop():
    time.sleep(10)
    pool = ThreadPoolExecutor(4)   # __init__ 不检查 _shutdown
    pool.submit(work)              # 但 submit 立刻爆炸

这里最“坑”的点在于:

ThreadPoolExecutor 对象本身可以成功创建,因为 __init__ 不检查全局 _shutdown;但第一次真正 submit() 时,才会发现已经进入 interpreter shutdown 状态。

这会制造一种错觉:对象创建看起来没问题,好像线程池还活着;实际上一提交任务就失败。


六、版本演变

这个问题不是所有 Python 版本都以同样方式表现出来。

版本行为关键变更
≤ 3.8_python_exit() 只 join 已有 worker,不设全局 flagsubmit() 不报错,但 worker 可能被解释器强杀,出现静默丢任务
3.9引入全局 _shutdown 标志,submit() 主动检查并抛 RuntimeError本文问题从这里开始,见 bpo-39812
3.11-3.14内部实现继续演化,如全局 shutdown 锁、register_at_fork 处理等外部行为不变,daemon 线程场景仍受影响
3.15(计划中)维护者已表示有望修复该误杀问题cpython #148280

结论可以概括为:

  • 3.9+ 均受影响
  • 3.8 及以前虽然不报这个错,但并不更安全,反而可能静默丢任务

七、官方已按 Bug 处理

这个问题已经有对应的官方 issue:

cpython #148280: ThreadPoolExecutor marks interpreter as shutdown before non daemon threads exit

目前可以确认的信息包括:

  • 状态是 Open
  • 已被 triage,并标记为 type-bug
  • 已打上 3.15 标签
  • 维护者 ZeroIntensity 明确表示:在 3.15 中,随着 threading 关闭行为的演进,理论上应该可以安全地允许这类场景继续创建 futures

issue 报告者给出的核心期望也很直接:

“All non daemon threads should be able to use ThreadPoolExecutor until they exit.”

所以,至少可以确定两件事:

  1. 这个问题已经被官方按 bug 处理,而不是简单归类为“使用者误用”
  2. 截至目前,它仍未在现有稳定版本中修复

八、为什么经历了多个版本仍未修复

8.1 历史根源:bpo-39812

2020 年,Antoine Pitrou 提出 bpo-39812: Avoid daemon threads in concurrent.futures,随后通过 PR #19149 合入 3.9 主线,动机大致有三点:

  1. Python 3.9 开始收紧子解释器中的 daemon 线程行为
  2. 标准库自身也需要去 daemon 化,避免和新约束冲突
  3. 因而要用 threading._register_atexit() 替代 atexit.register()

问题也正是在这里被引入:

_python_exit() 的调用时机,从“更靠近进程真正退出前”提前到了“主线程退出后、等待非 daemon 线程之前”。于是 _shutdown = True 变得更早生效,最终误伤了仍在合法工作的线程。

8.2 维护者长期倾向:安全关闭优先

Victor Stinner 在讨论 daemon 线程和解释器关闭问题时,给过一个很有代表性的判断:

“Daemon threads must die. That’s a first step towards their death!”

如果粗略看这条路线,大致会是这样:

3.9    子解释器中禁止 daemon 线程
3.9    concurrent.futures 去 daemon 化(bpo-39812 / PR #19149)
3.11+  相关实现继续演化,但对外行为基本不变
3.15   #148280 已进入修复讨论
未来   daemon 线程机制继续被收紧

8.3 修复为什么难:safety vs. liveness

本质上这是一个取舍问题:

       Safety(安全关闭)                   Liveness(正确性)
  防止 daemon 线程在解释器              非 daemon 线程应能继续
  终结后访问已释放状态 -> 崩溃           使用 executor 直到自身退出
              ↑                                  ↑
              └──────── 当前实现更偏向 Safety ────────┘

两边的代价分别是:

  • 不设 _shutdown = True:daemon worker 可能在解释器关闭后继续跑,存在更严重崩溃风险
  • _shutdown = True:合法线程会被过早拒绝,触发本文里的 RuntimeError

从当前实现来看,CPython 的选择明显更偏保守:

宁可提前拒绝 submit(),也不愿在解释器关闭阶段冒更严重的崩溃风险。


九、跨语言对比

把这个问题放到不同语言的运行时模型里看,会更容易理解它为什么显得别扭。

语言线程池关闭机制与主线程的关系设计选择
JavaExecutorService.shutdown() 由用户显式调用线程池生命周期不绑定 main 线程JVM 等待非 daemon 线程,线程池通常由使用者显式管理
Go没有标准库级线程池概念,goroutine 由 runtime 统一调度main() 返回后,进程立刻退出直接消灭“main 走了但进程还活着”的中间状态
PythonThreadPoolExecutor 受模块级 _shutdown 影响主线程退出后可能全局作废允许中间状态存在,但又在该状态下做全局清理

从工程视角看,Python 这里最尴尬的点在于:

  • 它不像 Go 那样,直接禁止这种中间状态
  • 也不像 Java 那样,把线程池生命周期交给使用者显式管理
  • 它是既允许“主线程退出但进程仍活着”,又在这个阶段执行会影响全局 executor 的清理动作

这正是本文这个陷阱之所以反直觉的原因。


十、更深一层:从工程视角看,这暴露了哪些结构性问题

这个 bug 并不只是一个边角料实现问题。至少从工程使用者的视角看,它暴露了 CPython 并发模型里的三个结构性问题。

1. “主线程即程序”的隐含假设

CPython 很多关闭逻辑,仍然把主线程当成程序生命周期的主要锚点。

但在现代服务里,实际业务生命周期未必由主线程决定。比如:

  • gRPC server
  • Kafka consumer
  • 第三方 SDK 自带的后台线程
  • 其他把“启动后即返回”当成常态的框架

在这些场景里,“主线程结束”并不天然等于“系统已经没事可做”。

2. 全局可变状态过于粗粒度

_shutdown 是模块级全局变量,没有 per-executor 粒度,也没有更细的生命周期边界。

一旦这个 flag 翻转,所有 ThreadPoolExecutor 都一起失效,不管它们是不是仍在被合法使用。

3. daemon 线程语义存在割裂

文档层面的承诺是:

daemon 线程不阻止进程退出

但这里的实际行为更像是:

daemon 线程在主线程退出后,会被视为“不再值得继续支持”的执行体

这两者并不是同一个语义层级。

  • 前者讨论的是进程生命周期
  • 后者讨论的是执行合法性

当这两层被混在一起时,就会出现本文这种“进程还活着,但线程池已经死了”的违和感。

一句话总结:

这不仅是一个 API bug,也折射出 CPython 长期以来对多线程场景的保守处理方式:单线程是默认假设,多线程能力是逐步补齐的,而 daemon 线程这条“逃生口”也在持续被收紧。


十一、修复方案

方案 A:保持主线程存活

这是最直接、通常也最稳的方案。

def main():
    consumer.start()
    poller_thread.start()
    threading.Event().wait()   # 主线程阻塞,不触发 _python_exit()

适用场景:

  • 仍然需要 ThreadPoolExecutor
  • 想保留并发能力
  • 不想重写现有处理逻辑

方案 B:去掉 ThreadPoolExecutor,改为顺序执行

如果任务量很小,并发本来就不是刚需,那么最简单的修复方式反而是“别用线程池”。

def tick(self) -> int:
    snapshots = self._get_pending_tasks()
    if not snapshots:
        return 0
    for snap in snapshots:
        self._process_one_safe(snap)
    return len(snapshots)

适用场景:

  • 任务量不大
  • 性能压力有限
  • 代码简单可靠比并发收益更重要

方案 C:把 poller 改成非 daemon 线程

poller_thread = threading.Thread(target=poller_loop, daemon=False)

这个方案理论上可行,但通常不推荐

原因在于:它会改变进程退出行为,可能导致 Ctrl+C 不能像以前那样自然结束,往往还要额外补信号处理、优雅停止逻辑和 join 策略。

方案选择建议

场景推荐方案
需要并发,且希望简单可靠A
任务量小,并发不是刚需B
对退出行为有严格控制能力C,但要配合完整信号处理

十二、最小复现脚本

下面是一个最小复现:

import concurrent.futures.thread as cf_thread
import threading
import time
from concurrent.futures import ThreadPoolExecutor

def mock_poller():
    """模拟 daemon 线程在主线程退出后使用 executor"""
    time.sleep(0.3)
    print(f"_shutdown = {cf_thread._shutdown}")
    pool = ThreadPoolExecutor(max_workers=2)
    try:
        f = pool.submit(lambda: "ok")
        print(f"submit 成功: {f.result()}")
    except RuntimeError as e:
        print(f"submit 失败: {e}")

def sdk_background_thread():
    """模拟非 daemon 线程保持进程存活"""
    time.sleep(5)

def main():
    threading.Thread(target=mock_poller, daemon=True).start()
    threading.Thread(target=sdk_background_thread, daemon=False).start()

if __name__ == "__main__":
    main()   # main 返回 -> 主线程退出 -> _shutdown = True

预期输出:

_shutdown = True
submit 失败: cannot schedule new futures after interpreter shutdown

如果你之前打印的是 threading._shutdown,要注意那是 threading 模块里的一个函数,不是 concurrent.futures.thread 里的布尔标志;真正需要观察的是 cf_thread._shutdown


参考链接