Python 异步批处理里,一个任务报错,外层已经返回失败,另一个任务却继续写文件或发请求。问题可能不在重试,而在 gather 的失败语义。下面用 Python 3.11+ 的隔离实验,比较 gather 与 TaskGroup,明确谁负责取消、等待和清理,避免把“捕获异常”误当成“整批已停”。
一、先分清:报错返回,不等于同伴停止
默认的 asyncio.gather 会并发运行传入的可等待对象。某个对象抛出普通异常时,第一个异常立即传播给等待 gather 的调用方;其他对象不会因此自动取消。外层看见失败,兄弟任务仍可能继续执行,这不是日志延迟。
主函数立即结束时,asyncio.run 关闭事件循环,也可能取消剩余任务。这不是 gather 的失败联动。实验应保持事件循环运行,保留任务引用,核对 done 和 cancelled。
二、用会合点复现,不靠猜睡眠时间
把代码保存为 demo.py,用 Python 3.11+ 运行。Event 确保慢任务先启动,再抛错;另一个 Event 暂停它以读取状态。实验只操作内存。
import asyncio
async def 主程序():
已启动 = asyncio.Event()
放行 = asyncio.Event()
async def 慢任务():
已启动.set()
await 放行.wait()
return "慢任务仍完成"
async def 失败任务():
await 已启动.wait()
raise ValueError("模拟失败")
慢 = asyncio.create_task(慢任务())
汇总 = asyncio.gather(失败任务(), 慢)
try:
await 汇总
except ValueError:
print("慢任务已结束:", 慢.done())
print("再取消汇总:", 汇总.cancel())
放行.set()
print(await 慢)
asyncio.run(主程序())
输出两个 False 和“慢任务仍完成”:同伴尚未结束,已完成的汇总对象不能再取消它;主动放行后同伴正常返回。cancel 要对准生命周期。
三、按业务关系选择,而不是全局替换
如果这些子任务合起来才算一个有效结果,例如同时读取一份报告的必需材料,一个失败后其他结果已无意义,适合用 TaskGroup。它从 Python 3.11 起提供,进入 async with 后用组的 create_task 登记任务。
组内任务首次出现非 CancelledError 异常,会向剩余组内任务请求取消;上下文退出会等待这些任务结束,再传播异常组。这里的“组内”很重要:绕过组、直接 asyncio.create_task 创建的游离任务,不会自动被它托管。

独立批量探测希望其他项继续完成时,可用 gather(return_exceptions=True)。它把异常放进结果列表;须逐项分类、保留目标标识,列表长度不等于成功数量。
四、让 TaskGroup 收尾,也让资源真正释放
仍用会合点制造故障,finally 负责清理,组外核对状态。实际项目替换为关闭连接、释放锁等动作,不要让清理覆盖原始异常。
import asyncio
async def 主程序():
已启动 = asyncio.Event()
已清理 = []
async def 慢任务():
try:
已启动.set()
await asyncio.Event().wait()
finally:
已清理.append("资源已释放")
async def 失败任务():
await 已启动.wait()
raise ValueError("模拟失败")
try:
async with asyncio.TaskGroup() as 组:
慢 = 组.create_task(慢任务())
组.create_task(失败任务())
except* ValueError as 错误组:
print("本次失败:", len(错误组.exceptions))
print("慢任务已取消:", 慢.cancelled())
print(已清理)
asyncio.run(主程序())
这段输出本次失败 1、慢任务已取消 True,以及资源已释放。只在正常退出 TaskGroup 后,才从成功任务读取 result;被取消的任务调用 result 会抛 CancelledError。不要把部分成功结果在失败分支里悄悄拼成完整成功。
五、异常组不是一条普通 ValueError
TaskGroup 可能把多个普通异常组合成 ExceptionGroup。except ValueError 不能匹配包在组内的 ValueError;except* ValueError 会处理匹配的子组,未匹配部分继续传播。不要用 except* Exception 加 pass 把程序错误一起吞掉。
嵌套异常组的 exceptions 长度不等于全部叶子数;示例只有一个直接失败。记录完整异常树。KeyboardInterrupt、SystemExit 有特殊规则。
六、取消不是硬停止,更不是撤销请求
cancel 只是安排在合适的执行机会抛出 CancelledError。协程长期执行 CPU 循环、同步阻塞调用,或吞掉取消继续工作,都会拖慢收尾。捕获取消只为做必要处理,处理后通常应重新 raise;优先用 try/finally,别把取消改成一个正常结果。
需要预算时,可以把 TaskGroup 放进 asyncio.timeout 上下文,并在该超时上下文外捕获 TimeoutError;这个接口同样要求 Python 3.11+。但等待任务清理仍可能超过设定时间,协作超时不是强制杀线程。
已经发出的请求、写入的数据,不会因为协程取消而自动撤销。付款、部署或消息发送仍需幂等键、状态查询与补偿流程。TaskGroup 管任务生命周期,不替数据库事务,也不承诺外部业务恰好一次。
七、故障分支对照表
| 现象 | 优先核对 | 处理 |
|---|---|---|
| 外层失败,同伴仍运行 | 默认 gather 普通异常 | 按依赖关系选择 TaskGroup 或显式取消并等待 |
| gather.cancel 返回 False | 汇总是否已经完成 | 保留并管理尚未完成的任务引用 |
| 组退出迟迟不完成 | 同步阻塞、吞取消、清理耗时 | 定位无法协作的任务,不承诺硬截止 |
| except ValueError 没接住 | 是否包装在异常组 | 使用 except* 精确处理,保留未匹配错误 |
| 结果列表中混有异常 | return_exceptions=True | 逐项核对,不把异常对象当业务数据 |
旧版本须显式取消未完成任务,再等待收尾;不要把新语法复制到 3.10。
八、用行为验收,别只看没有 traceback
验收清单:确认运行时支持所用接口;默认 gather 失败后同伴仍可完成;完成后的汇总取消不影响同伴;TaskGroup 失败后同伴取消且 finally 已执行;正常路径结果完整;未匹配异常仍向外传播;独立批次中的每个错误都有归属。
本文在 Python 3.14.3 的独立内存实验中完成 13 项检查,包含两段正文代码实跑、未完成 gather 的取消传播、异常结果列表、外层超时清理和异常组筛选。未接入真实 HTTP、线程池或生产业务;这些边界需要在目标系统另行验收。
先明确哪些任务必须共同成功,再查谁取消、谁等待清理。报错不等于收尾完成。
🔕 评论已关闭