AnyIO 工作已经结束,Python 进程为什么仍退不掉
AnyIO 4.15.1 的空闲工作线程可能因事件循环提前停止而残留。检查根任务是否完成清理,使用受管理的运行入口,区分空闲线程与仍在运行的阻塞函数。
同步函数早已返回,事件循环也调用了 stop(),Python 进程却停在退出阶段。线程栈里若还有名为 AnyIO worker thread 的线程等待队列,要检查它所属的异步根任务是否真正结束,不能只看业务函数是否做完。
AnyIO 4.15.1 在 asyncio 后端会复用非 daemon 工作线程。线程的停止回调挂在根任务完成事件上;事件循环在根任务仍 pending 时被直接停掉,回调可能没有机会执行,空闲线程便继续等下一项工作,并阻止解释器退出。
这条故障在 Python 3.14.5、AnyIO 4.15.1 与手动管理循环的环境中有明确记录。它不同于同步函数本身卡在网络或文件操作中,也不是 Trio 自由线程构建的 ContextVar 引用保留问题。
先分清线程在执行,还是已经空闲
保存退出时的线程栈和异步任务状态。工作线程停在业务函数内,说明实际工作没有结束,应先处理那项阻塞操作;线程停在 AnyIO 的队列读取处,而所属根任务还没完成,则要检查循环关闭顺序。
常见结构是调用 loop.run_forever(),再在某个回调里调用 loop.stop()。stop() 只要求事件循环停止运行,并不等于取消全部任务、等待它们完成清理,或执行所有 done callback。后续直接 loop.close() 也不能替代这些步骤。
AnyIO 的空闲线程有复用和回收逻辑,但不能据此认为“等十秒它必然退出”:4.15.1 的过期空闲线程清理由后续线程调用触发,并不是每个线程都自带一个十秒后自动退出的定时器。
独立程序使用管理完整生命周期的入口
自己拥有事件循环的命令行程序,优先使用 asyncio.run() 或 anyio.run()。下面仅演示一个有明确结束点的入口:
import asyncio
import anyio
def load_value():
return 42
async def main():
value = await anyio.to_thread.run_sync(load_value)
print(value)
asyncio.run(main())
这里同步调用被等待,main() 随后完成,运行入口负责后续循环清理。实际应用有后台任务时,也要由明确的任务管理者发出停止信号、等待它们清理,不能把后台任务遗留给一个突然停止的循环。
必须手动管理循环时,按所用 Python 和框架的关闭协议处理剩余任务,等待取消完成并收取异常,再进行异步生成器、默认执行器与循环的关闭。仅补上一句 shutdown_default_executor() 不等于关闭 AnyIO 自己的线程池;二者不是同一个执行器。
Jupyter、Tornado 或其他宿主已经管理循环时,不要在里面再嵌套 asyncio.run()。检查宿主的正常关闭钩子,把停止与清理接到它的生命周期中,而不是从扩展代码直接关闭共享循环。
正常清理也不能杀死任意阻塞函数
如果线程仍在执行无期限阻塞的同步函数,即便取消了等待方,也不会强行终止线程。abandon_on_cancel=True 允许异步调用方放弃等待,函数依然会运行;它不是解释器退出的保证。
给实际 I/O 设置有界超时,或让同步循环使用 AnyIO 提供的协作取消检查。关于外层超时后重复调用同一操作的风险,可看 asyncio 等待超时与线程实际执行。
不要把工作线程改成 daemon,或调用私有 threading._register_atexit() 拼接退出补丁来掩盖生命周期问题。后台写入可能被截断,资源也未必得到清理。截至 2026 年 10 月 22 日,针对额外循环与解释器清理路径的改动仍未成为这里可确认的正式修复,不能以未发布代码代替部署验证。
保留旧入口和关闭流程,在隔离进程中分别检查正常完成、外部取消、宿主退出与同步 I/O 超时:任务清理有记录,空闲工作线程结束,进程能自行退出。回退前确认没有仍在执行的外部操作,避免只换回旧入口后又启动一份相同工作。


