有个现象第一次遇到时很容易把人看懵:
程序明明还活着,后台线程也还在跑,但 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+ 在特定退出时序下的行为,不等同于所有
ThreadPoolExecutorshutdown 场景。
一、现象
程序启动后,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 也可能已经进入“全局拒绝提交”的状态。
三、触发条件
这个问题通常在下面三个条件同时成立时触发:
main()不阻塞,启动完后台服务就直接返回- 进程里还有非 daemon 线程继续运行,比如某个 SDK 自己拉起的后台线程
- 某个 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')
三层检查分别对应三种不同失败原因:
| 层级 | 变量 | 含义 |
|---|---|---|
| 1 | self._broken | executor 实例自身损坏 |
| 2 | self._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,不设全局 flag | submit() 不报错,但 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.”
所以,至少可以确定两件事:
- 这个问题已经被官方按 bug 处理,而不是简单归类为“使用者误用”
- 截至目前,它仍未在现有稳定版本中修复
八、为什么经历了多个版本仍未修复
8.1 历史根源:bpo-39812
2020 年,Antoine Pitrou 提出 bpo-39812: Avoid daemon threads in concurrent.futures,随后通过 PR #19149 合入 3.9 主线,动机大致有三点:
- Python 3.9 开始收紧子解释器中的 daemon 线程行为
- 标准库自身也需要去 daemon 化,避免和新约束冲突
- 因而要用
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(),也不愿在解释器关闭阶段冒更严重的崩溃风险。
九、跨语言对比
把这个问题放到不同语言的运行时模型里看,会更容易理解它为什么显得别扭。
| 语言 | 线程池关闭机制 | 与主线程的关系 | 设计选择 |
|---|---|---|---|
| Java | ExecutorService.shutdown() 由用户显式调用 | 线程池生命周期不绑定 main 线程 | JVM 等待非 daemon 线程,线程池通常由使用者显式管理 |
| Go | 没有标准库级线程池概念,goroutine 由 runtime 统一调度 | main() 返回后,进程立刻退出 | 直接消灭“main 走了但进程还活着”的中间状态 |
| Python | ThreadPoolExecutor 受模块级 _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。
参考链接
cpython #148280: ThreadPoolExecutor marks interpreter as shutdown before non daemon threads exit 官方 issue,当前状态为
Open / triaged / type-bug,也是本文判断“该问题已被按 bug 处理”的主要依据。bpo-39812: Avoid daemon threads in concurrent.futures 历史变更来源。Python 3.9 为了让
concurrent.futures摆脱对 daemon worker 的依赖,引入了新的退出注册机制,也间接让_shutdown更早生效。PR #19149: bpo-39812: Remove daemon threads in concurrent.futures
bpo-39812的实际合入记录,可直接看到变更发生在 2020 年,对应 Python 3.9 开发周期。Victor Stinner: Threading shutdown race condition 关于线程关闭竞态、daemon 线程风险和长期治理方向的背景材料,有助于理解为什么官方实现会优先选择“安全关闭”。
bpo-40234: Disallow daemon threads in subinterpreters daemon 线程在子解释器中被禁止的背景讨论,也是 Python 3.9 之后逐步收紧 daemon 线程语义的重要线索。