← 返回题目列表

Python 3.11 的 ExceptionGroup 和 except* 是什么?解决了什么问题?

困难 第 26 / 27 题 更新于 2026/07/31
ExceptionGroupexcept starPEP 654异常处理

简化版

ExceptionGroup(Python 3.11+,PEP 654)解决的是「一次操作同时失败了好几件事,但传统异常机制只能抛一个」的问题。并发跑 10 个任务、其中 3 个失败了,旧写法只能挑一个抛出(另外两个的信息就丢了)或者自己收集成列表(就不再是异常、没有 traceback、也没法被 except 捕获)。ExceptionGroup("批量失败", [ValueError(...), TimeoutError(...), KeyError(...)]) 把多个异常打包成一个异常对象(可以嵌套成树),完整保留每个子异常的 traceback。配套的新语法 except* 用来「从组里挑出自己关心的那类,其余继续往外传」:

try:
    ...  # 可能抛出 ExceptionGroup
except* ValueError as eg:      # eg 是一个"只含 ValueError 的子组"
    ...
except* (TimeoutError, OSError) as eg:
    ...

关键语义:① except* 的每个子句最多执行一次,但多个子句都可能被执行(普通 except 是「只进第一个匹配的分支」);② as eg 拿到的永远是一个 ExceptionGroup(不是单个异常),要遍历 eg.exceptions;③ 没有被任何 except* 匹配到的子异常会自动重新抛出(不会被静默吞掉);④ exceptexcept* 不能在同一个 try 里混用。最常见的接触点是 asyncio.TaskGroup(3.11+)——它失败时抛的就是 ExceptionGroup。核心记忆:ExceptionGroup 是「异常的集合」,except* 是「按类型从集合里筛出一部分处理、剩下的继续传」。

详细版

传统异常 vs 异常组

维度普通异常ExceptionGroup
数量一次只能抛一个一次携带多个(可嵌套成树)
捕获语法except Xexcept* X(也能被 except ExceptionGroup 整体捕获)
分支执行只进第一个匹配的多个 except* 都可能执行,各最多一次
as 绑定单个异常实例总是一个组(含匹配到的子异常)
未匹配部分——自动重新抛出(不会静默丢失)
典型来源任何地方asyncio.TaskGroup、批量操作、多路清理
# ① 手动构造与捕获
def load_all(paths):
    results, errors = [], []
    for p in paths:
        try:
            results.append(open(p).read())
        except OSError as e:
            errors.append(e)                       # 先收集,别急着抛
    if errors:
        raise ExceptionGroup("部分文件加载失败", errors)   # ★一次抛出全部★
    return results

try:
    load_all(["a.txt", "b.txt", "c.txt"])
except* FileNotFoundError as eg:
    print("缺失文件:", [e.filename for e in eg.exceptions])
except* PermissionError as eg:
    print("无权限:", len(eg.exceptions), "个")
# 未被匹配的(比如 IsADirectoryError)会自动继续向外抛

# ② except* 的核心语义:多个分支都可能执行
def demo():
    raise ExceptionGroup("mixed", [ValueError("v"), TypeError("t"), KeyError("k")])

try:
    demo()
except* ValueError as eg:
    print("A:", eg.exceptions)      # A: (ValueError('v'),)
except* TypeError as eg:
    print("B:", eg.exceptions)      # B: (TypeError('t'),)   ★A 执行了 B 还会执行★
# KeyError 没人处理 → 剩余部分作为新的 ExceptionGroup 继续抛出

# ③ eg 永远是组,不是单个异常
try:
    raise ExceptionGroup("g", [ValueError("only one")])
except* ValueError as eg:
    # print(eg.args)          # ✗ 这是组的 args,不是 ValueError 的
    for e in eg.exceptions:   # ✓ 要遍历
        print(type(e).__name__, e)

# ④ 嵌套结构与工具方法
inner = ExceptionGroup("inner", [ValueError("v1"), ValueError("v2")])
outer = ExceptionGroup("outer", [inner, TypeError("t")])
print(len(outer.exceptions))            # 2(一个子组 + 一个 TypeError)
sub = outer.subgroup(ValueError)        # 只保留 ValueError(★保持嵌套结构★)
match_, rest = outer.split(TypeError)   # 一分为二:匹配的 / 剩余的
print(sub)                              # ExceptionGroup('outer', [ExceptionGroup('inner', [...])])

# ⑤ asyncio.TaskGroup(最常遇到 ExceptionGroup 的地方)
import asyncio
async def main():
    try:
        async with asyncio.TaskGroup() as tg:      # 3.11+
            tg.create_task(fail_a())
            tg.create_task(fail_b())
    except* ValueError as eg:
        print("有", len(eg.exceptions), "个任务抛了 ValueError")

# ⑥ 只关心"有没有出错"时,也可以整体捕获(不需要 except*)
try:
    load_all(["x"])
except ExceptionGroup as eg:            # ★普通 except 照样能捕获整个组★
    print("失败了", len(eg.exceptions), "项")

⚠️ 四条必须记住的规则:① exceptexcept* 不能在同一个 try 语句里混用(语法错误)——因为两者的分支执行模型完全不同(前者只进一个分支,后者可能进多个),混用无法给出一致语义。② except* 子句里不能写 ExceptionGroupBaseExceptionGroup 本身except* ExceptionGroup: 会报 TypeError),因为「从组里筛出组」逻辑上会无限套娃;想整体处理就用普通的 except ExceptionGroup。③ except* 块里 break/continue/return 是被禁止的SyntaxError)——因为多个分支都可能执行,提前跳出会让「剩余异常重抛」的语义无法保证。④ BaseExceptionGroupExceptionGroup 的分工:前者是基类、可以容纳 KeyboardInterrupt/SystemExit 这类 BaseException;后者要求所有子异常都是 Exception 的子类。ExceptionGroup(...) 构造时如果传入了 BaseException,会直接 TypeError——需要混装时用 BaseExceptionGroup(它会根据内容自动返回合适的类型)。

完整版教学

一、要解决的问题:一次失败好几件事

场景:并发请求 10 个下游服务,其中 3 个失败了(超时、404、连接拒绝)

3.11 之前的三种做法,全都有损失:

  做法 A:抛第一个错
    for r in results:
        if isinstance(r, Exception): raise r
    ✗ 另外两个错误的信息★彻底丢失★(用户以为只有一个问题)
    ✗ 抛哪个取决于顺序,重跑一次可能报不同的错

  做法 B:收集成列表返回
    return successes, errors
    ✗ errors 不是异常 → 不会中断流程、不会被 except 捕获、调用方容易忘记检查
    ✗ traceback 丢失(只剩异常对象,看不到出错的调用栈)

  做法 C:自定义一个 MultiError 异常(trio 等库的老方案)
    class MultiError(Exception):
        def __init__(self, errors): self.errors = errors
    ✗ 每个库自己发明一套,互不兼容
    ✗ except ValueError 抓不到"包在 MultiError 里的 ValueError"
       → 调用方必须知道你用了 MultiError,写法完全不通用

PEP 654 的答案:把"多个异常"变成语言级的一等公民
  raise ExceptionGroup("批量失败", [TimeoutError(...), HTTPError(...), OSError(...)])
  ✓ 是真正的异常:能 raise、能被 except 捕获、每个子异常保留完整 traceback
  ✓ 标准化:asyncio、trio、任何库都用同一个类型
  ✓ 配套 except* 语法:调用方可以"只处理我关心的那类,其余继续往外传"

★ 一句话概括动机:并发和批量操作天然会产生"多个失败",
  而 1970 年代设计的异常机制假设"一次只错一件事"——PEP 654 补上了这个缺口

理解 ExceptionGroup 要先理解它填的是什么坑:传统异常机制假设「一次只错一件事」,而并发与批量操作天然会同时产生多个失败。3.11 之前只有三条路,每条都有硬伤:只抛第一个错会丢失其余错误信息(而且抛哪个取决于顺序,不可复现);返回错误列表就不再是异常——不会中断流程、不会被 except 捕获、调用方很容易忘记检查,而且 traceback 全没了;自定义 MultiError 则是各库各造一套且不通用——调用方写 except ValueError 根本抓不到「包在 MultiError 里的 ValueError」。PEP 654 的做法是把「多个异常」提升为语言级的一等公民:它是真正的异常(可 raise、可 catch、子异常保留完整 traceback),是标准类型(asyncio、trio、第三方库共用),并且配了专门的 except* 语法来做「部分处理、部分继续传播」。

二、ExceptionGroup 的结构:一棵异常树

构造:ExceptionGroup(message, [子异常列表])
  必须至少有一个子异常(空列表会 ValueError)
  子异常列表里可以再放 ExceptionGroup → ★形成一棵树★

  eg = ExceptionGroup("outer", [
          ValueError("v"),
          ExceptionGroup("inner", [TypeError("t1"), TypeError("t2")]),
       ])

  树形结构:
    ExceptionGroup("outer")
    ├── ValueError("v")
    └── ExceptionGroup("inner")
        ├── TypeError("t1")
        └── TypeError("t2")

  eg.message      → "outer"
  eg.exceptions   → (ValueError('v'), ExceptionGroup('inner', [...]))  ★只有直接子节点★
  len(eg.exceptions) → 2   (★不是 3!嵌套的要递归数★)

两个核心工具方法(理解 except* 的关键):
  eg.subgroup(条件)   → 只保留匹配的部分,★保持原有嵌套结构★,全不匹配则返回 None
  eg.split(条件)      → 返回 (匹配的组, 剩余的组),任一边可能是 None

  条件可以是:异常类型、类型元组、或一个 lambda(接受异常返回 bool)
  eg.split(lambda e: getattr(e, "errno", None) == 2)     # 按属性筛

  算例:
    eg = ExceptionGroup("g", [ValueError("a"), TypeError("b"), ValueError("c")])
    m, r = eg.split(ValueError)
    m → ExceptionGroup("g", [ValueError('a'), ValueError('c')])
    r → ExceptionGroup("g", [TypeError('b')])
    ★ split 出来的两半都保留原来的 message 和树形层级 ★

两个类的分工:
  BaseExceptionGroup   基类,可容纳 KeyboardInterrupt / SystemExit 等 BaseException
  ExceptionGroup       子类,要求★所有子异常都是 Exception★(传 BaseException 会 TypeError)
  ★ 调用 BaseExceptionGroup(msg, excs) 时,如果 excs 全是 Exception,
    它会★自动返回 ExceptionGroup 实例★(构造函数里做了类型提升)

traceback 的展示(3.11 起解释器专门做了渲染):
  + Exception Group Traceback (most recent call last):
  |   ...
  | ExceptionGroup: 批量失败 (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | Traceback ...
    | TimeoutError: 超时
    +---------------- 2 ----------------
    | Traceback ...
    | FileNotFoundError: ...
  → ★每个子异常都有自己完整的 traceback★,这是它相比"错误列表"最大的优势

ExceptionGroup 本质是一棵异常树message 是这一层的描述,exceptions直接子节点(可能又是组)。这里有个高频误解——len(eg.exceptions) 只数直接子节点,不递归,含嵌套时需要自己递归统计。两个工具方法是理解 except* 的钥匙:subgroup(条件) 筛出匹配的部分并保持原有嵌套结构(全不匹配返回 None),split(条件) 把树一分为二返回 (匹配的, 剩余的);条件可以是异常类型、类型元组,也可以是任意 lambda(按 errno、按错误码筛都行)。另外要分清两个类:BaseExceptionGroup 是基类,能容纳 KeyboardInterruptSystemExitExceptionGroup 要求所有子异常都是 Exception(塞 BaseExceptionTypeError)——直接调 BaseExceptionGroup(...) 时若内容全是 Exception,它会自动返回 ExceptionGroup 实例。最后,3.11 的解释器为异常组做了专门的 traceback 渲染,每个子异常都带自己完整的调用栈,这正是它比「返回错误列表」强的地方。

三、except* 的语义:和 except 完全不同的分支模型

普通 except:从上往下找,★只执行第一个匹配的分支★
  try: raise ValueError()
  except ValueError: print("A")     ← 只有这里执行
  except Exception:  print("B")     ← 不执行

except*:★每个分支都会被考察,可能多个都执行★(各自最多一次)
  try:
      raise ExceptionGroup("g", [ValueError("v"), TypeError("t"), KeyError("k")])
  except* ValueError as eg:  print("A", eg.exceptions)   # A (ValueError('v'),)
  except* TypeError  as eg:  print("B", eg.exceptions)   # B (TypeError('t'),)
  # KeyError 无人认领 → ★作为新的 ExceptionGroup 继续向外抛出★

执行过程(内部就是反复 split):
  剩余 = 原始组
  对每个 except* 子句 C:
      匹配, 剩余 = 剩余.split(C 的类型)
      if 匹配 is not None:
          绑定 as 变量 = 匹配(★一个组★)
          执行子句体
  循环结束后:if 剩余 is not None: 重新抛出剩余

四条派生规则(都能从上面这个过程推出来):
  ① as 绑定的永远是 ExceptionGroup,★不是单个异常★
     → 即使只匹配到一个 ValueError,拿到的也是含它的组,要 eg.exceptions[0]
  ② 未被匹配的部分自动重抛 → ★不会静默吞掉错误★(这是好事)
  ③ 子句体里抛出的新异常,会和"剩余部分"合并成一个新组一起抛出
  ④ 单个异常(非组)遇到 except* 时,会被★自动包装成组★再匹配
     try: raise ValueError("x")
     except* ValueError as eg:  print(eg)   # ExceptionGroup('', (ValueError('x'),))
     → 所以 except* 对"可能是单个也可能是组"的情况同样有效

三条硬性限制(语法层面):
  ✗ except 和 except* 不能在同一个 try 里混用            → SyntaxError
  ✗ except* ExceptionGroup / BaseExceptionGroup         → TypeError(从组里筛组,无限套娃)
  ✗ except* 块里写 break / continue / return             → SyntaxError
     (因为多个分支都可能执行,提前跳出会破坏"剩余异常重抛"的保证)
  ✓ else / finally 照常可用

except* 最反直觉的地方是分支模型完全不同于 except:普通 except 是「从上往下找,只进第一个匹配的分支」;except* 是「每个子句都被考察,可能多个都执行,各自最多一次」。内部实现其实就是反复调用 split():拿剩余部分和当前子句的类型做拆分,匹配到就绑定并执行子句体,然后把剩余部分交给下一个子句;所有子句处理完后,如果还有剩余,就重新抛出。理解了这个循环,四条派生规则就都是自然结果:as 绑定的永远是一个组(哪怕只匹配到一个异常,也要 eg.exceptions[0] 才能拿到它)、未匹配的部分自动重抛(不会静默吞错,这是安全设计)、子句体里新抛的异常会和剩余部分合并、以及单个非组异常遇到 except* 会被自动包装成组(所以 except* 对「可能是单个也可能是组」的情况同样有效)。三条语法限制也都源于这个模型:不能和 except 混用(语义无法统一)、不能 except* ExceptionGroup(从组里筛组会无限套娃)、块里不能 break/continue/return(提前跳出会破坏「剩余重抛」的保证)。

四、和 asyncio.TaskGroup:最常遇到它的地方

3.11 引入的 asyncio.TaskGroup 是 ExceptionGroup 的最大使用方:

  async with asyncio.TaskGroup() as tg:
      tg.create_task(fetch("a"))
      tg.create_task(fetch("b"))
      tg.create_task(fetch("c"))
  # 退出 with 时等待全部完成;★任何任务失败 → 取消其余任务 → 抛 ExceptionGroup★

  处理:
  try:
      async with asyncio.TaskGroup() as tg: ...
  except* TimeoutError as eg:
      logger.warning("超时 %d 个", len(eg.exceptions))
  except* HTTPError as eg:
      for e in eg.exceptions: report(e)

和老的 asyncio.gather 对比:
  ┌────────────────────────┬──────────────────────────────────────┐
  │ gather(..., return_exceptions=False) │ 第一个异常立刻抛出,★其余任务仍在后台跑★(易泄漏)│
  │ gather(..., return_exceptions=True)  │ 异常混在结果列表里返回,★不是异常★,要自己遍历判断 │
  │ TaskGroup(3.11+)                   │ 出错自动取消兄弟任务 + 抛 ExceptionGroup(结构化并发)│
  └────────────────────────┴──────────────────────────────────────┘
  → TaskGroup 是"结构化并发":任务的生命周期不会超出 with 块,
    错误不会丢失,这两点是 gather 做不到的

★ 一个必须知道的配套变化:asyncio.timeout / cancel 与 CancelledError
  TaskGroup 里某个任务失败时,其余任务会收到 CancelledError;
  CancelledError 继承自 BaseException(不是 Exception)
  → 组里混有 CancelledError 时,抛出的是 BaseExceptionGroup
  → 想捕获取消:except* asyncio.CancelledError(但通常不该拦截取消)

  另外 3.11 的 asyncio.timeout() 超时后抛的是 TimeoutError,
  而不是把 CancelledError 泄漏出来 —— 这也是同一轮改造的一部分

trio / anyio 的历史:
  trio 早就有 MultiError(同样的思路),PEP 654 相当于把它标准化进语言,
  trio 后来也迁移到了 ExceptionGroup

日常最容易撞见 ExceptionGroup 的地方是 asyncio.TaskGroup(3.11+)async with 块退出时等待所有任务,任何一个任务失败就取消其余任务并抛出 ExceptionGroup。这相比老的 gather 是实质性的改进——gather(return_exceptions=False) 只抛第一个异常,其余任务还在后台跑(容易泄漏、且它们的异常可能变成「never retrieved」警告);gather(return_exceptions=True) 则把异常混在结果列表里返回,它们不再是异常,调用方必须自己遍历判断(忘了就静默吞错)。TaskGroup 实现的是结构化并发:任务生命周期不超出 with 块,错误一个都不丢。有个配套细节要知道:CancelledError 继承自 BaseException,所以当组里混有取消异常时,抛出的是 BaseExceptionGroup 而不是 ExceptionGroup——用 except* Exception 是抓不到取消的(通常你也不该拦截取消)。

五、什么时候自己用它,什么时候别用

适合抛 ExceptionGroup 的场景(共同点:多个★独立★的失败都值得被知道):
  ① 批量操作:批量导入 1000 条记录,第 3/17/402 条失败
     → 用户需要知道全部三条,而不是"第 3 条失败了"就结束
  ② 并发调用:同时请求多个下游,多个失败
  ③ 多路清理:关闭 5 个资源,3 个 close() 抛错(ExitStack 内部就用它)
  ④ 校验/解析:一次性报告所有校验错误(而不是修一个报一个)
  ⑤ 插件/回调:广播事件给 N 个订阅者,多个抛错

★ 不适合的场景(别为了用而用):
  ✗ 顺序流程里的单点失败 —— 就一个错,普通异常更清晰
  ✗ 重试逻辑的中间失败 —— 应该记日志,不是攒起来一起抛
  ✗ 只是想"带上更多上下文" —— 用 raise X from e(异常链)或自定义异常属性
  ✗ 库的公开 API 突然改抛 ExceptionGroup → ★破坏性变更★
     调用方的 except ValueError 会失效(组不是 ValueError)
     → 要么发大版本,要么保持"只有一个错时抛原异常、多个才抛组"

典型写法模板:
  errors = []
  for item in items:
      try:
          process(item)
      except Exception as e:
          e.add_note(f"处理 {item.id} 时失败")     # ★3.11+ 的 add_note,给异常加上下文★
          errors.append(e)
  if errors:
      if len(errors) == 1:
          raise errors[0]                        # 只有一个 → 保持传统行为(可选)
      raise ExceptionGroup(f"{len(errors)}/{len(items)} 项失败", errors)

兼容 3.10 及以下:
  pip install exceptiongroup      # backport 了 ExceptionGroup/BaseExceptionGroup
  ★ 但 except* 是★语法★,backport 不了 → 3.10 上只能:
    except BaseExceptionGroup as eg:
        matched, rest = eg.split(ValueError)
        if matched: 处理(matched)
        if rest: raise rest
  → 也就是手写 except* 的等价逻辑(这反过来说明 except* 就是 split 的语法糖)

判断该不该抛 ExceptionGroup,标准是「是否存在多个独立的失败、且每个都值得被调用方知道」:批量导入、并发请求、多路资源清理、一次性报告全部校验错误、广播回调——这些都该用。反过来,顺序流程里的单点失败、重试中的中间失败、只是想附加上下文(应该用 raise X from e 或 3.11 新增的 e.add_note("上下文"))都不该用。最需要警惕的是库的公开 API 从抛普通异常改成抛 ExceptionGroup 是破坏性变更——调用方原有的 except ValueError 会突然失效(组不是 ValueError 的实例),稳妥做法是「只有一个错时抛原异常、多个错才抛组」或干脆发大版本。兼容性上:exceptiongroup 这个 backport 包能在 3.10 及以下提供 ExceptionGroup 类,但 except* 是语法、无法 backport,老版本只能手写 split + 条件重抛——这也反过来印证了 except* 本质就是 split() 的语法糖。

六、traceback、日志与常见处理姿势

① 打印/记录异常组(logging 已支持,会渲染整棵树)
   try:
       ...
   except* Exception as eg:
       logger.exception("批量任务失败")        # ★会打印每个子异常的完整 traceback★

   手动格式化:
   import traceback
   traceback.print_exception(eg)              # 3.11 起自动渲染成 +-+---- 树形

② 递归遍历所有叶子异常(因为 exceptions 只有直接子节点)
   def leaves(eg):
       for e in eg.exceptions:
           if isinstance(e, BaseExceptionGroup):
               yield from leaves(e)
           else:
               yield e
   print([type(e).__name__ for e in leaves(outer)])

③ 按业务条件筛(split 支持任意 predicate)
   retryable, fatal = eg.split(lambda e: isinstance(e, (TimeoutError, ConnectionError)))
   if retryable: schedule_retry(retryable)
   if fatal: raise fatal                       # 不可重试的继续往外抛

④ 只想知道"有没有出错"→ 普通 except 就够
   except ExceptionGroup as eg:
       metrics.incr("batch.failed", len(eg.exceptions))
   ★ 注意:普通 except ExceptionGroup 捕获后★不会自动重抛剩余★(整个组都被你接管了)

⑤ 给子异常加上下文(3.11 的 add_note)
   e.add_note(f"item_id={item.id}")           # traceback 里会额外显示这一行
   → 批量场景里定位"是哪一条数据出错"的利器,比自定义异常类轻量

⑥ 常见处理姿势总结
   想按类型分别处理、剩余继续传   → except*
   只想整体感知/统计/兜底         → except ExceptionGroup
   想按业务属性筛(错误码等)     → except ExceptionGroup + eg.split(predicate)
   写库、要兼容旧版               → 收集错误 + 只有一个时抛原异常

实践中的几个姿势值得固化。日志方面 logger.exception() 已经原生支持异常组,会把整棵树连同每个子异常的 traceback 打印出来(3.11 的 traceback 模块专门做了 +-+---- 树形渲染)。遍历时记得 eg.exceptions 只含直接子节点,要拿到所有叶子异常得写个递归生成器。split() 接受任意 predicate,所以「可重试的 vs 致命的」这类业务维度的划分可以一行完成:retryable, fatal = eg.split(lambda e: isinstance(e, (TimeoutError, ConnectionError))),然后重试一半、抛出另一半。如果只想统计和兜底,普通 except ExceptionGroup 就够了——但要注意它和 except* 的区别:普通 except 捕获后整个组都被你接管、不会自动重抛剩余部分。最后强烈推荐 3.11 的 e.add_note(f"item_id={item.id}"):在批量场景里给每个子异常打上「是哪条数据」的标记,比自定义异常类轻量得多,且会直接显示在 traceback 里。

记忆钩子:「ExceptionGroup(3.11,PEP 654)补的是『异常机制假设一次只错一件事』这个缺口——并发和批量天然会同时失败多件事,旧的三条路都有硬伤:只抛第一个错会丢信息、返回错误列表就不再是异常(不中断、不可捕获、没 traceback)、自定义 MultiError 各库一套且 except ValueError 抓不到。它把『多个异常』变成语言级一等公民:一棵异常树(message + exceptions,★exceptions 只含直接子节点、不递归★),每个子异常保留完整 traceback,配 subgroup/split 两个工具方法。★配套语法 except* 的分支模型和 except 完全不同:except 只进第一个匹配分支,except* 是每个子句都被考察、可能多个都执行(各最多一次),内部就是反复 split。四条派生规则:as 绑定的永远是『组』不是单个异常(要 eg.exceptions[0])、没被任何子句匹配的部分会自动重新抛出(不静默吞错)、子句里新抛的异常会和剩余合并、单个非组异常遇到 except* 会被自动包装成组。★三条硬限制:except 和 except* 不能混用(SyntaxError)、不能写 except* ExceptionGroup(TypeError,从组里筛组会套娃)、块里不能 break/continue/return。★最常撞见它的地方是 asyncio.TaskGroup(结构化并发:一个任务失败就取消兄弟任务并抛组,比 gather 的『其余任务在后台裸奔』和『异常混在结果列表里』都强);组里混入 CancelledError 时抛的是 BaseExceptionGroup(CancelledError 是 BaseException)。★写库时注意:把公开 API 从普通异常改成抛组是破坏性变更,稳妥做法是『只有一个错时抛原异常、多个才抛组』;3.10 及以下可以 pip install exceptiongroup 拿到类,但 except* 是语法、backport 不了,只能手写 split + 条件重抛。」

七、常见误区与追问

  • 误区:except* ValueError as eg 里的 eg 就是那个 ValueError 不是——as 绑定的永远是一个 ExceptionGroup,即使组里只匹配到一个 ValueError,你拿到的也是「包含这一个 ValueError 的子组」。所以 eg.argsstr(eg) 拿到的是的信息而不是子异常的,必须遍历 eg.exceptions 才能拿到真正的异常对象(只有一个时是 eg.exceptions[0])。这个设计是必然的:except* 的语义是「从组里筛出匹配的那一部分」,而「一部分」在结构上仍然是组(还可能保留原来的嵌套层级)。写代码时把 except* 的处理块默认写成 for e in eg.exceptions: 循环,就不会出错。
  • 误区:except*except 一样,只会执行第一个匹配的分支。 这是最核心的语义差异:普通 except 从上往下找、只进第一个匹配的分支;except*依次考察每一个子句,多个子句都可能被执行(各自最多一次)。原因在于一个异常组里可能同时含有 ValueErrorTimeoutError,两者都需要被各自的处理逻辑接管。内部实现就是反复 split():每个子句从「剩余部分」里拆走自己匹配的,处理完所有子句后,如果还有剩余就重新抛出——这也意味着 except* 不会静默吞掉你没写到的异常类型,这是它相比「捕获整个组然后凭印象处理」更安全的地方。
  • 误区:可以在一个 try 里同时写 exceptexcept*,谁匹配用谁。 语法直接禁止(SyntaxError)。因为两者的分支执行模型根本冲突:except 是「只进一个分支且不重抛剩余」,except* 是「可进多个分支且自动重抛剩余」,混在一起无法定义一致的语义(先按哪套走?剩余部分还重抛吗?)。同一个 try 里要么全用 except(把组当成一个整体异常处理),要么全用 except*(按子异常类型分别处理)。另外两条相关限制也要记住:except* ExceptionGroup / except* BaseExceptionGroupTypeError(「从组里筛出组」逻辑上无限套娃),想整体处理就用普通 except ExceptionGroupexcept* 块里禁止 break/continue/returnSyntaxError),因为提前跳出会让「剩余异常重抛」的保证失效。
  • 误区:只要用了 except*,就不用担心漏掉某类异常。 恰恰相反——没被任何 except* 匹配到的子异常会继续向外抛出,这是设计上的安全保证(不静默吞错),但也意味着你的函数仍然会抛异常。很多人以为写了几个 except* 就等于「都处理干净了」,结果剩余部分作为一个新的 ExceptionGroup 继续向上传播,在更外层炸掉。如果确实想兜住所有情况,要么加一个 except* Exception as eg: 兜底子句,要么改用普通 except ExceptionGroup(它会接管整个组,不重抛任何部分)——注意这两种写法的语义差别很大,选择时要清楚自己是想「筛选处理」还是「整体接管」。
  • 误区:len(eg.exceptions) 就是失败的总数。 eg.exceptions 只包含直接子节点,异常组是可以嵌套的树——ExceptionGroup("outer", [ValueError(), ExceptionGroup("inner", [TypeError(), TypeError()])])len(eg.exceptions)2 而不是 3。嵌套在实际场景里很常见(比如 TaskGroup 里的任务本身又用了 TaskGroup,或库把多层批量操作的失败逐层打包)。要统计真正的失败数或遍历所有具体异常,需要写一个递归生成器:遇到 BaseExceptionGroupyield from 递归下去,否则 yield 这个叶子异常。同理,eg.subgroup()eg.split()递归处理并保持嵌套结构的,它们返回的结果同样可能是多层的树。
  • 追问:asyncio.TaskGroup 相比 asyncio.gather 好在哪,和 ExceptionGroup 是什么关系? gather 有两种模式,各有硬伤:return_exceptions=False(默认)在第一个任务抛异常时立刻把它抛给调用方,但其余任务仍在后台继续运行——没人等待它们、它们的异常会变成「Task exception was never retrieved」警告,资源也可能泄漏;return_exceptions=True 则把异常混在结果列表里返回,此时它们不再是异常(不中断流程、不会被 except 捕获),调用方必须自己遍历 isinstance(r, Exception) 判断,忘了就是静默吞错。TaskGroup(3.11+)实现的是结构化并发async with 块退出前必然等待所有任务结束,任何一个任务失败会自动取消其余兄弟任务,然后把所有失败(含取消导致的异常)打包成 ExceptionGroup 抛出。两个保证是 gather 给不了的:任务生命周期不会超出 with没有任何错误会丢失。所以 TaskGroupExceptionGroup 最主要的产生者,两者是同一轮(PEP 654 + 结构化并发)改造的两半。
  • 追问:ExceptionGroupBaseExceptionGroup 有什么区别,什么时候会遇到后者? BaseExceptionGroup基类,可以容纳任意 BaseException(包括 KeyboardInterruptSystemExitasyncio.CancelledError 这些不继承自 Exception 的异常);ExceptionGroup 是它的子类,要求所有子异常都是 Exception 的实例——构造时传入一个 BaseException 会直接 TypeError。有个贴心设计:直接调用 BaseExceptionGroup(msg, excs) 时,如果 excs 里全都是 Exception,它会自动返回 ExceptionGroup 实例。最常遇到 BaseExceptionGroup 的场景是 asyncioCancelledError 继承自 BaseException,所以 TaskGroup 里发生取消时抛出的就是 BaseExceptionGroup——这意味着 except* Exception 抓不到取消(这通常正是你想要的,取消不应该被业务代码拦下来),而 except BaseExceptionGroup 才能整体兜住。写捕获逻辑时要想清楚自己是否真的要处理 BaseException 级别的东西。
  • 追问:我的库要不要改成抛 ExceptionGroup?兼容性怎么处理? 先明确这是破坏性变更:调用方现有的 except ValueError 会突然失效,因为 ExceptionGroup 不是 ValueError 的实例——升级后他们的错误处理会静默失灵(异常一路往上抛到顶层)。稳妥的做法有两条:① 「一个错抛原异常、多个错才抛组」——保持单失败场景的向后兼容,只有真正出现多个独立失败时才抛组,并在文档和 CHANGELOG 里写清楚;② 发布主版本号并在迁移指南里明确说明。版本兼容方面:pip install exceptiongroup 这个官方 backport 能在 Python 3.10 及以下提供 ExceptionGroup/BaseExceptionGroup 类和 add_note 等能力,但 except* 是语法特性、无法 backport(3.10 上包含 except* 的文件连 import 都会 SyntaxError),旧版本只能手写等价逻辑:except BaseExceptionGroup as eg: 里用 matched, rest = eg.split(X) 处理匹配部分、再 raise rest 抛出剩余——这也正说明了 except* 本质上就是 split() 加条件重抛的语法糖。

八、加强记忆

ExceptionGroup(Python 3.11,PEP 654)补的是「传统异常机制假设一次只错一件事」这个缺口——并发与批量操作天然会同时产生多个失败,而旧的三条路都有硬伤:只抛第一个错丢失其余信息(且抛哪个取决于顺序)、返回错误列表就不再是异常(不中断流程、不可 except、没有 traceback)、自定义 MultiError各库一套且 except ValueError 抓不到。它把「多个异常」变成语言级一等公民:本质是一棵异常树message + exceptions,注意 exceptions 只含直接子节点、不递归),每个子异常保留完整 traceback,并提供 subgroup(条件)/split(条件) 两个递归且保持嵌套结构的工具方法(条件可以是类型,也可以是任意 predicate)。配套语法 except* 的分支模型和 except 完全不同except 只进第一个匹配的分支,except*依次考察每个子句、可能多个都执行(各最多一次),内部就是反复 split。由此派生四条规则:as 绑定的永远是「组」而不是单个异常(要 eg.exceptions[0])、没被任何子句匹配的部分会自动重新抛出(不静默吞错,但也意味着函数仍会抛异常)、子句里新抛的异常会与剩余合并、单个非组异常遇到 except* 会被自动包装成组三条硬限制exceptexcept* 不能混用(SyntaxError)、不能写 except* ExceptionGroupTypeError,从组里筛组会套娃)、块里禁止 break/continue/return最常撞见它的地方是 asyncio.TaskGroup(结构化并发:一个任务失败即取消兄弟任务并抛组,胜过 gather 的「其余任务后台裸奔」和「异常混在结果列表里」);由于 CancelledError 继承自 BaseException,混入取消时抛的是 BaseExceptionGroupexcept* Exception 抓不到)。工程上:批量导入、并发调用、多路清理、一次性报告全部校验错误才该用它,顺序流程的单点失败别用;给子异常加上下文用 3.11 的 e.add_note(...)把公开 API 从普通异常改成抛组是破坏性变更,稳妥做法是「一个错抛原异常、多个才抛组」;3.10 及以下可 pip install exceptiongroup 拿到类,但 except* 是语法、无法 backport,只能手写 split + 条件重抛。