← 中文资源

简体中文版本

Python asyncio — gather() 首个失败即取消兄弟任务

理解 asyncio.gather 在其中一个失败时为何中止其余任务,用 return_exceptions 保住兄弟任务,并处理扇出失败的整体语义。

阅读英文原文

症状

  五行代码的最小复现就能立刻厘清语义:ok() 任务已推进 90%,boom() 先抛 ValueError,gather 默认立即传播该异常并取消 ok()——它本可以完成的结果永远丢失。生产环境的指纹是:兄弟任务日志在同一主失败时间戳上布满 CancelledError,调用方只收到一个异常、零个部分结果;10 个任务的批量作业报告为“1 个错误 + 9 个取消”。嵌套 gather 会成倍放大——一个叶子的失败取消整棵本可健康的子树。

诊断思路

  先跑最小复现确认默认 gather 契约(首异常传播 + 取消其余);再全局审计 gather 调用点,任务之间互不依赖的每一处都是 return_exceptions=True 候选;最后把日志里的 CancelledError 时间戳与主失败对齐,同步出现即取消级联而非各自独立的 bug。

根因

  asyncio.gather 默认收到第一个异常就抛出并取消其余未完成的可等待对象。对独立任务而言,这把部分失败升级成整体失败:接近完成的工作被主动取消、结果丢失,调用方只拿到那一个异常。

修复与验证

  独立任务传 return_exceptions=True,再把结果分区为值与异常;分区时必须把 CancelledError 单列出来重新抛出——return_exceptions 会把取消也当异常捕获,吞掉它会破坏协作式停机。确实需要全有或全无的组,用 Python 3.11 的 asyncio.TaskGroup 做结构化取消:显式表达整体语义,并用 ExceptionGroup 报告全部失败而不是碰巧最先到达的那个。验证时搭一个扇出测试器:5 个任务、1 个按指定延迟失败。修复后应交付 4 个值加 1 个异常对象,兄弟日志 grep 零 CancelledError;再调整失败延迟重复运行,确认与失败顺序无关;TaskGroup 变体则断言 ExceptionGroup 恰含注入的失败、所有非失败任务在组退出前完成。

代码示例

import asyncio

async def ok():
    await asyncio.sleep(0.1)
    return 'ok'

async def boom():
    await asyncio.sleep(0.01)
    raise ValueError('x')

async def main():
    result = await asyncio.gather(ok(), boom())

asyncio.run(main())
results = await asyncio.gather(
    *coros,
    return_exceptions=True,
)

values, failures = [], []
for item in results:
    if isinstance(item, BaseException):
        if isinstance(item, asyncio.CancelledError):
            raise item          # never swallow shutdown signals
        failures.append(item)
    else:
        values.append(item)
async with asyncio.TaskGroup() as tg:
    tasks = [tg.create_task(coro()) for coro in coros]
# raises ExceptionGroup with every failure, cancels cleanly