原创

Python TaskGroup 的 ExceptionGroup:业务失败和清理失败怎样一起处理

并发任务失败以后,日志里出现了两种异常:一个来自原本要执行的校验,另一个来自被取消任务的清理。只记住“第一个任务出错,其他任务取消”还不够,清理本身仍可能失败,最终离开任务组的异常也就不一定只有一条。

TaskGroup 的价值不只是少写几次 await。它让一组子任务在退出作用域前完成收尾,并把需要向外报告的异常放进异常组。调用方如果只抓住第一个异常打印,反而丢掉了结构化并发已经替你保留的信息。

下面固定 Python 3.12。TaskGroup 与 except* 从 3.11 起可用;旧版本没有这套语法,不能靠安装一个普通库让解释器读懂它。

让第二个失败发生在取消清理里

示例不靠两个随机延时“同时出错”。先让一个任务进入受保护区域,再启动真正失败的任务。任务组取消等待者,等待者在 finally 中产生另一个错误。这样可以确定两种错误的来路。

保存为 group_failure.py:

import asyncio


async def wait_for_work(entered, cleaned):
    try:
        entered.set()
        await asyncio.Event().wait()
    finally:
        cleaned.set()
        raise OSError("closing temporary result failed")


async def validate():
    raise ValueError("record id is missing")


async def main():
    entered = asyncio.Event()
    cleaned = asyncio.Event()
    seen = []
    tasks = []
    try:
        async with asyncio.TaskGroup() as group:
            tasks.append(group.create_task(wait_for_work(entered, cleaned)))
            await entered.wait()
            tasks.append(group.create_task(validate()))
    except* ValueError as errors:
        seen.append(("validation", len(errors.exceptions)))
        print("validation:", len(errors.exceptions))
    except* OSError as errors:
        seen.append(("cleanup", len(errors.exceptions)))
        print("cleanup:", len(errors.exceptions))

    assert cleaned.is_set()
    assert all(task.done() for task in tasks)
    assert sorted(seen) == [("cleanup", 1), ("validation", 1)]
    print("all child tasks have finished")


if __name__ == "__main__":
    asyncio.run(main())

执行 python3 group_failure.py,会得到两类错误计数,以及所有子任务已经结束的说明。每个分支拿到的 errors 依然是异常组,不是单个 ValueError 或 OSError。当前例子每类只有一个直接子异常,因此计数为 1;嵌套任务组产生树形结构以后,直接子节点数量不等于叶子错误总数。

finally 主动抛错只是为了展示报告行为,不是建议业务清理总要报错。清理真正失败时,应保留这个事实;清理成功则让原有取消继续传播。把它改成 except BaseException: pass,虽然让示例不再吵闹,也会把退出控制与真实故障一起吃掉。

except* 会拆分匹配分支,而不是任选一个分支

普通 except 命中一个分支后,不会继续尝试后面的同级分支。except* 面向异常组,可以将不同叶子按类型拆分,因此上面的两个分支都会执行。

未匹配的部分不会因为某个分支处理成功就消失。保存下面的小实验,或直接在解释器运行:

try:
    try:
        raise ExceptionGroup("batch", [ValueError("bad"), TypeError("wrong")])
    except* ValueError as matched:
        assert len(matched.exceptions) == 1
        print("handled validation")
except ExceptionGroup as remaining:
    assert len(remaining.exceptions) == 1
    assert isinstance(remaining.exceptions[0], TypeError)
    print("still unhandled:", type(remaining.exceptions[0]).__name__)

外层仍然收到包含 TypeError 的异常组。这样的分工适合“输入错误转换成业务报告,其他编程错误继续冒泡”,不适合把所有分支一律转成“批次失败”而丢掉类型。

同一个 try 不能混用普通 except 与 except* 分支,需要像示例一样再套一层。except* 分支内也不能直接用 return、break、continue 跳出;先收集处理结果,待异常处理完成再决定函数返回值,控制流会更清楚。

取消不是普通业务失败项

任务组遇到非取消异常,会取消其余任务并等待它们结束;被取消任务正常传播的 CancelledError 不会像普通错误那样被加入最终失败列表。否则一次真实故障引起十个兄弟任务取消,报告就会出现十个没有新增诊断价值的“错误”。

但取消期间发生的 OSError 不同,它说明资源没有正常完成退出,因此本例会报告它。KeyboardInterrupt 和 SystemExit 还存在特殊处理,不能把 TaskGroup 的所有退出情况都简化成“总会抛 ExceptionGroup”。

调用方的取消也要保持传播。不要为了把子任务错误转成页面提示,就在外层随手吞掉 BaseException。用户请求结束、服务准备退出,与某条数据格式不对,是两类不同控制信号。

任务组也不会撤销已经发生的外部写入。一个任务完成了文件发布,另一个任务随后失败,取消和异常组都不能把那个文件变回旧版。需要整体成败的业务,仍应使用明确的暂存、事务或补偿协议。结构化并发约束的是子任务的寿命,不是替所有外部系统开启分布式事务。

异常组的树形结构还保留了作用域关系。外层有两个子任务组,每组各有一个失败时,报告里的嵌套层次可以帮助区分错误来自哪批工作。直接把所有叶子拼成一个列表虽然便于计数,却会丢失这层组织信息;面向用户可以生成简短摘要,内部日志仍应保存原异常和 traceback。

也不要从 TaskGroup 的报错列表推断哪一个故障先发生。调度顺序、取消进入清理的时机以及异常生成位置都会影响表现。需要追踪业务时间线,应在关键动作发生时记录任务标识和阶段,而不是事后从列表下标还原一个并不存在的严格因果顺序。

异常对象中可能带有输入片段、文件路径或下游错误正文。把完整异常链留给受控诊断,并不意味着原样交给公开接口。业务报告保留稳定错误码与记录编号,内部记录继续保留类型和链条,两份输出有不同受众,但都应能关联到同一次处理。

结果收集要与失败政策一起设计

如果每一项失败都允许被单独记录,工作任务可以捕获预期的业务异常并返回结构化结果;只有无法继续维持任务组不变量的故障才向外抛。这样做是在明确区分“这一项失败”和“整组应该停止”,而不是为了让 TaskGroup 永远不抛错。

相反,若一项失败就必须停止整组,应让异常到达任务组。另一个任务即使已经结束,也可能持有有效结果,能否对外使用要按业务合同决定,不能因为 Python 对象还在就当作整批成功。

观察任务组代码时,可以把退出点当成一条边界:越过它以后,子任务应该已经收尾;如果失败,异常树里保留了哪些事实。只要错误仍然有类型和来路,后续日志、重试与人工处理才不需要从一条拼接后的字符串里猜现场。

正文到此结束
评论插件初始化中...
Loading...