asyncio的报错有个麻烦:报错的位置和真正写错的位置常常隔着几层。协程压根没跑、Task抛了异常没人取、循环关了还在await,这几种情况打印出来的句子都带never或closed,混在一起看很容易修错方向,照着其中一种去改另外几种,越改越乱。
先把四种报错拆开。
RuntimeWarning: coroutine 'fetch' was never awaited
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
Task exception was never retrieved
future: <Task finished coro=<fetch() done, defined at app.py:11> exception=ValueError('boom')>
Traceback (most recent call last):
File "app.py", line 13, in fetch
raise ValueError("boom")
Task was destroyed but it is pending!
Task: <Task pending coro=<fetch() running at app.py:42> wait_for=<Future pending cb=[...]>>
RuntimeError: Event loop is closed
第一种是协程对象建出来了、函数体一行都没执行;第二种是Task跑完了、异常搁在Task对象上没人读;第三种是循环关闭时还有Task没做完;第四种是拿已经关掉的循环继续做await。四个报错对应四个不同阶段,排查顺序完全不同。
开debug模式,让报错带上出处
默认的RuntimeWarning只说哪一行建的协程被回收了,不说谁建的。遇到过好几台机器上日志里只剩一行never awaited,翻遍调用栈也找不到源头,打开debug模式才看到创建位置。三种开法效果一样。
PYTHONASYNCIODEBUG=1 python app.py python -X dev app.py python -W error::RuntimeWarning app.py
import asyncio, tracemalloc tracemalloc.start(5) asyncio.run(main(), debug=True)
debug模式下多出三类信息:回调耗时超过slow_callback_duration(默认值是0.1秒)就打日志,在协程里偷偷用了requests或者time.sleep,靠这条能直接揪出来;never awaited会带上source_traceback,指向建协程的那一行;跨线程调loop的方法会被拦下。tracemalloc.start(5)再配上它,告警里会多一段分配位置的栈,排查老项目里不知从哪冒出来的协程很好用。
never awaited:忘了await,或者在同步环境里调async函数
成因就一句话:async def定义的函数,直接调用返回的是协程对象,不是返回值。下面这两种写法,第二种才是多数人踩的坑。
async def fetch(url):
...
# 坑1:少了 await
async def main():
fetch("http://a") # 只建了个协程对象,函数体没跑
# 坑2:同步函数里调 async 函数
def handler():
asyncio.run(fetch("http://a")) # 每次都新建循环,性能很差
# 或者更糟:直接 fetch("http://a"),连循环都没有
# 正解
async def main():
await fetch("http://a")
还有一种藏得深的:async with写成with。用with去开一个异步上下文管理器,不会报错,只会拿到一个什么都没做的对象,紧接着的await就落到别的循环上去了。之前有个项目里aiomysql的连接池就是这么开的,症状是查询偶发卡死,报错却报在别处,折腾了半天才发现是with少了async。
# 错:__aenter__ 从未被调用
with pool.acquire() as conn:
...
# 对
async with pool.acquire() as conn:
...
版本也会在这里插一脚。老代码里常见asyncio.wait([coro1, coro2])这种传协程的写法,实测在CPython 3.10.20、3.11.15、3.12.13、3.13.14、3.14.4,表现并不一样:3.10还是DeprecationWarning,3.11起直接TypeError,报错文案是「Passing coroutines is forbidden, use tasks explicitly」。看到这个报错别去改wait的参数顺序,先把协程包成Task再传。
t1 = asyncio.create_task(coro1()) t2 = asyncio.create_task(coro2()) done, pending = await asyncio.wait([t1, t2])
Task exception was never retrieved:异常搁在Task上没人读
协程里抛出的异常不会往外冒,它被存进Task对象,等有人await这个Task或者读它的result()、exception()时才重新抛出。没人读,就一直搁着,直到Task被回收时才打那句「never retrieved」。所以这句报错真正的意思是:有个后台任务挂了,而且没人知道。
四种收尾方式,按场景选:
# 1. 要结果,就直接 await,异常立刻冒出来
await task
# 2. 批量并发,加 return_exceptions=True 逐个判断
results = await asyncio.gather(*coros, return_exceptions=True)
for r in results:
if isinstance(r, BaseException):
log.error("子任务失败: %r", r)
# 3. Python 3.11、3.12、3.13、3.14,才有 TaskGroup,一个失败同级全取消
async with asyncio.TaskGroup() as tg:
for u in urls:
tg.create_task(fetch(u))
# 4. 纯后台任务,挂个统一处理器兜住
loop = asyncio.get_running_loop()
loop.set_exception_handler(lambda l, ctx: log.error("asyncio: %s", ctx))
注意gather不加return_exceptions=True时的行为:第一个抛异常就立刻往上冒,剩下的协程变成没人管的孤儿继续跑,连接池、内存就这么一点点被吃掉。见过线上一个批量同步任务,跑了两天把数据库连接耗光,查到最后发现是三个月前加的一行gather没写return_exceptions。现场排这类问题,直接搜代码里的gather调用最省事。
Task was destroyed but it is pending:Task被垃圾回收了
事件循环只持有Task的弱引用。create_task()建出来任务却没把返回值存下来,垃圾回收随时可能把它收走,任务跑到一半就没了,不报错也不写库。官方文档里给的标准写法是拿一个集合兜住。
background_tasks = set()
def spawn(coro):
task = asyncio.create_task(coro)
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
return task
async def shutdown():
for t in list(background_tasks):
t.cancel()
await asyncio.gather(*background_tasks, return_exceptions=True)
另一半原因是关得太急。进程退出、测试结束、热重载触发时,循环直接close,还挂着的Task就报这句话。收尾流程要反过来写:先cancel,再await等它们真正结束,最后才关循环。asyncio.run()已经把这套做对了,自己new_event_loop再run_until_complete的旧写法就得自己补。
Event loop is closed:循环关了还在用
常见四种触发场景:同一个进程里调了两次asyncio.run();测试框架为每个用例新建循环却在上一个用例里关掉了;Jupyter这类环境本身有循环在跑,再调asyncio.run()会撞上「This event loop is already running」;进程退出阶段还有回调往已关闭的循环里塞任务。
排查办法很直接:把报错往上的调用栈读一遍,确认是谁关的循环。
python -X dev -W error::RuntimeWarning app.py
加上这两个开关,关循环的那一行会以ResourceWarning的形式先报出来,比只看最后那句RuntimeError好定位。pytest这边也有坑:pytest-asyncio从0.21起默认asyncio_mode改成strict,异步用例没打@pytest.mark.asyncio标记就会在没有循环的环境里跑,症状同样是「no running event loop」。配一次就行。
[pytest] asyncio_mode = auto
版本差异,升级时最容易踩的几条
同一份代码在不同Python版本上行为不一样,是asyncio这几年的常态。下面这张表是在CPython 3.10.20、3.11.15、3.12.13、3.13.14、3.14.4,跑同一个探针脚本实测出来的结果,升级前照着对一遍,能省掉不少半夜的排查。
| 写法 | 3.10 | 3.11 | 3.12 | 3.13 | 3.14 |
|---|---|---|---|---|---|
| 无循环时调get_event_loop() | 静默新建 | 静默新建 | Deprecation | Deprecation | RuntimeError |
| asyncio.wait()传协程 | Deprecation | TypeError | TypeError | TypeError | TypeError |
| set_event_loop_policy() | 正常 | 正常 | 正常 | 正常 | Deprecation |
| TaskGroup / timeout / Runner | 没有 | 有 | 有 | 有 | 有 |
| eager_task_factory | 没有 | 没有 | 有 | 有 | 有 |
| Queue.shutdown / async as_completed | 没有 | 没有 | 没有 | 有 | 有 |
三条改动值得单独拎出来。get_event_loop()在没有循环时的行为一路收紧,到3.14直接RuntimeError,老项目里那句loop = asyncio.get_event_loop()加loop.run_until_complete()的写法必须在升级前换成asyncio.run()。事件循环策略那套API也是同一个节奏,Python 3.14、3.16,前者起废弃、后者计划移除,用uvloop的改走asyncio.run(main(), loop_factory=...)这个3.12起支持的参数。wait_for的行为在3.12也变了:它现在跑在调用方的Task里,取消语义跟以前不完全一样,依赖旧行为的超时代码要重新测一遍。
数据截至2026年10月,几个版本号是当时实测的版本。