Python 3.11 TaskGroup:一个任务失败时取消同组任务

136 次浏览5 条回复

并发任务有依赖关系时,一个已经失败,其他任务通常也没必要继续跑。Python 3.11 的 asyncio.TaskGroup 会在组内任务抛出异常后取消其余任务,并等它们执行清理逻辑。

环境:Python 3.11+,Linux、macOS 或 Windows,只用标准库。

import asyncio

async def worker(name, delay, fail=False):
    try:
        await asyncio.sleep(delay)
        if fail:
            raise RuntimeError(name)
        print(f"{name}: done")
    finally:
        print(f"{name}: cleanup")

async def main():
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(worker("slow", 1))
            group.create_task(worker("bad", 0.05, fail=True))
    except* RuntimeError as errors:
        print([str(error) for error in errors.exceptions])

asyncio.run(main())

运行后能看到两个任务都执行了 cleanup,但不会出现 slow: done。组内异常会在退出上下文时组成 ExceptionGroup,所以这里用 except* 按类型处理。相比只创建一组裸任务,TaskGroup 把取消、等待清理和异常汇总放在了同一个作用域里。

补一个容易踩的点:TaskGroup 退出时会等兄弟任务把取消和清理处理完,所以 finally 里的清理最好可控,别在里面无限等待。任务如果捕获 asyncio.CancelledError 做日志,通常还要 raise 回去;把取消异常吞掉会让结构化并发的退出语义变得很难判断。

再补一个异常处理细节:except* RuntimeError 只会取出匹配的那部分,组里同时出现的其他异常仍会作为剩余的 ExceptionGroup 继续向外抛。实际服务里如果这里只是记日志,最好在处理后重新 raise,不然这组任务表面上会变成正常返回,调用方可能看不到失败。

JordanLv1#1

补一个容易踩的点:TaskGroup 退出时会等兄弟任务把取消和清理处理完,所以 finally 里的清理最好可控,别在里面无限等待。任务如果捕获 asyncio.CancelledError 做日志,通常还要 raise 回去;把取消异常吞掉会让结构化并发的退出语义变得很难判断。

顺着这个点再补一句:外层套 asyncio.timeout() 也不是硬截止时间。超时后它会先取消当前任务,等 TaskGroup 里的任务完成取消和 finally 清理,离开上下文时才把取消转换成 TimeoutError;所以清理卡住时,实际耗时仍可能超过设定值。需要硬上限的任务,通常还得放到可由外部终止的子进程里。

小咸鱼Lv1#2

再补一个异常处理细节:except* RuntimeError 只会取出匹配的那部分,组里同时出现的其他异常仍会作为剩余的 ExceptionGroup 继续向外抛。实际服务里如果这里只是记日志,最好在处理后重新 raise,不然这组任务表面上会变成正常返回,调用方可能看不到失败。

这里还有个容易让示例误导的细节:当前 except* RuntimeError 只打印异常,没有再次抛出;如果组里只有这个异常,asyncio.run(main()) 可能以 0 状态结束,调用方会误以为成功。若只是记录后仍要让失败向上暴露,可以在处理日志后 raise,或者把异常转换成明确的返回值。这样更能体现 TaskGroup 的失败语义。

这个取消语义也意味着它不太适合“各项互不相关,失败一项仍要收齐其他结果”的批处理。那类场景可以用 asyncio.gather(..., return_exceptions=True),或者让每个任务在内部把成功/失败包装成结果再返回;否则一个输入报错会把还没完成的输入一起取消。选 TaskGroup 前先确认同组任务确实是共同成败,会少一次语义上的坑。