登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python asyncio.gather 与 TaskGroup 取消语义怎么选

来源:17golang原创

时间:2026-09-12 15:29:06 237浏览 收藏

在并发请求、批量读取或并行计算里,asyncio.gather()asyncio.TaskGroup 都能同时运行多个协程,但它们对“一个任务失败后怎么办”的答案完全不同。简单记忆是:只想收集一组彼此独立的结果,可以用 gather;这些任务属于同一个业务操作,任何一个失败都应该让其余任务停下并统一收尾,优先用 TaskGroup

选择的关键不是哪个 API 更快,而是谁负责取消兄弟任务、谁等待清理完成,以及调用方最终接收到一个异常还是一组异常。
要点速览
  • gather 默认按输入顺序返回结果;首个异常会立即传播,但其他任务不会因此自动取消。
  • TaskGroup 适合有共同生命周期的任务:一个子任务失败,剩余任务会被取消,退出上下文前会等待它们结束。
  • 协程捕获 CancelledError 后应在清理完成后继续抛出,避免破坏结构化并发的取消协议。

下面的代码只演示语义,图示也是根据代码实体绘制的结构示意,并不代表本机运行截图。

先看两种 API 管的到底是什么

gather(*aws) 接收一组可等待对象,把成功值按传入顺序聚合成列表。它关心的是“结果怎么回来”。TaskGroup 则是异步上下文管理器,任务必须在组的生命周期内创建和完成,它关心的是“这一组任务何时一起结束”。

场景更合适的选择原因
独立查询,允许部分任务继续gather一个查询失败不必打断其他查询
一次订单操作拆成多个子任务TaskGroup失败后统一取消并等待收尾
必须拿到每个任务的成功/异常值gather(return_exceptions=True)异常被放进结果列表,调用方自行分类
Python asyncio.gather 与 TaskGroup 的结果聚合边界和任务组生命周期关系示意图
图1:结构示意图对比 gather 的结果聚合边界与 TaskGroup 的任务组生命周期,不是运行截图。

异常传播不同,兄弟任务的命运也不同

使用默认参数 return_exceptions=False 时,gather 会把第一个抛出的异常传给等待它的调用方。这个动作并不等于取消其他 awaitable:它们可能继续运行,甚至在调用方已经进入 except 后才完成。如果调用方在捕获异常后才调用已结束的 gather.cancel(),也不能补取消已经继续运行的兄弟任务。

TaskGroup 的规则更像一个失败即收口的边界。某个子任务抛出非 CancelledError 异常后,剩余任务会被取消;上下文管理器会等待这些任务完成清理,再把异常组合成 ExceptionGroupBaseExceptionGroup 抛出。要分别处理异常类型,可以使用 Python 的 except*

import asyncio

async def fetch_part(name, delay, should_fail=False):
    # 每个子任务只负责自己的资源;取消时仍然执行 finally。
    try:
        await asyncio.sleep(delay)
        if should_fail:
            raise RuntimeError(f"{name} 返回错误")
        return name
    except asyncio.CancelledError:
        # 清理完成后继续抛出,不能把取消伪装成成功。
        print(f"{name} 收到取消信号")
        raise
    finally:
        # 真实项目中可在这里关闭连接、删除临时文件或归还令牌。
        pass

async def load_together():
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(fetch_part("用户", 0.2))
            group.create_task(fetch_part("库存", 0.4, should_fail=True))
            group.create_task(fetch_part("优惠", 1.0))
    except* RuntimeError as errors:
        # TaskGroup 会把并发边界内的非取消异常组合后交给这里。
        for error in errors.exceptions:
            print(error)

asyncio.run(load_together())

在这个例子里,“优惠”不是因为自己的逻辑失败,而是因为“库存”失败触发了组级取消。若把三项换成 gather,默认行为是先把库存异常传出,用户和优惠任务不会自动被取消。这就是两者最容易造成生产事故的差别:调用方以为批次已经结束,实际上还有任务在后台修改状态。

Python TaskGroup 失败后取消兄弟任务并等待 CancelledError 清理的静态关系示意图
图2:静态关系示意图展示失败任务、组级取消、兄弟任务和 finally 清理之间的约束,不是实际运行结果。

把取消责任写进协程,而不是写在调用方猜

无论选择哪个 API,子协程都应把清理放进 finally。如果确实需要观察取消信号,可以捕获 CancelledError 做日志或释放动作,但动作完成后通常要重新抛出。吞掉这个异常会让上层误以为任务正常完成,也可能让 TaskGroupasyncio.timeout() 的内部取消机制失去预期。

还要区分“取消 gather 本身”和“一个被 gather 收集的 Task 被取消”:前者会取消尚未完成的 awaitable;后者在 gather 中按 CancelledError 处理,并不会连带取消其他任务。需要把每个结果和异常都作为数据处理时,才使用 return_exceptions=True,并明确检查列表里的异常对象,不能直接当作业务结果。

按业务边界落地的判断清单

  • 任务相互独立,某项失败后其他项仍有价值:使用 gather,并在调用方设计好异常与后台任务的归属。
  • 任务共同组成一次不可拆分的操作:使用 TaskGroup,让失败、取消和清理在同一个上下文内闭合。
  • 需要“成功项 + 失败项”完整报告:使用 gather(return_exceptions=True),先把异常转成明确的报告结构。
  • 子任务会再创建子任务,或存在嵌套超时:优先考虑 TaskGroup,避免任务脱离父操作的生命周期。

最终可以用一句工程判断收尾:gather 更像“把几份结果装进一个列表”,TaskGroup 更像“为一组相关任务建立责任边界”。先画清楚失败后的责任,再决定 API,通常比先写代码再追取消问题省得多。

常见问题

TaskGroup 能不能像 gather 一样直接返回列表?

不能直接返回聚合列表。需要保存 create_task() 返回的 Task,在上下文退出后读取各自的 result();若组失败,先处理异常组。

gather 的 return_exceptions=True 会取消兄弟任务吗?

不会。它只是把异常作为列表元素返回;是否重试、忽略或终止其他任务仍由调用方决定。

为什么捕获 CancelledError 后还要 raise?

取消是上层生命周期控制的一部分。清理完成后继续传播,父任务才能知道该任务确实被取消,结构化并发也才能正确完成退出。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>