症状
五行代码的最小复现就能立刻厘清语义:ok() 任务已推进 90%,boom() 先抛 ValueError,gather 默认立即传播该异常并取消 ok()——它本可以完成的结果永远丢失。生产环境的指纹是:兄弟任务日志在同一主失败时间戳上布满 CancelledError,调用方只收到一个异常、零个部分结果;10 个任务的批量作业报告为“1 个错误 + 9 个取消”。嵌套 gather 会成倍放大——一个叶子的失败取消整棵本可健康的子树。
诊断思路
先跑最小复现确认默认 gather 契约(首异常传播 + 取消其余);再全局审计 gather 调用点,任务之间互不依赖的每一处都是 return_exceptions=True 候选;最后把日志里的 CancelledError 时间戳与主失败对齐,同步出现即取消级联而非各自独立的 bug。
根因
asyncio.gather 默认收到第一个异常就抛出并取消其余未完成的可等待对象。对独立任务而言,这把部分失败升级成整体失败:接近完成的工作被主动取消、结果丢失,调用方只拿到那一个异常。
修复与验证
独立任务传 return_exceptions=True,再把结果分区为值与异常;分区时必须把 CancelledError 单列出来重新抛出——return_exceptions 会把取消也当异常捕获,吞掉它会破坏协作式停机。确实需要全有或全无的组,用 Python 3.11 的 asyncio.TaskGroup 做结构化取消:显式表达整体语义,并用 ExceptionGroup 报告全部失败而不是碰巧最先到达的那个。验证时搭一个扇出测试器:5 个任务、1 个按指定延迟失败。修复后应交付 4 个值加 1 个异常对象,兄弟日志 grep 零 CancelledError;再调整失败延迟重复运行,确认与失败顺序无关;TaskGroup 变体则断言 ExceptionGroup 恰含注入的失败、所有非失败任务在组退出前完成。
代码示例
import asyncio
async def ok():
await asyncio.sleep(0.1)
return 'ok'
async def boom():
await asyncio.sleep(0.01)
raise ValueError('x')
async def main():
result = await asyncio.gather(ok(), boom())
asyncio.run(main())
results = await asyncio.gather(
*coros,
return_exceptions=True,
)
values, failures = [], []
for item in results:
if isinstance(item, BaseException):
if isinstance(item, asyncio.CancelledError):
raise item # never swallow shutdown signals
failures.append(item)
else:
values.append(item)
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(coro()) for coro in coros]
# raises ExceptionGroup with every failure, cancels cleanly