asyncio.gather() 默认会把最先出现的错误抛给等待的那一侧,其余还在跑的却不会被取消。即便把 return_exceptions 打开,也只是把错误当结果收进列表,同样不会去停旁边那些尚未结束的工作。几个 asyncio 任务要的是要么都做成,要么有一个失败就一起停,并不想自己维护一堆 gather 异常。真要「一组一起收场」,还得自己补取消和汇总的逻辑。
Python 官方文档的 Task groups 一节 把 asyncio.TaskGroup 写成一个异步上下文管理器:用 create_task() 往组里加任务,离开这段上下文时会等待组内全部工作结束。文档标明它是 3.11 加入的,create_task() 的签名和 asyncio.create_task() 一致。常见写法是:
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(some_coro(...))
task2 = tg.create_task(another_coro(...))
print(f"Both tasks have completed now: {task1.result()}, {task2.result()}")
async with 会等到组里的工作都做完。等待期间仍可继续往里添加,比如把 tg 传进某个协程,在里面再调用 tg.create_task()。最后一项结束、离开这个块之后,就不能再往组里加了。组若尚未进入、已经结束、或正在收尾,再调用 create_task() 时,文档写明会关闭传入的协程并抛出 RuntimeError(3.13 起的行为)。
它和 gather 真正分开的地方,是失败之后怎么收场。组里任何一个成员只要以 asyncio.CancelledError 以外的错误退出,其余尚未结束的会被取消,也不能再往组里添加。若此时 async with 的主体还在执行,直接包含这段语句的那一项也会被取消。由此产生的 CancelledError 会打断一次 await,但不会再冒出这个 async with。等全部结束后,那些非取消失败会被收进 ExceptionGroup 或 BaseExceptionGroup 再抛出。KeyboardInterrupt 和 SystemExit 则是特例:组仍会取消其余成员并等待,随后直接重抛这两个异常,而不是包进异常组。async with 主体自己带着错误退出时,处理方式相同:取消并等待其余成员,非取消异常连同传入 __aexit__() 的那一个(若不是 CancelledError)一并进异常组。
同一页里对 gather 的说明也点了这层差别:TaskGroup 被写成并发创建并等待的新替代,安全保证更严。某个成员或由它再调度出去的子任务一旦抛错,组会取消其余已调度的工作,gather 则不会。文档还写了 cancel(),标明是 3.15 加入的:在目标已经达成、或这组服务不再需要时,可以对尚未完成的成员以及组的主体请求提前结束,上下文会正常退出,不会把 CancelledError 再抛出这个块。这和某一个成员失败后的异常汇总不是同一条路径。若在进入组之前就调用了 cancel(),一进组就会被取消。该调用可重复执行,组已经离开之后再调也没有额外效果。嵌套使用时,文档强调它不会把内部用来唤醒 __aexit__() 的取消,和外界对该任务提出的取消请求混在一起:内层先处理自己的错误,外层再接到取消并处理自己的。组被外部取消、同时又必须抛出 ExceptionGroup 时,它会调用父任务的 cancel(),好让下一次 await 仍能接到 CancelledError,这次取消不会被吞掉。3.13 起它对同时出现的内部取消和外部取消处理得更清楚,并会保留 asyncio.Task.cancelling() 所报告的计数。async with 的主体若抛出 GeneratorExit,而组里其他成员没有需要汇报的错误,文档写明会把这个 GeneratorExit 重新抛出。
若要的是一组协程同生同死,不想自己去拼如何取消其余成员,官方把这种用法放在了 TaskGroup 上。它解决的是同一事件循环里一组协程如何收场,并不是另换一套线程或协程模型。
你们现在是一组请求要全部成功才算过,还是已经在为 gather 漏掉的取消补补丁?