← 返回题目列表

asyncio 里任务的异常去哪了?为什么会出现 exception was never retrieved?

中等 第 20 / 27 题 更新于 2026/08/01
asyncio异常处理Task后台任务

简化版

在 asyncio 里,Task 抛出的异常不会自动向上传播**——它被存在 Task 对象里,等着有人通过 await tasktask.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 tasktask.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 里有 messageexception可能不存在)、task/future 等键——可以在这里统一上报 Sentry 或打点告警;想保留默认打印行为就先调 loop.default_exception_handler(context)。要清楚它的边界抓不到已被 try/except 正常捕获的异常、抓不到同步代码的异常、也抓不到 asyncio.run(main()) 里主协程抛出的异常(那个直接向上传播)——它只是兜底,不能代替正确的任务异常处理。完整的分层是:协程内 try/exceptawait 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+ 优先用 TaskGroupgather(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 tasktask.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 tasktask.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 起继承 BaseExceptionexcept Exception 抓不到它(有意设计),捕获后必须重新 raise。后台任务的正确写法 = 强引用 + 命名 + done_callback 里取异常记日志。一句话总结:每个 Task 都必须有明确的所有者——要么被 await、要么在 TaskGroup 里、要么有 done_callback 处理异常;没有所有者的 Task 就是一颗定时炸弹