报错原文
这个报错有两条长得像、实际完全不同的信息,它们印在同一行代码上:
python
RuntimeError: cannot schedule new futures after shutdown
python
RuntimeError: cannot schedule new futures after interpreter shutdown
生产上更常见的是第二条,堆栈通常长这样(取自 boto3 issue 的真实 traceback):
text
File "/usr/local/lib/python3.9/site-packages/boto3/s3/transfer.py", line 304, in download_file
future = self._manager.download(
File "/usr/local/lib/python3.9/site-packages/s3transfer/manager.py", line 500, in _submit_transfer
self._submission_executor.submit(
File "/usr/local/lib/python3.9/site-packages/s3transfer/futures.py", line 467, in submit
future = ExecutorFuture(self._executor.submit(task))
File "/usr/local/lib/python3.9/concurrent/futures/thread.py", line 163, in submit
raise RuntimeError('cannot schedule new futures after '
RuntimeError: cannot schedule new futures after interpreter shutdown
这一条的关键特征是:用户代码一行都没写 submit(),报错却来自 submit()。本文要解决的问题正是这个------为什么你的业务代码里根本没有并发逻辑,线程池却在退出时报错。
GitHub 真实案例
四个高星项目的真实 issue,覆盖四种完全不同的业务形态:
| 项目 | Issue | 👍 | 状态 | 触发形态 |
|---|---|---|---|---|
| boto/boto3 | #3113 | 8 | open(2026-07 仍活跃) | 自定义线程里调用 S3 下载/上传 |
| apache/airflow | #14089 | 11 | closed | K8s Executor 上 S3 远端日志写入 |
| Aider-AI/aider | #2932 | 10 | closed | 用线程池并发跑多个模型的 summarizer |
| benoitc/gunicorn | #3007 | 6 | open | --threads 2 --max_requests N 下请求被中断 |
boto3 #3113 的讽刺点在于 :这个 issue 是 2022 年 1 月提的,2022-2026 年间被反复 reopen,到 2026 年 7 月还有新活动。提问者的诉求甚至不是"修 bug",而是"求一个推荐的做法"------因为社区流传的解法是把 boto3 降级回 1.17.53 ,或者全局关掉 S3 操作的线程。
Apache Airflow 的选择更直白:官方邮件列表里给出的方案就是"禁用 S3 操作的线程化"。换句话说,这个报错的官方级处理方式是放弃并发------这正是本文要替掉的错误答案。
gunicorn #3007 的场景值得单独看,因为它揭示了一个反直觉事实:进程并没有要退出,是"优雅重启"被当成了退出 。当 worker 因为 max_requests 达到阈值需要重启时,另一个线程仍在处理请求,这个请求里只要碰一次线程池,请求就被异常中断------用户看到的是"偶发的 500",而不是"服务重启"。
根因:同一个 submit() 里的两个布尔标志
打开 Lib/concurrent/futures/thread.py(本机 Python 3.11 实际源码),模块级只有两个东西:
python
# Lib/concurrent/futures/thread.py 第 18-37 行(Python 3.11)
_shutdown = False
# Lock that ensures that new workers are not created while the interpreter is
# shutting down. Must be held while mutating _threads_queues and _shutdown.
_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)
for t, q in items:
t.join()
# Register for `_python_exit()` to be called just before joining all
# non-daemon threads. This is used instead of `atexit.register()` for
# compatibility with subinterpreters, which no longer support daemon threads.
# See bpo-39812 for context.
threading._register_atexit(_python_exit)
再看报错发生的那 10 行:
python
# Lib/concurrent/futures/thread.py 第 161-170 行(Python 3.11)
def submit(self, fn, /, *args, **kwargs):
with self._shutdown_lock, _global_shutdown_lock:
if self._broken:
raise BrokenThreadPool(self._broken)
if self._shutdown:
raise RuntimeError('cannot schedule new futures after shutdown')
if _shutdown:
raise RuntimeError('cannot schedule new futures after '
'interpreter shutdown')
两个 if 挨着,但含义差着量级:
| 报错信息 | 触发标志 | 含义 | 谁能修复 |
|---|---|---|---|
after shutdown |
self._shutdown(实例级) |
这个 executor 已经被 shutdown(),或某个 worker 已经退出 |
调用方:不要在 shutdown() 之后再提交 |
after interpreter shutdown |
_shutdown(模块级全局) |
CPython 已经进入解释器退出流程 | 调用方:必须让线程在进程退出前结束 |
_shutdown 是什么时候被置位的? 由 _python_exit() 完成,而它是通过 threading._register_atexit() 注册的------不是普通的 atexit.register()。原因是源码注释里写的 bpo-39812:子解释器不支持 daemon 线程,所以要用 threading 自己的退出钩子。
Lib/threading.py 里对应的两个符号说明了执行时机:
python
# Lib/threading.py 第 1506-1523 行(Python 3.11)
_threading_atexits = []
_SHUTTING_DOWN = False
def _register_atexit(func, *arg, **kwargs):
"""CPython internal: register *func* to be called before joining threads.
The registered *func* is called with its arguments just before all
non-daemon threads are joined in `_shutdown()`. It provides a similar
purpose to `atexit.register()`, but its functions are called prior to
threading shutdown instead of interpreter shutdown.
...
"""
if _SHUTTING_DOWN:
raise RuntimeError("can't register atexit after shutdown")
call = functools.partial(func, *arg, **kwargs)
_threading_atexits.append(call)
python
# Lib/threading.py 第 1534-1553 行(Python 3.11)
def _shutdown():
"""Wait until the Python thread state of all non-daemon threads get deleted."""
if _main_thread._is_stopped:
return # _shutdown() was already called
global _SHUTTING_DOWN
_SHUTTING_DOWN = True
# Call registered threading atexit functions before threads are joined.
for atexit_call in reversed(_threading_atexits):
atexit_call()
把三处串起来,进程退出时的真实顺序是:
text
main() 返回 / sys.exit()
│
▼
Py_FinalizeEx()
│
├─► threading._shutdown() ← threading.py:1534
│ │
│ ├─ _SHUTTING_DOWN = True
│ ├─ 反向执行 _threading_atexits
│ │ └─► _python_exit() ← thread.py:23
│ │ ├─ _shutdown = True ← 【模块级标志在这里置位】
│ │ ├─ 每个 work_queue.put(None)
│ │ └─ 逐个 t.join() 等所有 worker 退出
│ │
│ └─ join 所有非 daemon 线程
│
▼
真正结束进程
关键点:模块级 _shutdown = True 是在"等待线程结束"之前置位的。 所以只要你的后台线程还活着、还想提交任务,就撞在这道门上------而且没有例外 。_python_exit() 不会先等你把活干完,它先关门,再等人。
worker 侧还有一句注释值得注意(thread.py 第 99-105 行):
python
# Exit if:
# - The interpreter is shutting down OR
# - The executor that owns the worker has been collected OR
# - The executor that owns the worker has been shutdown.
if _shutdown or executor is None or executor._shutdown:
if executor is not None:
executor._shutdown = True # 提前把实例标志也置位
work_queue.put(None) # 通知其他 worker
return
worker 退出时会把 executor._shutdown 也置为 True。这就解释了为什么同一个根因会报出两条不同的错误信息 :谁先被看到,取决于这个 executor 的 worker 有没有来得及退出并置位实例标志------两个 if 是竞态关系。
复现实验:两条报错在同一台机器上都能稳定复现
在 Python 3.10.12 上跑四组对比,输出如下(完整脚本见文末自检部分):
text
== python 3.10.12
[main] main 返回
[A] 已存在executor 提交失败 i=8: RuntimeError: cannot schedule new futures after shutdown
[B] 退出阶段新建executor 提交失败: RuntimeError: cannot schedule new futures after interpreter shutdown
---
== python 3.10.12
[C] 显式 shutdown 后提交: RuntimeError: cannot schedule new futures after shutdown
[D] 正确姿势: 42
[D] join 完成,进程干净退出
四组实验说明了四件事:
- A 复用长期存在的 executor :报的是
after shutdown(实例级)------因为 worker 已经先退出并把实例标志置位了。这和 Aider #2932 报的信息一致。 - B 在退出阶段新建 executor :报的是
after interpreter shutdown------因为新 executor 还没有 worker 去置位实例标志,submit()只能看到模块级标志。这和 boto3 #3113、gunicorn #3007 报的信息一致。 - C 显式 shutdown 之后再提交 :报
after shutdown,这是唯一一个调用方自己造成的错误,也是唯一一条正常的错误。 - D
shutdown(wait=True)之后再退出 :submit()返回 42,进程干净退出,没有任何报错。
这个实验的价值 :网上大量文章把两条报错混为一谈,或者只说"在子线程里调用 submit 就会报错"------那是把 A 当成全部。你如果照那个说法去搜,永远找不到自己那条 after interpreter shutdown 的答案,因为你的问题不在"子线程调用",而在"进程已经在退出"。
五种生产级触发场景
场景 1:自定义线程 + 官方 SDK 的隐藏线程池(最典型)
boto3 是最经典的例子:download_file / upload_fileobj 走 s3transfer,s3transfer 内部持有 ThreadPoolExecutor。你的代码只是"在线程里传了个文件",报错却来自 concurrent/futures/thread.py。
python
# ❌ 错误代码:后台线程还在跑,主线程已经返回
import threading
from queue import Queue
import boto3
s3 = boto3.client("s3")
def worker(q: Queue):
while True:
item = q.get()
# 这一行内部会向 s3transfer 的线程池 submit
s3.download_file(item["bucket"], item["key"], item["dest"])
q.task_done()
q = Queue()
threading.Thread(target=worker, args=(q,), daemon=False).start()
def main():
q.put({"bucket": "b", "key": "k", "dest": "/tmp/f"})
return # ❌ main 返回 → 解释器开始退出 → worker 下一次 submit 必炸
python
# ✅ 正确代码:先把线程收干净,再让 main 返回
import threading
from queue import Queue
import boto3
s3 = boto3.client("s3")
stop = threading.Event()
def worker(q: Queue):
while not stop.is_set():
try:
item = q.get(timeout=0.5)
except Exception:
continue
s3.download_file(item["bucket"], item["key"], item["dest"])
q.task_done()
q = Queue()
t = threading.Thread(target=worker, args=(q,), daemon=False)
t.start()
def main():
q.put({"bucket": "b", "key": "k", "dest": "/tmp/f"})
q.join() # ① 等业务队列排空
stop.set() # ② 通知 worker 退出
t.join(timeout=30) # ③ 等 worker 真正结束 ------ 这一步才是根治
if __name__ == "__main__":
main()
中级视角 :报错不在你的代码里,但生命周期责任在你的代码里。第三方 SDK 用线程池做加速,等于把你的进程退出时序变成了它的正确性前提。凡是"SDK 内部偷偷起线程"的地方(boto3/s3transfer、requests 的某些适配器、部分监控上报 SDK),都要按这个模式处理。
场景 2:main() 返回了,但还有活儿没干完(占比最高)
这类问题的本质是把"非 daemon 线程"当成了"进程会等我"。进程会等你 join,但不会等你"干完活" ------_python_exit() 是先置位再等,所以等待期间任何提交都是非法操作。
text
现象:程序似乎"正常跑完了",日志里却是异常退出
根因:main() 返回 = 退出流程开始,与业务是否完成无关
判断:报错线程的日志时间戳,总是和进程退出时间戳在同一秒
场景 3:gunicorn / uWSGI 的优雅重启被当成进程退出
--max_requests N 是防内存泄漏的常用配置,但 worker 触发重启时,正在处理请求的另一个线程会直接撞上这个错误(gunicorn #3007 原始报告)。用户侧看到的现象是:
text
request 开始 → 日志正常 → 突然 500,堆栈指向 concurrent/futures/thread.py:submit
ini
# ❌ 有问题:--max_requests 与请求内线程池叠加
gunicorn app:app --threads 2 --max-requests 1000
python
# ✅ 请求内不要依赖进程级线程池;如需并发,用受控的 asyncio/应用级池
# 并把"进程即将退出"作为一等状态处理
import signal, sys
_shutting_down = False
def _on_term(signum, frame):
global _shutting_down
_shutting_down = True
signal.signal(signal.SIGTERM, _on_term)
def handler():
if _shutting_down:
return {"error": "server is draining"}, 503
...
注意 :SIGTERM 处理器里只是打标记,绝不要在这里提交线程池任务 ------此刻解释器很可能已经在退出流程中,任何 submit() 都会命中模块级标志。这个坑在"关机前上报一次指标/刷一次日志"的写法里非常常见。
场景 4:退出阶段新建线程池(B 实验的组合)
监控上报、日志 flush、atexit 回调里"最后再做一件事",如果那件事内部用了线程池(很多 SDK 默认用),就会稳定复现 after interpreter shutdown------因为退出阶段新建的 executor 一定没有 worker 帮它置位实例标志。
python
# ❌ 错误代码:退出钩子里做重活
import atexit
from concurrent.futures import ThreadPoolExecutor
def flush_metrics():
with ThreadPoolExecutor(max_workers=4) as ex: # 退出阶段新建
list(ex.map(上报, 待上报指标)) # 必炸
atexit.register(flush_metrics)
python
# ✅ 正确代码:把"最后一步"提前到业务阶段,退出钩子只做同步、快速的事
上报队列 = []
def flush_metrics_shutdown_safe():
for item in 上报队列: # 同步、无线程池、有超时
上报_with_timeout(item, timeout=1)
# 不在这里创建任何 executor
atexit.register(flush_metrics_shutdown_safe)
中级视角 :判断一个退出钩子是否安全,只看一条------它内部有没有"起新线程/线程池"的动作。有,就必须挪到退出流程之前。
场景 5:测试与多进程环境下的假失败
pytest 多进程插件、multiprocessing + fork、以及"每条用例都新建 executor"的测试风格,会在进程收尾阶段留下大量待结束的池。表现是测试偶发失败、CI 上难以复现。
python
# ✅ 让 executor 的作用域等于业务的作用域
def process_all(items):
with ThreadPoolExecutor(max_workers=8) as ex: # 出块即 wait
return list(ex.map(handle, items))
with 语句退出时等价于 shutdown(wait=True),把"提交"和"等待"锁在同一个代码块里,是成本最低的根治手段。
排障流程
第一步:确认是两条报错中的哪一条。
bash
grep -rn "cannot schedule new futures" \
| sed -E 's/.*cannot schedule new futures after (.*)/\1/' | sort | uniq -c
- 只有
shutdown→ 有人在shutdown()之后提交,看调用方的生命周期(场景 1/2 的局部问题) - 有
interpreter shutdown→ 进程退出与业务提交并发,这是本文的主问题
第二步:确认报错线程是"谁"、在什么时刻。
bash
grep -B5 "cannot schedule new futures" app.log | grep -E "Thread-|thread_name_prefix|asctime"
如果报错线程名带 ThreadPoolExecutor- 前缀,说明提交方是某个 SDK 的池;如果是你自己的线程名,说明提交方是你的代码------这决定了要改哪一处。
第三步:看进程退出时间与报错时间的距离。
bash
# 报错时间戳 vs 进程退出时间戳,同一秒 = 退出竞态
grep -nE "cannot schedule new futures|SIGTERM|shutting down|Worker exiting" app.log | tail -20
第四步:把隐藏的线程池找出来。
bash
python3 -c "
import threading, time
time.sleep(3)
for t in threading.enumerate():
print(t.name, 'daemon=', t.daemon)
"
第五步:验证修复------不要只看报错消失,要看线程都收干净了。
python
import atexit, threading
@atexit.register
def _dump_leftover_threads():
alive = [t for t in threading.enumerate() if t is not threading.main_thread()]
if alive:
print("[退出时仍在运行的非主线程]", [(t.name, t.daemon) for t in alive])
退出时这个列表为空,才算真正修好。这一步是区分"报错被掩盖"和"生命周期被修正"的唯一标准。
三种修法对比
| 修法 | 做法 | 代价 | 评价 |
|---|---|---|---|
| 生命周期修正(推荐) | 业务队列 join() → 通知线程退出 → t.join() → 再让 main() 返回 |
需要理清退出时序,改动 5-20 行 | 根治。报错消失、线程收干净、日志不再截断 |
| 作用域收敛(推荐) | 所有线程池用 with ThreadPoolExecutor(...),或显式 shutdown(wait=True) |
几乎无损 | 根治"提交与等待分离"这一类 |
| 退出钩子降级(补充) | 把"最后一步"从退出钩子挪到业务阶段,钩子只做同步短操作 | 需要确认"最后一步"不丢数据 | 根治场景 4 |
| 放弃并发(不推荐) | boto3 降级到 1.17.53 / 全局 use_threads=False |
吞吐下降,且掩盖真实的生命周期缺陷 | 治标。Airflow 与 boto3 社区的历史选择,但代价是性能 |
| 捕获异常忽略(禁止) | except RuntimeError: pass |
0 | 表面无错,实际任务被静默丢掉------比报错更危险 |
总结:三层理解
- 初级 :
RuntimeError: cannot schedule new futures after ...表示线程池不接受新任务了。两条信息分别对应"这个池被关了"和"整个解释器在退出"。 - 中级 :根因不是线程池,而是进程退出时序与业务提交的竞态 。
_python_exit()先置位_shutdown,再等待线程结束;submit()里的两个if是竞态观察点,所以同样一个根因能报出两条不同信息。修法是让线程在main()返回之前结束。 - 记忆锚点 :"两条报错,一个根因:退出时先关门,再等人。" 看到
interpreter shutdown就别再查线程池用法,直接查你进程的退出时序。
自检脚本
把下面这段保存成 check_futures.sh,在服务退出前的最后一步跑,能同时回答"有没有漏提交"和"线程收干净没有":
bash
#!/usr/bin/env bash
set -u
LOG="${1:-app.log}"
echo "== 1. 报错分布(两条信息各多少)"
grep -oh "cannot schedule new futures after [a-z ]*" "$LOG" | sort | uniq -c
echo "== 2. 报错线程名(判断是谁的池)"
grep -B3 "cannot schedule new futures" "$LOG" | grep -oE "Thread-[0-9]+|ThreadPoolExecutor-[0-9]+" | sort | uniq -c
echo "== 3. 报错时间戳 vs 进程退出时间戳"
grep -nE "cannot schedule new futures|SIGTERM|SIGINT|shutting down" "$LOG" | tail -10
echo "== 4. 代码里有几处线程池在 with 之外使用(应为 0)"
grep -rn "ThreadPoolExecutor(" --include="*.py" . \
| grep -v "with " | grep -v "^\s*#" | wc -l
四项输出的读法:第 1 项出现 interpreter shutdown → 是退出竞态;第 2 项线程名带 ThreadPoolExecutor- → 改第三方 SDK 的生命周期(场景 1),带自定义线程名 → 改自己的线程收尾顺序(场景 2);第 3 项时间戳同秒 → 竞态确认;第 4 项非 0 → 先补 with / shutdown(wait=True)。
版本差异与五个常见误区
版本差异(都以源码为依据,不是记忆):
| 版本点 | 变化 | 依据 |
|---|---|---|
| 3.8 / 3.9 期间 | 开始出现"退出后禁止提交"的行为;boto3 社区反馈 1.17.53 及更早版本未见此报错,之后开始出现 | boto3 #3113 原文引用提交 c4b695f 与 bpo-33097 |
| 3.11(本机验证) | submit() 两条分支、_python_exit()、threading._register_atexit 均已存在 |
Lib/concurrent/futures/thread.py:18-37,161-170;Lib/threading.py:1506-1553 |
| 3.13+ 主分支 | 默认线程数改用 os.process_cpu_count()(3.11 用 os.cpu_count()),容器内 cgroup 限额下取值更准 |
CPython 主分支 thread.py 对应行 |
| 主分支(未来版本) | worker 初始化重构为 WorkerContext / prepare_context,并新增 fork 后清理 _threads_queues |
CPython 主分支 thread.py 中 os.register_at_fork(after_in_child=_threads_queues.clear) |
注意 :不要在文章里"按版本号背结论"。真正决定行为的是你的进程退出时序 ,而不是 Python 版本------同一个版本在不同 s3transfer 组合、不同启动方式(gunicorn fork 模式 / 直接 python app.py)下表现可以不同。
五个常见误区:
| 误区 | 为什么错 | 正确做法 |
|---|---|---|
"在子线程里调用 submit() 就会报错" |
子线程调用本身完全合法,报错来自解释器已在退出 | 查退出时序,而不是禁止子线程提交 |
| "两条报错是同一个东西,随便搜哪条都一样" | 两个 if 是竞态观察点,含义与修法不同 |
先分辨 shutdown / interpreter shutdown 再决定改哪里 |
"加个 try/except 跳过就行" |
任务被静默丢弃,日志上报/文件切片的最后一块可能永远丢 | 用 join() 保证提交在退出前完成 |
| "降级 boto3 就好了" | 治标,且把真实缺陷留在代码里;后续升级还会复发 | 修正线程收尾顺序 |
"daemon=True 能解决" |
daemon 线程会在退出时被直接掐断,任务同样丢,而且丢得更静默 | 用非 daemon + 显式 join;daemon 只用于纯后台、可丢的活 |
同类家族
| 报错 | 触发条件 | 与本文的关系 |
|---|---|---|
RuntimeError: cannot schedule new futures after shutdown |
实例已 shutdown() |
同一函数的上一个分支 |
RuntimeError: cannot schedule new futures after interpreter shutdown |
模块级 _shutdown 已置位 |
本文主问题 |
RuntimeError: can't create new thread at interpreter shutdown |
退出阶段 Thread.start() |
同一退出阶段的兄弟错误 |
RuntimeError: can't register atexit after shutdown |
_SHUTTING_DOWN 之后注册 atexit |
threading.py:1519 同一批标志 |
BrokenThreadPool: A thread initializer failed |
submit() 第一个分支 |
同一函数最上层的判断 |
RuntimeError: Event loop is closed |
asyncio 侧同一类问题(退出后再提交协程) | 异步版对偶问题 |
原始出处
- boto/boto3#3113 --- Threads calling S3 operations return RuntimeError(8👍,2022-01 提交,2026-07 仍有活动)
- apache/airflow#14089 --- S3 Remote Logging Kubernetes Executor worker task keeps waiting(11👍)
- Aider-AI/aider#2932 --- Summarization failed: cannot schedule new futures after interpreter shutdown(10👍)
- benoitc/gunicorn#3007 --- A request raises RuntimeError when submitting ThreadPoolExecutor future(6👍)
- CPython 源码:
Lib/concurrent/futures/thread.py(_python_exit/submit)、Lib/threading.py(_register_atexit/_shutdown),行号取自本机 Python 3.11 标准库- 相关 bpo:bpo-33097(退出后禁止提交任务)、bpo-39812(子解释器与退出钩子)
- 复现实验环境:Python 3.10.12,本机实测输出见「复现实验」一节
本文首发于 CSDN 专栏《Python 生产环境报错速查:从崩溃到修复》。