asyncio 里任务的异常去哪了?为什么会出现 exception was never retrieved?
简化版
在 asyncio 里,Task 抛出的异常不会自动向上传播**——它被存在 Task 对象里,等着有人通过 await task 或 task.result()/task.exception() 来「取走」。如果没人取,这个异常就悄无声息地消失了:你的后台任务已经挂了,主流程却毫不知情,业务表现为「某个功能突然不工作了」。唯一的提示是——当这个 Task 对象被垃圾回收时,asyncio 会打印一句 Task exception was never retrieved,但它出现的时机不确定**(取决于 GC),而且只是一条日志、不会中断程序,在容器里往往被淹没在日志流里根本没人看。三种并发原语的异常语义完全不同:gather() 默认在第一个异常发生时把它抛给调用方,但其余任务仍在后台继续运行(它们的异常同样可能无人认领);gather(return_exceptions=True) 则把异常当作结果放进返回列表,一个都不抛;而 TaskGroup(3.11+)在任何一个任务失败时会取消所有兄弟任务,并把所有异常打包成 ExceptionGroup 抛出——这才是「结构化并发」的正确语义。兜底手段是 loop.set_exception_handler(),它能捕获「无人认领的异常」和事件循环内部的错误。最重要的实践:asyncio.create_task() 的返回值必须保存引用(否则可能被 GC 提前回收、任务直接消失),并且给每个后台任务加 add_done_callback 记录异常。核心记忆:异常存在 Task 里等人取,没人取就丢;后台任务要保存引用 + 加 done_callback;能用 TaskGroup 就别用 gather。
详细版
三种并发原语的异常语义:
| 写法 | 第一个异常时 | 其余任务 | 调用方看到 |
|---|---|---|---|
await gather(*tasks) | 立即抛给调用方 | ⚠️ 继续在后台跑 | 第一个异常 |
gather(..., return_exceptions=True) | 不抛 | 全部跑完 | 异常混在结果列表里 |
async with TaskGroup() | 取消所有兄弟任务 | 被取消 | ExceptionGroup |
await asyncio.wait(tasks) | 不抛 | 按参数决定 | 要自己遍历 done 取 |
create_task() 不 await | 无人认领 | —— | ❌ 静默消失 |
import asyncio, logging
async def boom():
raise ValueError("炸了")
# ① ★异常被存起来,没人取就消失★
async def demo_lost():
task = asyncio.create_task(boom())
await asyncio.sleep(0.1)
print(task.done()) # True
# ★不 await、不 result()、不 exception() → 异常永远不会浮出水面★
# 只有 task 被 GC 时才可能打印 "Task exception was never retrieved"
# ② 三种"取走异常"的方式
task = asyncio.create_task(boom())
await asyncio.sleep(0)
try:
await task # ✓ 抛出 ValueError
except ValueError:
pass
# 或
exc = task.exception() # ✓ 返回异常对象(★不抛★),没异常返回 None
# 或
task.result() # ✓ 抛出异常,或返回结果
# ★注意:任务被取消时,exception() 和 result() 都会抛 CancelledError★
# ③ ★gather 的默认行为:抛第一个,其余仍在跑★
async def demo_gather():
async def slow_ok():
await asyncio.sleep(5)
print("我还在跑!") # ★gather 已经抛异常返回了,我仍然在后台跑★
try:
await asyncio.gather(boom(), slow_ok())
except ValueError:
print("捕获到第一个异常")
await asyncio.sleep(6) # ★能看到 slow_ok 打印★
# ④ return_exceptions=True:异常变成"结果"
results = await asyncio.gather(boom(), ok(), return_exceptions=True)
# results = [ValueError('炸了'), 'ok值']
for r in results:
if isinstance(r, Exception): # ★必须自己检查★
logging.error("任务失败: %r", r)
# ⑤ ★TaskGroup(3.11+):结构化并发的正确语义★
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(boom())
tg.create_task(slow_ok()) # ★boom 失败后它会被取消★
except* ValueError as eg: # ★ExceptionGroup,用 except* 处理★
print("失败的任务数:", len(eg.exceptions))
# ⑥ ★全局兜底:事件循环的异常处理器★
def handler(loop, context):
# context 里有 'message'、'exception'、'task'、'future' 等
logging.error("未处理的异步异常: %s", context["message"],
exc_info=context.get("exception"))
loop = asyncio.get_running_loop()
loop.set_exception_handler(handler) # ★捕获"无人认领"的异常★
# ⑦ ★后台任务的正确写法(两件事都要做)★
_background = set() # ★① 保存强引用,防止被 GC★
def spawn(coro, name=None):
t = asyncio.create_task(coro, name=name)
_background.add(t)
t.add_done_callback(_background.discard)
t.add_done_callback(_log_exception) # ★② 主动记录异常★
return t
def _log_exception(task):
if task.cancelled():
return
if (exc := task.exception()) is not None:
logging.error("后台任务 %s 失败", task.get_name(), exc_info=exc)
⚠️ 三个必须记住的机制:① Task 的异常是「存起来等人取」而不是「向上抛」——协程本身只是个对象,包装成
Task交给事件循环后就独立运行了,它抛的异常没有「调用方的栈」可以传播,只能存进 Task。取走的方式有三种:await task、task.result()(会重新抛出)、task.exception()(返回异常对象而不抛)。没人取 = 异常静默丢失,唯一的痕迹是 Task 被 GC 时打印的Task exception was never retrieved(时机不确定,且只是一条日志)。②asyncio.create_task()的返回值必须保存引用:事件循环只持有任务的弱引用,如果你写asyncio.create_task(coro())而不保存返回值,这个 Task 可能在执行完成前被垃圾回收,任务直接消失(官方文档明确警告过这一点)。正确做法是存进一个模块级set,并用add_done_callback(set.discard)在完成时移除。③gather抛出异常后,其余任务并不会被取消——它们继续在后台运行到底,它们的异常同样可能无人认领。这与TaskGroup形成鲜明对比:TaskGroup里任何一个任务失败,会自动取消所有兄弟任务,并把异常打包成ExceptionGroup抛出——这才是「一荣俱荣、一损俱损」的结构化并发语义。
完整版教学
一、异常在协程和任务里的传播模型
★ 关键区分:协程 vs 任务★
coro = boom() # ★只是创建了一个协程对象,什么都没执行★
await boro # ★在当前栈上执行 → 异常正常向上传播★
task = asyncio.create_task(coro) # ★交给事件循环独立运行★
┌──────────────────────────────────────────────────────────┐
│ await coro() │
│ → 在★调用方的协程栈★上执行 │
│ → 异常像普通函数一样★向上传播★,try/except 能抓到 │
├──────────────────────────────────────────────────────────┤
│ create_task(coro()) │
│ → 由★事件循环★调度,独立于调用方 │
│ → 异常★没有调用方的栈可传播★ → 存进 Task 对象 │
│ → 等 await task / task.result() / task.exception() 来取 │
└──────────────────────────────────────────────────────────┘
Task 的状态与异常:
task.done() 是否结束(完成/异常/取消都算)
task.cancelled() 是否被取消
task.result() ★有异常就重新抛出★;被取消则抛 CancelledError
task.exception() ★返回异常对象(不抛)★;没异常返回 None;
★被取消时仍然会抛 CancelledError★
→ 所以安全的检查顺序是:
if task.cancelled(): ...
elif (exc := task.exception()) is not None: ...
★ "Task exception was never retrieved" 是怎么来的:
Task 内部有个标志 __log_traceback
→ 异常发生时置 True
→ 有人调用 result()/exception() 取走异常时置 False
→ ★Task 被 GC 时如果标志还是 True★ → 调用 loop.call_exception_handler()
→ 默认处理器打印这条警告
★ 三个特征(解释了它为什么难发现):
① ★时机不确定★:取决于 GC 什么时候回收这个 Task
② ★只是日志★:不中断程序、不影响返回值
③ 在容器/生产日志流里★极易被淹没★
→ 所以不能指望它来发现问题,★必须主动处理★
★ 一个最小复现:
async def main():
asyncio.create_task(boom()) # ★连引用都没保存★
await asyncio.sleep(1)
asyncio.run(main())
# 可能什么都不打印,也可能在退出时打印警告 —— ★取决于 GC★
理解异常传播要先分清协程和任务:await coro() 是在调用方的协程栈上执行,异常像普通函数一样向上传播、try/except 能抓到;而 create_task(coro()) 是交给事件循环独立运行,它抛的异常没有调用方的栈可以传播,只能存进 Task 对象等人来取。取走的方式有三种,注意它们的差异:result() 会重新抛出异常、exception() 返回异常对象而不抛、但两者在任务被取消时都会抛 CancelledError——所以安全的检查顺序是先 cancelled() 再 exception()。至于那句 Task exception was never retrieved,机制是:Task 内部有个 __log_traceback 标志,异常发生时置 True、被取走时置 False,Task 被 GC 时如果标志还是 True 就打印警告。它的三个特征解释了为什么难发现:时机不确定(取决于 GC)、只是日志不中断程序、在生产日志流里极易被淹没——所以不能指望它来发现问题,必须主动处理。
二、create_task 的引用陷阱
★ 官方文档的明确警告:
"Save a reference to the result of this function, to avoid a task
disappearing mid-execution. The event loop only keeps weak references
to tasks."
★ 事件循环只持有 Task 的★弱引用★★
→ 如果你没有保存强引用
→ Task 可能在★执行完成之前★被垃圾回收
→ 任务★直接消失★,没有任何提示
✗ asyncio.create_task(background_work()) # ★返回值被丢弃★
✗ [asyncio.create_task(f(i)) for i in range(10)] # ★列表是临时的★
✓ 正确写法(★两步★):
_tasks: set[asyncio.Task] = set() # 模块级
def spawn(coro):
t = asyncio.create_task(coro)
_tasks.add(t) # ★① 强引用★
t.add_done_callback(_tasks.discard) # ★② 完成后移除,防止无限增长★
return t
★ 为什么用 set + discard 而不是 list:
set.discard 是 O(1) 且不存在时不报错
list.remove 是 O(n) 且元素不存在会抛 ValueError
★ 实际影响有多大:
在 CPython 里,因为引用计数的关系,"正在运行的 Task"通常被事件循环的
ready 队列间接引用着,所以★大多数时候不会真的消失★
→ 但这是★实现细节★,在等待 I/O、被挂起的时刻就可能被回收
→ 3.12 之前有过真实的任务丢失案例
★ 结论:不管概率多低,都要保存引用(成本几乎为零)
★ 相关:TaskGroup 不需要你保存引用
async with asyncio.TaskGroup() as tg:
tg.create_task(work()) # ★TaskGroup 自己持有强引用★
→ 这是用 TaskGroup 的又一个理由
★ 另一个易错:在同步代码里创建任务
✗ def sync_func():
asyncio.create_task(...) # ★RuntimeError: no running event loop★
✓ 必须在协程内部,或用 loop.call_soon_threadsafe / run_coroutine_threadsafe
asyncio.create_task() 的返回值必须保存引用——这是官方文档明确警告的:事件循环只持有 Task 的弱引用,不保存强引用的话,Task 可能在执行完成之前被垃圾回收、任务直接消失且没有任何提示。所以 asyncio.create_task(background_work()) 这种「即发即忘」的写法是错的,正确做法是两步:存进一个模块级 set(强引用),并 add_done_callback(set.discard) 在完成时移除(防止集合无限增长)。用 set 而不是 list 是因为 discard 是 O(1) 且元素不存在时不报错。要客观地说:在 CPython 里正在运行的 Task 通常被事件循环的 ready 队列间接引用着,大多数时候不会真的消失,但这是实现细节,在等待 I/O 被挂起的时刻就可能被回收(3.12 之前有过真实案例)——保存引用的成本几乎为零,没理由不做。顺带一提,TaskGroup 自己持有强引用,不需要你操心,这是用它的又一个理由。
三、gather / wait / TaskGroup 的异常语义
★ gather 的默认行为(return_exceptions=False):
results = await asyncio.gather(t1, t2, t3)
→ 任何一个抛异常 → ★立即把这个异常抛给调用方★
→ ★但 t2、t3 仍然在后台继续运行到底★(gather 不会取消它们)
→ 它们如果也抛异常 → ★同样无人认领 → 静默丢失 + 可能的警告★
★ 这就是 gather 最大的问题:
"我以为 gather 抛异常就都结束了" → 实际上剩下的任务还在跑
→ 资源没释放、还在写数据、日志里冒出莫名其妙的输出
✓ 如果一定要用 gather 又想取消其余任务:
tasks = [asyncio.create_task(c) for c in coros]
try:
await asyncio.gather(*tasks)
except Exception:
for t in tasks:
t.cancel() # ★自己取消★
await asyncio.gather(*tasks, return_exceptions=True) # 等它们收尾
raise
★ gather(return_exceptions=True):
results = await asyncio.gather(*coros, return_exceptions=True)
→ ★不抛任何异常★,异常对象混在结果列表里
→ ★必须自己遍历检查★,否则异常被彻底忽略(★比"never retrieved"还隐蔽★)
for coro_name, r in zip(names, results):
if isinstance(r, BaseException): # ★用 BaseException 才能抓到 CancelledError★
logging.error("%s 失败: %r", coro_name, r)
★ 适合"部分失败可接受"的批量场景(如批量拉取,失败的记录下来即可)
★ asyncio.wait:
done, pending = await asyncio.wait(tasks, return_when=FIRST_EXCEPTION)
→ ★永远不抛异常★,返回两个集合
→ 异常在 done 里的 task 上,要自己 task.exception() 取
→ pending 里的任务★仍在运行★,通常要手动 cancel
★ 注意:3.8+ 传裸协程会警告,3.11+ ★只接受 Task/Future★
★ ★TaskGroup(3.11+):结构化并发的正确语义★
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(a())
tg.create_task(b())
except* ValueError as eg:
...
→ 行为:
① 任何一个任务失败 → ★立即取消所有其他任务★
② 等所有任务真正结束(含收尾)
③ 把所有异常打包成 ★ExceptionGroup★ 抛出
④ ★退出 with 块时保证没有遗留的任务在跑★("结构化"的含义)
→ ★CancelledError 不会被包进 ExceptionGroup★(它是"取消"不是"失败")
★ 三者对比总结:
┌──────────────┬────────────┬──────────────┬────────────────┐
│ │ 失败时抛? │ 其余任务 │ 退出后有遗留吗 │
├──────────────┼────────────┼──────────────┼────────────────┤
│ gather │ ★抛第一个★ │ ★继续跑★ │ ★有★ │
│ gather(r_e=T)│ 不抛 │ 全部跑完 │ 没有 │
│ wait │ 不抛 │ pending 里 │ ★有(要自己 cancel)│
│ ★TaskGroup★ │ ExceptionGroup│ ★全部取消★ │ ★没有★ │
└──────────────┴────────────┴──────────────┴────────────────┘
→ ★3.11+ 优先用 TaskGroup★;批量容错场景才用 gather(return_exceptions=True)
三种原语的异常语义差别很大。gather 默认在第一个异常时立即抛给调用方,但其余任务仍在后台跑到底——这是它最大的问题:「我以为 gather 抛异常就都结束了」,实际上剩下的任务还在运行、还在写数据、它们的异常同样无人认领。gather(return_exceptions=True) 不抛任何异常,把异常对象混在结果列表里,必须自己遍历检查(用 isinstance(r, BaseException) 才能连 CancelledError 一起抓到),否则异常被彻底忽略——比 never retrieved 还隐蔽。asyncio.wait 永远不抛异常,返回 (done, pending) 两个集合,异常要自己从 done 里的 task 上取,pending 里的任务还在跑、通常要手动 cancel。而 TaskGroup(3.11+)才是结构化并发的正确语义:任何一个任务失败就立即取消所有兄弟任务、等它们真正结束、把所有异常打包成 ExceptionGroup 抛出,并且退出 with 块时保证没有遗留任务在跑——所以 3.11+ 应该优先用 TaskGroup,只有「部分失败可接受」的批量场景才用 gather(return_exceptions=True)。
四、全局兜底:事件循环的异常处理器
★ loop.set_exception_handler(handler)
捕获"没有任何地方处理"的异步异常,包括:
- Task 的异常无人认领(GC 时触发)
- 回调函数(call_soon/call_later)里抛出的异常
- Transport/Protocol 层的错误
- 异步生成器清理时的错误
def handler(loop, context):
# context 是一个 dict,常见键:
# 'message' 描述
# 'exception' 异常对象(★可能不存在★)
# 'task'/'future'/'handle'/'protocol'/'transport'
exc = context.get("exception")
logging.error("asyncio 未处理异常: %s", context["message"], exc_info=exc)
# ★可以在这里上报到 Sentry / 打点告警★
loop = asyncio.get_running_loop()
loop.set_exception_handler(handler)
★ 默认处理器(loop.default_exception_handler)就是打印到 stderr
★ 想保留默认行为再加自己的:
def handler(loop, context):
loop.default_exception_handler(context) # 先打印
report_to_sentry(context) # 再上报
★ 它抓不到什么(★重要边界★):
✗ 你已经 await 并被 try/except 捕获的异常(那是正常处理)
✗ ★同步代码里的异常★(那是普通的 Python 异常)
✗ 主协程 asyncio.run(main()) 抛出的异常(★直接向上传播给调用方★)
→ 它只是"兜底",★不能代替正确的任务异常处理★
★ asyncio.run 的行为:
asyncio.run(main())
→ main 抛异常 → ★直接向上传播★(不走 exception_handler)
→ 退出前会:取消所有剩余任务 → 等它们结束 → 关闭异步生成器 → 关闭 loop
★ 3.12+ 增加了 asyncio.Runner,可以复用 loop 并自定义这些行为
★ 未处理异常的完整分层(★答题时能说清楚层次很加分★):
① 协程内 try/except ← 最应该处理的地方
② await task 的调用方 ← 任务的"所有者"负责
③ TaskGroup / gather 的调用方 ← 结构化的边界
④ ★loop.set_exception_handler★ ← 兜底日志/告警
⑤ sys.excepthook / 进程退出 ← 最后防线
★ 常见的"看不到异常"排查清单:
□ 是不是 create_task 后没人 await?→ ★加 done_callback★
□ 是不是 gather(return_exceptions=True) 后没检查结果?
□ 是不是 except Exception 把 CancelledError 漏掉了?(★3.8+ 它是 BaseException★)
□ 是不是异常发生在 done_callback 里?(★回调里的异常走 exception_handler★)
□ 日志级别/handler 配置对吗?(asyncio 用 logging.getLogger("asyncio"))
□ 开 ★debug 模式★ 看有没有更多线索
loop.set_exception_handler() 是全局兜底,能捕获「没有任何地方处理」的异步异常:Task 异常无人认领(GC 时触发)、call_soon/call_later 回调里抛出的异常、Transport/Protocol 层的错误、异步生成器清理时的错误。处理器接收 (loop, context),context 里有 message、exception(可能不存在)、task/future 等键——可以在这里统一上报 Sentry 或打点告警;想保留默认打印行为就先调 loop.default_exception_handler(context)。要清楚它的边界:抓不到已被 try/except 正常捕获的异常、抓不到同步代码的异常、也抓不到 asyncio.run(main()) 里主协程抛出的异常(那个直接向上传播)——它只是兜底,不能代替正确的任务异常处理。完整的分层是:协程内 try/except → await task 的调用方 → TaskGroup/gather 的边界 → set_exception_handler 兜底 → sys.excepthook 最后防线,答题时能说清这个层次很加分。
五、后台任务的正确写法
★ "后台任务"指:不需要调用方等待结果的任务(发通知、写日志、清理、心跳)
它们最容易出现"悄悄挂掉"的问题
★ 完整的 spawn 工具(★可直接抄★):
import asyncio, logging
_background: set[asyncio.Task] = set()
def spawn(coro, *, name: str | None = None) -> asyncio.Task:
task = asyncio.create_task(coro, name=name)
_background.add(task) # ★① 强引用防 GC★
task.add_done_callback(_background.discard) # ★② 完成后移除★
task.add_done_callback(_on_done) # ★③ 记录异常★
return task
def _on_done(task: asyncio.Task) -> None:
if task.cancelled():
logging.info("后台任务 %s 被取消", task.get_name())
return
exc = task.exception() # ★取走异常(消除 never retrieved)★
if exc is not None:
logging.error("后台任务 %s 失败", task.get_name(), exc_info=exc)
# ★可以在这里做告警 / 重试 / 熔断★
# 用法
spawn(send_notification(user), name="notify")
★ 三个细节:
① ★给任务起名字★(name=):出问题时日志里能看出是谁
3.8+ 支持 create_task(coro, name=...),task.get_name()
② ★done_callback 里不要抛异常★:它抛的异常会走 exception_handler,
变成"处理异常时又出异常",更难查
③ ★done_callback 是同步函数★,不能 await;需要异步收尾就在里面再 spawn
(但要小心无限递归)
★ 优雅关闭时要等后台任务★:
async def shutdown(timeout=10):
if not _background:
return
logging.info("等待 %d 个后台任务", len(_background))
done, pending = await asyncio.wait(_background, timeout=timeout)
for t in pending:
t.cancel() # ★超时的强制取消★
if pending:
await asyncio.gather(*pending, return_exceptions=True)
★ 更好的方案:用 TaskGroup 管理有生命周期的后台任务
async def service():
async with asyncio.TaskGroup() as tg: # ★退出时保证全部结束★
tg.create_task(heartbeat())
tg.create_task(consume_queue())
await stop_event.wait()
# 退出 with 时会取消这些任务并等它们收尾
★ 缺点:TaskGroup 里一个任务失败会取消全部 → 对"互相独立的后台任务"可能太激进
→ 这种情况在每个任务内部自己 try/except 兜住,别让它抛出去
★ "即发即忘"的正确姿势总结:
✗ asyncio.create_task(coro()) # 无引用、无异常处理
✗ asyncio.ensure_future(coro()) # 同上
✓ spawn(coro(), name="xxx") # ★引用 + 命名 + 异常记录★
✓ 或者放进 TaskGroup(有明确生命周期时)
后台任务(不需要等待结果的任务:发通知、写日志、清理、心跳)最容易悄悄挂掉,所以要有一个统一的 spawn 工具,做三件事:① 存进 set 保持强引用防 GC、② 完成后从 set 移除、③ 在 done_callback 里主动 exception() 取走异常并记日志(这同时也消除了 never retrieved 警告)。三个细节:给任务起名字(create_task(coro, name=...),出问题时日志能看出是谁)、done_callback 里不要抛异常(否则变成「处理异常时又出异常」)、done_callback 是同步函数不能 await。优雅关闭时要等后台任务收尾(asyncio.wait(timeout) 后取消超时的)。更好的方案是用 TaskGroup 管理有明确生命周期的后台任务(退出时保证全部结束),但要注意它「一个失败取消全部」的语义对互相独立的后台任务可能太激进——那种情况应该在每个任务内部自己 try/except 兜住、别让异常抛出去。
六、实践清单与排查
★ 编码清单:
□ ★create_task 的返回值必须保存引用★(用统一的 spawn 工具)
□ ★每个后台任务都要有 done_callback 记录异常★
□ ★3.11+ 优先用 TaskGroup★(自动取消兄弟 + 保证退出时无遗留 + 自带强引用)
□ 用 gather 时想清楚:★失败后其余任务还在跑★,要不要自己 cancel
□ ★gather(return_exceptions=True) 后必须遍历检查结果★
□ 捕获异常用 ★except Exception★,别用裸 except(会吞掉 CancelledError)
□ ★捕获了 CancelledError 一定要重新 raise★(否则任务变成"不可取消")
□ 设置 ★loop.set_exception_handler★ 做兜底告警
□ 给任务命名(name=),日志里才认得出
★ CancelledError 的特殊性(★高频考点★):
★3.8 起 CancelledError 继承自 BaseException 而不是 Exception★
→ except Exception ★抓不到它★(★这是有意设计★,防止业务代码误吞取消信号)
✓ 正确写法:
try:
await work()
except asyncio.CancelledError:
await cleanup()
raise # ★必须重新抛出★
except Exception as e:
logging.exception("业务失败")
★ 排查"任务悄悄挂了"的步骤:
① 开 ★debug 模式★:asyncio.run(main(), debug=True) 或 PYTHONASYNCIODEBUG=1
→ 会报告 "Task was destroyed but it is pending!" 等更多信息
② 检查有没有 ★create_task 后无人 await★ 的地方(grep create_task)
③ 检查 gather(return_exceptions=True) 的结果有没有被检查
④ 加 ★set_exception_handler★ 把所有未处理异常打到日志
⑤ 用 ★asyncio.all_tasks()★ 观察任务数量是否异常增长(泄漏)
⑥ py-spy dump 看事件循环线程当前在做什么
★ 监控建议:
- 后台任务的失败次数(在 _on_done 里打点)
- ★asyncio.all_tasks() 的数量★(持续增长 = 任务泄漏)
- 事件循环滞后(见调试专题)
- "never retrieved" 警告的出现次数(★有一条就说明有问题★)
★ 一句话总结实践:
★"每一个 Task 都必须有明确的所有者——要么被 await,
要么在 TaskGroup 里,要么有 done_callback 处理异常。
没有所有者的 Task 就是一颗定时炸弹。"★
实践清单里最关键的五条:create_task 的返回值必须保存引用、每个后台任务都要有 done_callback 记录异常、3.11+ 优先用 TaskGroup、gather(return_exceptions=True) 后必须检查结果、捕获了 CancelledError 一定要重新 raise。关于 CancelledError 有个高频考点:Python 3.8 起它继承自 BaseException 而不是 Exception,所以 except Exception 抓不到它——这是有意设计,防止业务代码里宽泛的异常处理误吞取消信号(如果你捕获了却不重新抛出,任务就变成「不可取消」了)。排查「任务悄悄挂了」的步骤是:开 debug 模式(会额外报告 Task was destroyed but it is pending!)→ grep 检查 create_task 后无人 await 的地方 → 检查 return_exceptions=True 的结果有没有被遍历 → 加 set_exception_handler 兜底 → 用 asyncio.all_tasks() 观察任务数量是否持续增长(泄漏信号)。一句话总结:每一个 Task 都必须有明确的所有者——要么被 await、要么在 TaskGroup 里、要么有 done_callback 处理异常;没有所有者的 Task 就是一颗定时炸弹。
记忆钩子:「★asyncio 里 Task 的异常不会自动向上传播,而是『存在 Task 对象里等人来取』★——因为协程被 create_task 交给事件循环后就独立运行了,★没有调用方的栈可以传播★。取走的三种方式:await task、task.result()(重新抛出)、task.exception()(返回异常对象不抛,★但任务被取消时这两个都会抛 CancelledError★,所以要先判 cancelled())。★没人取 = 异常静默消失★,唯一痕迹是 Task 被 GC 时打印的
Task exception was never retrieved——但它★时机不确定(取决于 GC)、只是一条日志、在生产日志流里极易被淹没★,不能指望它发现问题。★create_task 的返回值必须保存强引用★(官方明确警告:事件循环只持有弱引用,任务可能执行中途被 GC 掉),正确做法是存进模块级 set + add_done_callback(set.discard)。★三种原语的异常语义完全不同★:gather 默认★抛第一个异常但其余任务仍在后台跑到底★(它们的异常同样无人认领);gather(return_exceptions=True) ★一个都不抛、异常混在结果列表里必须自己遍历检查★(比 never retrieved 还隐蔽,用 isinstance(r, BaseException) 才能连 CancelledError 一起抓);asyncio.wait 永远不抛、pending 里的任务还在跑;★TaskGroup(3.11+)才是结构化并发的正确语义★——一个失败就取消所有兄弟、等它们收尾、把异常打包成 ExceptionGroup 抛出、★退出 with 时保证没有遗留任务★,而且它自己持有强引用。兜底用 ★loop.set_exception_handler★(能抓无人认领的异常、回调里的异常、传输层错误;但抓不到已被 try/except 处理的、同步代码的、以及 asyncio.run 主协程抛出的)。★CancelledError 从 3.8 起继承 BaseException★,except Exception 抓不到它(有意设计),★捕获后必须重新 raise★。后台任务的正确写法=★强引用 + 命名 + done_callback 里取异常记日志★;一句话:★每个 Task 都必须有明确的所有者,没有所有者的 Task 就是定时炸弹★。」
七、常见误区与追问
- 误区:
asyncio.create_task()创建的任务抛异常后,会像同步代码一样冒泡到主流程。 不会——协程一旦被包装成Task交给事件循环,它就独立于创建它的代码运行,没有「调用方的栈」可供异常传播。异常会被存进 Task 对象,等着有人调用await task、task.result()或task.exception()来取走。没人取走的话,异常就静默消失了:你的后台任务已经挂了,主流程毫不知情,业务表现为「某个功能突然不工作了」,日志里也没有堆栈。唯一的痕迹是 Task 被 GC 时打印的Task exception was never retrieved警告——但它的出现时机取决于垃圾回收、只是一条日志、且在生产环境的日志流里极易被淹没。所以必须主动处理:要么await,要么加done_callback取走异常。 - 误区:
asyncio.create_task(coro())不保存返回值也没关系,反正任务已经交给事件循环了。 官方文档明确警告过这个陷阱:事件循环只持有 Task 的弱引用,如果没有其他强引用,Task 可能在执行完成之前就被垃圾回收,任务直接消失——没有异常、没有日志,就是「这段代码好像没执行」。虽然在 CPython 里正在运行的 Task 通常被事件循环的 ready 队列间接引用着、大多数时候不会真的丢失,但这属于实现细节,在任务等待 I/O 被挂起的时刻就存在被回收的窗口(3.12 之前有过真实的任务丢失案例)。正确写法是把 Task 存进一个模块级set保持强引用,并用add_done_callback(set.discard)在完成时移除以防集合无限增长。用set而非list是因为discard是 O(1) 且元素不存在时不报错。 - 误区:
await asyncio.gather(...)抛出异常后,其他任务也都停止了。 它们还在后台继续运行到底。gather的默认行为(return_exceptions=False)是「第一个异常立即抛给调用方」,但它不会取消其余任务——那些任务会一直跑完,继续占用连接、继续写数据、继续打日志,而且它们自己抛出的异常同样无人认领(又是一批静默丢失的异常)。这会造成很多困惑:「我明明捕获异常返回了,为什么日志里还在冒出后续输出」「为什么数据被写了两遍」。如果一定要用gather又想让失败时全部停止,必须自己保存 task 列表、在 except 里逐个cancel()、再gather(*tasks, return_exceptions=True)等它们收尾。3.11+ 直接用TaskGroup就自带这个语义,不用手写。 - 误区:用了
gather(return_exceptions=True)就安全了,不会丢异常。 它确实不会抛异常,但异常被当成「结果」放进了返回列表——如果你不遍历检查,就等于把所有失败都彻底忽略了,这比never retrieved还隐蔽(后者至少还有一条警告日志,前者连警告都没有)。正确做法是遍历结果并逐个判断:if isinstance(r, BaseException): logging.error(...)。注意这里要用BaseException而不是Exception——因为CancelledError从 Python 3.8 起继承自BaseException,用Exception会漏掉「任务被取消」这种情况。这个模式适合「部分失败可接受」的批量场景(批量拉取、批量通知),关键是失败要被记录和统计,而不是悄悄咽下去。 - 误区:
except Exception能捕获所有异步异常,包括任务取消。 抓不到CancelledError——Python 3.8 起它继承自BaseException而不是Exception,这是有意的设计变更:防止业务代码里宽泛的except Exception把「取消信号」误吞掉,导致任务变成不可取消(task.cancel()调了却停不下来,优雅关闭直接超时)。正确的写法是分开处理:except asyncio.CancelledError: await cleanup(); raise(必须重新抛出,否则就是在拒绝被取消)和except Exception: logging.exception(...)。反过来也要注意:如果你确实想在取消时做清理,finally块或except CancelledError里的await可能再次被取消(尤其在超时强制关闭时),必要时用asyncio.shield()保护关键的收尾操作。 - 追问:
task.result()和task.exception()有什么区别,什么时候用哪个? 两者都会「取走」异常(从而消除never retrieved警告),区别在于行为:result()在任务有异常时会重新抛出它(正常完成则返回返回值),适合「我要拿结果,出错就让它继续往上抛」的场景;exception()返回异常对象本身而不抛出(没有异常则返回None),适合「我只想检查一下有没有出错、并记录日志」的场景——这正是done_callback里应该用的。两者有一个共同的陷阱:任务被取消时,result()和exception()都会抛CancelledError,所以安全的检查顺序是先if task.cancelled(): return再取exception()。另外两者都要求任务已经完成(task.done()为真),否则会抛InvalidStateError——在done_callback里调用天然满足这个条件。 - 追问:
TaskGroup相比gather好在哪?什么时候仍然该用gather?TaskGroup(3.11+)实现的是结构化并发,四个保证是gather给不了的:① 任何一个任务失败,立即取消所有兄弟任务(不会有「其余任务还在后台裸奔」);② 退出async with块时保证所有任务都已结束(包括收尾逻辑执行完),不存在遗留任务;③ 所有异常被打包成ExceptionGroup,用except*可以按类型分别处理,不会只看到「第一个」;④ 它自己持有任务的强引用,不用担心 GC 问题。仍然适合用gather的场景只有一类:「部分失败可接受、且希望所有任务都跑完」的批量操作——比如批量拉取 100 个 URL,失败的记录下来即可、不应该因为一个失败就取消其余 99 个,这时gather(return_exceptions=True)更合适(TaskGroup的「一损俱损」语义反而不对)。此外要兼容 3.10 及以下时也只能用gather(或第三方的anyio.create_task_group)。 - 追问:
loop.set_exception_handler能兜住所有异常吗?应该怎么用? 它是兜底而不是万能。能抓到:Task 异常无人认领(Task 被 GC 时触发)、call_soon/call_later注册的回调里抛出的异常、Transport/Protocol 层的错误、异步生成器清理时的错误。抓不到:已经被try/except正常处理的异常(那本来就不该报)、同步代码里的普通异常(那走的是正常的 Python 异常机制)、以及asyncio.run(main())中主协程抛出的异常(它直接向上传播给asyncio.run的调用方)。用法上有两个要点:① 想保留默认的打印行为就在自定义处理器里先调loop.default_exception_handler(context),再做自己的上报;②context里的exception键可能不存在(有些错误只有message),取的时候要用context.get("exception")。它最大的价值是统一告警——把所有「漏网」的异步异常送到 Sentry 或打点系统,这样即使某处忘了处理,你也能在监控上看到,而不是等业务报障。
八、加强记忆
asyncio 里 Task 的异常不会自动向上传播,而是「存在 Task 对象里等人来取」——因为协程被 create_task 交给事件循环后就独立运行了,没有调用方的栈可以传播。取走的三种方式:await task、task.result()(重新抛出)、task.exception()(返回异常对象不抛);但任务被取消时后两者都会抛 CancelledError,所以要先判 cancelled()。没人取 = 异常静默消失,唯一痕迹是 Task 被 GC 时打印的 Task exception was never retrieved——它时机不确定(取决于 GC)、只是一条日志、在生产日志流里极易被淹没,不能指望它来发现问题。create_task 的返回值必须保存强引用(官方明确警告:事件循环只持有弱引用,任务可能在执行中途被 GC 掉),做法是存进模块级 set + add_done_callback(set.discard)。三种原语的异常语义完全不同:gather 默认抛第一个异常但其余任务仍在后台跑到底(它们的异常同样无人认领);gather(return_exceptions=True) 一个都不抛、异常混在结果列表里,必须自己遍历检查(比 never retrieved 更隐蔽,且要用 isinstance(r, BaseException) 才能连 CancelledError 一起抓);asyncio.wait 永远不抛、pending 里的任务还在跑;而 TaskGroup(3.11+)才是结构化并发的正确语义——一个失败就取消所有兄弟、等它们收尾、把异常打包成 ExceptionGroup 抛出、退出 with 时保证没有遗留任务,而且它自己持有强引用。兜底用 loop.set_exception_handler(能抓无人认领的异常、回调里的异常、传输层错误;但抓不到已被 try/except 处理的、同步代码的、以及 asyncio.run 主协程抛出的)。CancelledError 从 3.8 起继承 BaseException,except Exception 抓不到它(有意设计),捕获后必须重新 raise。后台任务的正确写法 = 强引用 + 命名 + done_callback 里取异常记日志。一句话总结:每个 Task 都必须有明确的所有者——要么被 await、要么在 TaskGroup 里、要么有 done_callback 处理异常;没有所有者的 Task 就是一颗定时炸弹。