asyncio.gather:一个任务失败,其他任务为何没停

黎 浩然/ 24 10 月, 2026/ 教程/TUTORIAL, 计算机/COMPUTER/ 0 comments

await asyncio.gather(…)因为一个子任务抛出ValueError而失败,不代表其他子任务已停止。默认gather会把异常交给等待它的调用者,却不会因此取消其余任务;如果你随后只重试整批请求,旧任务可能仍在继续。

文章目录
  1. 聚合结果已失败,子任务仍可能未完成
  2. 用事件构造反例,不靠等待多少毫秒
  3. 完整程序:二十六项状态与结果检查
  4. TaskGroup把失败与子任务收尾放在同一作用域
  5. return_exceptions适合收齐结果,不代表忽略错误
  6. 为什么有些短脚本看起来会自动取消全部
  7. 把收尾策略写在重试之前

对并发LLM请求、多个数据源查询或批量处理,这个区别决定了谁负责收尾。下面用Event固定执行顺序,复现继续执行、显式取消、TaskGroup与收集全部结果四种处理,再单独检查程序退出时的取消。代码不访问任何模型或远程服务。Read in English。

聚合结果已失败,子任务仍可能未完成

CPython 3.13.0版文档区分了这两个状态:默认gather传播最先出现的异常,其余awaitable继续;已因传播异常而完成的gather,再调用cancel(),不会取消原来的子任务。这里讨论的是子任务普通异常,不把它推广为调用者取消等待、或子任务自己取消时的全部规则。

可以把gather看成一个聚合结果对象,而不是一个必然拥有统一停止策略的工作单元。“我已经拿到失败结果”和“这组工作全部结束”是两项不同的事实。第一项足以让调用者进入except,第二项还需要你明确检查或等待各个任务。

最容易误写的收尾是在except中执行group.cancel(),以为这时还能一并停掉其他请求。若group已经done,这个操作就太迟。示例同时断言group.done()为真、仍在等待的sibling.done()为假,以及group.cancel()返回假,避免只根据异常日志猜测状态。

失败任务A
抛出ValueError
聚合对象
已done,调用者进入except
兄弟任务B
仍等release,尚未done
原创状态图:聚合结果完成,不等于每个子任务已经结束;图中B由Event控制。

用事件构造反例,不靠等待多少毫秒

worker先设置ready,再等待release。失败任务必须等到ready才抛ValueError;因此调用者捕获异常时,worker已进入工作流程,并且release仍未放行。调用者随后可以选择放行它,或直接取消它。两条路径复用同一个任务,区别只在收尾策略。

这里的“work committed”只是向本地列表追加一条字符串,不代表数据库提交、HTTP请求成功或远程推理完成。它的作用是让“异常已经传播之后,另一项工作仍能产生效果”变成可检查的事件。代码也不使用任意sleep来赌调度时机。

在继续执行路径中,捕获异常后设置release,再等待sibling,得到返回值7和完成记录。在显式取消路径中,直接对sibling调用cancel,然后用return_exceptions=True等待两个任务收尾;结果中同时保留ValueError与CancelledError,不能把“没有再次抛出”理解成它们成功了。

完整程序:二十六项状态与结果检查

程序使用Python标准库,TaskGroup与except*语法要求Python 3.11或以上。本地实际运行版本是Python 3.15.0;参考语义固定到早已公开的3.13.0资料,不声称在多个Python版本上执行过。

import asyncio

checks = 0

def check(condition):
    global checks
    assert condition
    checks += 1


async def worker(ready, release, trace):
    ready.set()
    try:
        await release.wait()
        trace.append('work committed')
        return 7
    except asyncio.CancelledError:
        trace.append('cancel observed')
        raise
    finally:
        trace.append('cleanup finished')


async def fail(ready):
    await ready.wait()
    raise ValueError('synthetic failure')


async def gather_case(cancel_worker):
    ready, release = asyncio.Event(), asyncio.Event()
    trace = []
    sibling = asyncio.create_task(worker(ready, release, trace))
    broken = asyncio.create_task(fail(ready))
    group = asyncio.gather(broken, sibling)
    try:
        await group
    except ValueError:
        check(group.done())
        check(broken.done())
        check(not sibling.done())
        check(not group.cancel())  # Too late: the aggregate is already done.
        check(trace == [])
    else:
        raise AssertionError('expected ValueError')
    if cancel_worker:
        check(sibling.cancel())
        check(not sibling.done())  # A request is not completed cancellation.
        settled = await asyncio.gather(broken, sibling, return_exceptions=True)
        check(isinstance(settled[0], ValueError))
        check(isinstance(settled[1], asyncio.CancelledError))
        check(sibling.cancelled())
        check(trace == ['cancel observed', 'cleanup finished'])
    else:
        release.set()
        check(await sibling == 7)
        check(trace == ['work committed', 'cleanup finished'])
    return trace


async def taskgroup_case():
    ready, release = asyncio.Event(), asyncio.Event()
    trace, errors = [], []
    try:
        async with asyncio.TaskGroup() as group:
            sibling = group.create_task(worker(ready, release, trace))
            group.create_task(fail(ready))
    except* ValueError as eg:
        errors.extend(eg.exceptions)
    check(len(errors) == 1 and isinstance(errors[0], ValueError))
    check(sibling.done() and sibling.cancelled())
    check(trace == ['cancel observed', 'cleanup finished'])
    return trace


async def collect_case():
    ready, release = asyncio.Event(), asyncio.Event()
    trace = []
    sibling = asyncio.create_task(worker(ready, release, trace))
    broken = asyncio.create_task(fail(ready))
    group = asyncio.gather(broken, sibling, return_exceptions=True)
    try:
        await broken
    except ValueError:
        pass
    check(not group.done())  # It still waits for the blocked sibling.
    release.set()
    settled = await group
    check(isinstance(settled[0], ValueError) and settled[1] == 7)
    check(trace == ['work committed', 'cleanup finished'])
    return trace


async def main():
    print('gather, continue:', await gather_case(False))
    print('gather, explicit cancel:', await gather_case(True))
    print('TaskGroup:', await taskgroup_case())
    print('collect all:', await collect_case())


asyncio.run(main())

# A separate run shows cancellation caused by runner shutdown.
exit_trace = []

async def runner_exit_case():
    ready, release = asyncio.Event(), asyncio.Event()
    sibling = asyncio.create_task(worker(ready, release, exit_trace))
    try:
        await asyncio.gather(fail(ready), sibling)
    except ValueError:
        check(not sibling.done())
    # Return without releasing or directly cancelling the sibling.

asyncio.run(runner_exit_case())
check(exit_trace == ['cancel observed', 'cleanup finished'])
print('runner shutdown:', exit_trace)
print(f'{checks} asyncio checks passed')

实际输出最后一行为“26 asyncio checks passed”。默认gather的继续路径记录work committed、cleanup finished;显式取消和TaskGroup路径记录cancel observed、cleanup finished;收集全部结果路径仍允许worker完成。最后一段单独的asyncio.run展示运行器退出清理,不能把它与前面的gather策略混为一谈。

这些断言检查的是任务状态、异常类型及可控顺序,没有给出吞吐、延迟或真实API费用结论。worker能在等待事件时配合取消,清理也立即完成;它不是忽略取消、阻塞事件循环或执行外部不可逆操作的任务模型。

显式取消路径还检查了一个短暂但重要的状态:cancel()返回真,而sibling.done()仍为假。前者记录请求已发出,后者说明协程尚未处理它。直到等待收尾之后,才检查cancelled()与清理记录。在批处理日志中区分“请求取消”和“已观察到取消”,能防止重试代码把仍在收尾的任务当成已释放资源。

TaskGroup把失败与子任务收尾放在同一作用域

TaskGroup的文档契约在本例中的含义是:ValueError触发剩余任务取消,等待收尾后,以异常组交回普通失败。示例只安排一个ValueError,因而except* ValueError收集到一个匹配异常;它没有假设每个真实任务组永远只有一个失败。

对“几项子工作共同组成一次结果”的任务,这种作用域便于确定何时可以离开。例如一次回答必须同时有两个数据源,缺少任意一个就不能继续生成最终回答;此时让另一项仍然静默运行,往往不是你想要的生命周期。这里是业务选择示例,不是对所有LLM调用一律使用TaskGroup的建议。

取消仍是协作过程。worker在CancelledError分支记录观察结果后重新抛出,再执行finally。若其他协程把取消吞掉、持续做阻塞工作,或清理本身很慢,离开作用域仍可能等待。TaskGroup不是强制终止任意代码的开关,更不是撤销之前所有外部效果的事务。

继续等待
放行B并取得结果
显式取消/TaskGroup
请求取消,再等清理
收集全部结果
等待每项,逐项判断
原创策略图:图中取消路径建立在协程配合取消的前提上,不表示外部效果自动回滚。

return_exceptions适合收齐结果,不代表忽略错误

如果请求彼此独立,例如一批文本允许逐条成功或失败,可以选择收集全部结果,再逐项判断。示例中的collect_case先确认broken已失败,再断言聚合结果还没完成,因为worker仍在等待release;放行后才拿到异常与数值组成的列表。

这能直接回答一个常见误解:return_exceptions=True改变的是失败如何进入结果集合,并不等于在第一次失败时立即返回。调用者仍要定义如何处理异常项,哪些条目允许重试,哪些成功结果应保留。只看列表长度与输入数量相同,并不足以判断整批成功。

在批量LLM流程中,保存每个输入对应的结果或失败状态,通常比捕获一个异常后重发全部输入更容易解释。整批重试若与仍在运行的旧任务重叠,就可能重复工作;本文只推导这种生命周期风险,没有运行供应商请求,也没有证明远程服务是否会按某种方式计费。

为什么有些短脚本看起来会自动取消全部

如果main捕获异常后立刻返回,程序可能进入运行器的退出清理。CPython 3.13.0的Runner.close与_cancel_all_tasks会在关闭流程中取消尚未结束的任务并等待收尾。因此,最后观察到worker被取消,不足以推出“gather遇到异常会取消兄弟任务”。

程序末尾专门演示这个差别:runner_exit_case在except里确认sibling尚未结束,然后既不放行也不直接取消它,直接返回。等asyncio.run退出后,外部再检查取消与清理记录。取消发生在运行器收尾阶段,而不是gather抛出ValueError的那个时刻。

真实服务中的事件循环通常不会因为一个请求处理函数返回就立即结束。因此,不能把一个极短命令行脚本里的退出现象,当作长时间运行服务的子任务管理策略。排查时先确认事件循环和任务的拥有者,再解释取消日志由谁触发。

把收尾策略写在重试之前

若选择默认gather,保留子任务引用,并明确失败后要继续等待还是逐个取消再等待清理。若选择TaskGroup,理解异常组及协作取消的边界。若选择收集全部结果,逐项分类,而不是把异常当成普通成功值往后传。三种策略分别对应不同的完成条件。

对远程调用,还要区分本地任务结束与服务端工作结束;本示例没有验证后者。此前的asyncio超时与后台线程讨论的是线程工作可能继续的另一层问题;本篇即使完全不用线程,默认gather的兄弟协程也可能继续。先确定哪一层尚未结束,才能判断重试是否会与旧工作重叠。

来源使用v3.13.0文档源文件与运行器实现;该版本发布于2024年10月7日,早于原计划时段。两幅图、事件驱动反例和二十六项检查为原创内容。文章保留2026年10月24日23:30(北京时间)日期,实际于2026年10月26日补发。

支持

如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。

微信支持二维码,点击查看大图
微信
支付宝支持二维码,点击查看大图
支付宝
Buy Me a Coffee,点击支持本站
Buy Me a Coffee

二维码可点击放大。更多支持方式见支持页面。

Share this Post

Leave a Comment

您的邮箱地址不会被公开。 必填项已用 * 标注

*
*