JavaLYG

Python asyncio 一个任务失败同伴仍运行:用 gather 与 TaskGroup 明确取消边界

Python 异步批处理里,一个任务报错,外层已经返回失败,另一个任务却继续写文件或发请求。问题可能不在重试,而在 gather 的失败语义。下面用 Python 3.11+ 的隔离实验,比较 gather 与 TaskGroup,明确谁负责取消、等待和清理,避免把“捕获异常”误当成“整批已停”。

一、先分清:报错返回,不等于同伴停止

默认的 asyncio.gather 会并发运行传入的可等待对象。某个对象抛出普通异常时,第一个异常立即传播给等待 gather 的调用方;其他对象不会因此自动取消。外层看见失败,兄弟任务仍可能继续执行,这不是日志延迟。

主函数立即结束时,asyncio.run 关闭事件循环,也可能取消剩余任务。这不是 gather 的失败联动。实验应保持事件循环运行,保留任务引用,核对 done 和 cancelled。

二、用会合点复现,不靠猜睡眠时间

把代码保存为 demo.py,用 Python 3.11+ 运行。Event 确保慢任务先启动,再抛错;另一个 Event 暂停它以读取状态。实验只操作内存。

import asyncio

async def 主程序():
    已启动 = asyncio.Event()
    放行 = asyncio.Event()
    async def 慢任务():
        已启动.set()
        await 放行.wait()
        return "慢任务仍完成"
    async def 失败任务():
        await 已启动.wait()
        raise ValueError("模拟失败")
    慢 = asyncio.create_task(慢任务())
    汇总 = asyncio.gather(失败任务(), 慢)
    try:
        await 汇总
    except ValueError:
        print("慢任务已结束:", 慢.done())
        print("再取消汇总:", 汇总.cancel())
    放行.set()
    print(await 慢)

asyncio.run(主程序())

输出两个 False 和“慢任务仍完成”:同伴尚未结束,已完成的汇总对象不能再取消它;主动放行后同伴正常返回。cancel 要对准生命周期。

三、按业务关系选择,而不是全局替换

如果这些子任务合起来才算一个有效结果,例如同时读取一份报告的必需材料,一个失败后其他结果已无意义,适合用 TaskGroup。它从 Python 3.11 起提供,进入 async with 后用组的 create_task 登记任务。

组内任务首次出现非 CancelledError 异常,会向剩余组内任务请求取消;上下文退出会等待这些任务结束,再传播异常组。这里的“组内”很重要:绕过组、直接 asyncio.create_task 创建的游离任务,不会自动被它托管。

gather普通异常与TaskGroup组内普通异常的取消和等待边界
默认 gather 与 TaskGroup 的失败收尾;取消是协作请求,不是业务回滚。

独立批量探测希望其他项继续完成时,可用 gather(return_exceptions=True)。它把异常放进结果列表;须逐项分类、保留目标标识,列表长度不等于成功数量。

四、让 TaskGroup 收尾,也让资源真正释放

仍用会合点制造故障,finally 负责清理,组外核对状态。实际项目替换为关闭连接、释放锁等动作,不要让清理覆盖原始异常。

import asyncio

async def 主程序():
    已启动 = asyncio.Event()
    已清理 = []
    async def 慢任务():
        try:
            已启动.set()
            await asyncio.Event().wait()
        finally:
            已清理.append("资源已释放")
    async def 失败任务():
        await 已启动.wait()
        raise ValueError("模拟失败")
    try:
        async with asyncio.TaskGroup() as 组:
            慢 = 组.create_task(慢任务())
            组.create_task(失败任务())
    except* ValueError as 错误组:
        print("本次失败:", len(错误组.exceptions))
    print("慢任务已取消:", 慢.cancelled())
    print(已清理)

asyncio.run(主程序())

这段输出本次失败 1、慢任务已取消 True,以及资源已释放。只在正常退出 TaskGroup 后,才从成功任务读取 result;被取消的任务调用 result 会抛 CancelledError。不要把部分成功结果在失败分支里悄悄拼成完整成功。

五、异常组不是一条普通 ValueError

TaskGroup 可能把多个普通异常组合成 ExceptionGroup。except ValueError 不能匹配包在组内的 ValueError;except* ValueError 会处理匹配的子组,未匹配部分继续传播。不要用 except* Exception 加 pass 把程序错误一起吞掉。

嵌套异常组的 exceptions 长度不等于全部叶子数;示例只有一个直接失败。记录完整异常树。KeyboardInterrupt、SystemExit 有特殊规则。

六、取消不是硬停止,更不是撤销请求

cancel 只是安排在合适的执行机会抛出 CancelledError。协程长期执行 CPU 循环、同步阻塞调用,或吞掉取消继续工作,都会拖慢收尾。捕获取消只为做必要处理,处理后通常应重新 raise;优先用 try/finally,别把取消改成一个正常结果。

需要预算时,可以把 TaskGroup 放进 asyncio.timeout 上下文,并在该超时上下文外捕获 TimeoutError;这个接口同样要求 Python 3.11+。但等待任务清理仍可能超过设定时间,协作超时不是强制杀线程。

已经发出的请求、写入的数据,不会因为协程取消而自动撤销。付款、部署或消息发送仍需幂等键、状态查询与补偿流程。TaskGroup 管任务生命周期,不替数据库事务,也不承诺外部业务恰好一次。

七、故障分支对照表

现象优先核对处理
外层失败,同伴仍运行默认 gather 普通异常按依赖关系选择 TaskGroup 或显式取消并等待
gather.cancel 返回 False汇总是否已经完成保留并管理尚未完成的任务引用
组退出迟迟不完成同步阻塞、吞取消、清理耗时定位无法协作的任务,不承诺硬截止
except ValueError 没接住是否包装在异常组使用 except* 精确处理,保留未匹配错误
结果列表中混有异常return_exceptions=True逐项核对,不把异常对象当业务数据

旧版本须显式取消未完成任务,再等待收尾;不要把新语法复制到 3.10。

八、用行为验收,别只看没有 traceback

验收清单:确认运行时支持所用接口;默认 gather 失败后同伴仍可完成;完成后的汇总取消不影响同伴;TaskGroup 失败后同伴取消且 finally 已执行;正常路径结果完整;未匹配异常仍向外传播;独立批次中的每个错误都有归属。

本文在 Python 3.14.3 的独立内存实验中完成 13 项检查,包含两段正文代码实跑、未完成 gather 的取消传播、异常结果列表、外层超时清理和异常组筛选。未接入真实 HTTP、线程池或生产业务;这些边界需要在目标系统另行验收。

先明确哪些任务必须共同成功,再查谁取消、谁等待清理。报错不等于收尾完成。

参考资料:Python 官方:协程、任务组与取消;PEP 654:异常组与 except*。

🔕 评论已关闭