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

Python asyncio TaskGroup 一个任务失败时其他任务怎么收尾

来源:17golang原创

时间:2026-09-08 00:47:02 475浏览 收藏

asyncio.TaskGroup 并发跑多个任务时,最容易误判的一点是:一个任务抛出普通异常,其他任务不会继续“各跑各的”。TaskGroup 会取消仍在运行的兄弟任务,等待它们完成清理,再把非取消异常组合成 ExceptionGroup 抛出。正确的收尾方式是把资源释放放进 finally,清理结束后不要吞掉 CancelledError

排查这类问题时,先确认失败来源,再确认兄弟任务是否进入 finally,最后才处理 ExceptionGroup。超时本质上也是取消当前任务和子任务,不能把 TimeoutError 当成每个子任务都会单独抛出的错误。
要点速览
  • 首个非 CancelledError 的任务异常会触发 TaskGroup 取消剩余任务。
  • 子任务必须在 finally 释放连接、文件或临时状态,取消清理完成后继续抛出取消信号。
  • except* 处理的是异常组的匹配子集;超时和外部取消要在更外层判断。

为什么一个任务失败后其他任务也会停

TaskGroup 是结构化并发的边界。下面的例子让快速任务失败,让慢任务停在等待处:

import asyncio

async def worker(name: str, delay: float, fail: bool = False) -> None:
    try:
        await asyncio.sleep(delay)
        if fail:
            raise RuntimeError(f"{name} 读取上游失败")
        print(f"{name} 完成")
    finally:
        # 无论成功、失败还是取消,都在这里释放资源
        print(f"{name} 执行清理")

async def main() -> None:
    async with asyncio.TaskGroup() as group:
        group.create_task(worker("fast", 0.1, fail=True))
        group.create_task(worker("slow", 5))

asyncio.run(main())

fast 抛出 RuntimeError 后,slow 会收到取消请求并执行 finally。上下文管理器退出时会等待组内任务结束,因此不能把离开 async with 理解成“立即强杀”。如果清理代码本身又等待网络或锁,退出时间也会随之变长。

Python asyncio TaskGroup 中失败任务、兄弟任务取消与 finally 清理的静态关系图
图1:TaskGroup 边界内,普通异常连接到兄弟任务的取消请求,所有任务都要经过清理边界。

CancelledError 要清理,但不要吞掉

取消不是业务失败,它是 asyncio 用来通知协程停止工作的控制信号。官方文档建议用 try/finally 做可靠清理;如果显式捕获 CancelledError,清理完成后通常应继续抛出。

async def fetch_with_session(session, url: str) -> bytes:
    try:
        # 业务代码可能在这里被 TaskGroup 或 timeout 取消
        return await session.get_bytes(url)
    except asyncio.CancelledError:
        # 只做轻量记录或释放动作,不把取消改写成成功
        print(f"取消请求:{url}")
        raise
    finally:
        # 关闭本协程独占的临时资源;共享连接池不要在这里误关
        await session.release_request(url)

最危险的写法是 except asyncio.CancelledError: return None。它会让上层以为任务正常结束,还可能干扰 TaskGroup 或超时上下文内部依赖的取消机制。只有确实要抑制取消时,才需要同时理解任务的取消状态,并承担改变控制流的后果。

用 ExceptionGroup 找到真正失败的任务

TaskGroup 退出后,多个非取消异常会组合起来。普通的 except RuntimeError 不能替代对异常组的处理,应使用 except* 按类型匹配:

async def run_batch() -> None:
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(load_profile())
            group.create_task(load_orders())
    except* (TimeoutError, ConnectionError) as errors:
        # 这里只处理可重试的网络类错误,其余异常继续向上
        for error in errors.exceptions:
            print(f"可重试异常:{error!r}")
    except* ValueError as errors:
        # 数据格式错误通常应记录并修复输入,不要盲目重试
        print(f"输入数据异常数量:{len(errors.exceptions)}")

except* 的每个分支拿到的是匹配后的异常组,不保证只有一个叶子异常。不要在分支里把所有错误都标成成功;未匹配部分会继续传播,正好能保留真正的故障证据。

asyncio timeout、TaskGroup、外部取消与 ExceptionGroup 的静态边界关系图
图2:超时和外部取消从组外进入,子任务通过 CancelledError 收到信号,业务异常再由 except* 分类处理。

asyncio.timeout 应该放在哪一层

如果一批任务共享一个总时限,把 asyncio.timeout 放在 TaskGroup 外层最容易表达意图:总时限到达时,当前任务被取消,TaskGroup 负责等待子任务收尾,外层再得到 TimeoutError

async def run_with_deadline() -> None:
    try:
        async with asyncio.timeout(2):
            async with asyncio.TaskGroup() as group:
                group.create_task(load_profile())
                group.create_task(load_orders())
    except TimeoutError:
        # 统一处理这一批超过总时限的结果
        print("批处理超时,检查慢任务和清理耗时")

若每个任务有自己的预算,可以在 worker 内部使用独立 timeout,但要明确它产生的是局部超时,是否让异常离开 worker 触发整组取消。排查时建议记录三类证据:哪个任务先抛出普通异常、哪些任务执行了 finally、最外层最终捕获的是 TimeoutError 还是 ExceptionGroup。

发布前可以照着做的收尾清单

现象先检查正确判断
兄弟任务突然结束是否有首个非取消异常TaskGroup 的故障联动取消生效
程序迟迟不退出finally 是否还在等待 I/O取消不是强杀,清理耗时会进入退出时间
日志只剩一条大异常是否使用 except*按异常类型拆出 ExceptionGroup 的匹配子集
超时后状态不一致是否吞掉 CancelledError清理后重新抛出取消信号

相关问题

TaskGroup 会取消已经完成的任务吗?

不会。已经完成的任务不会再次执行取消流程,只有仍在运行的兄弟任务会收到取消请求。

为什么 finally 执行了,TaskGroup 还没退出?

TaskGroup 要等待任务真正结束。finally 中如果还有异步清理、锁竞争或网络等待,退出就会继续等待。

可以用 gather 替代 TaskGroup 吗?

两者语义不同。需要明确的父子任务边界、失败联动取消和异常组时优先考虑 TaskGroup;迁移前要按失败、取消和超时分别回归。

TaskGroup 的关键不是“失败后全部停止”,而是把失败传播、兄弟取消和资源清理放进同一个可观察边界。只要保留取消信号,按异常组分类,超时放在清晰的层级,收尾行为就能稳定复查。完整语义可继续核对 Python asyncio 任务文档PEP 654

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