Python报错coroutine was never awaited与Task exception was never retrieved的排查方法(asyncio事件循环、TaskGroup与debug模式定位)

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()静默新建静默新建DeprecationDeprecationRuntimeError
asyncio.wait()传协程DeprecationTypeErrorTypeErrorTypeErrorTypeError
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月,几个版本号是当时实测的版本。