Python 异步代码的异常传播机制

1次阅读

async/await异常不会自动冒泡,必须显式调度协程(如await、asyncio.run)或检查task(如task.exception())才能捕获;gather默认快速失败,需return_exceptions=true才聚合异常;未处理异常会在Event loop关闭时转为runtimeerror输出到stderr。

Python 异步代码的异常传播机制

async/await 中的异常不会自动冒泡到外层同步代码

你写了个 async def,里面 raise ValueError("boom"),但调用它时没加 await 或没用 asyncio.run(),结果异常根本没抛出来——不是没发生,是它卡在了 coroutine Object 里,等着被驱动执行。python 不会主动执行协程,也不会替你检查它内部有没有异常。

常见错误现象:RuntimeWarning: coroutine 'xxx' was never awaited,或者更隐蔽的:程序静默退出,日志里啥也没有。

  • 必须显式调度协程:用 await(在另一个 async 函数内)、asyncio.run()(顶层入口)、或 loop.run_until_complete()
  • 直接打印或返回协程对象(比如 print(fetch_data()))不会触发执行,自然也捕获不到异常
  • 单元测试里如果忘了 await 被测协程,断言会永远不运行,甚至可能误判为“通过”

Task 对象的异常要 await 才能触发

asyncio.create_task() 启动一个任务后,它的异常不会立刻向上抛,而是“挂起”在 Task 实例上,直到你对它做 await task 或调用 task.result()

使用场景:并发发多个请求,其中一个失败,但你不希望整个流程被中断,而是等全部结束再统一处理错误。

立即学习Python免费学习笔记(深入)”;

  • await task 会立即抛出该任务内部的异常(如果有的话)
  • task.exception() 可以安全获取异常对象(返回 None 表示没异常),适合非阻塞检查
  • 如果任务已取消,task.exception() 返回 CancelledError;但 await task 会直接 raise 它,需注意是否要捕获
  • 忘记 await 已创建的 task,可能导致异常丢失、资源未释放,且难以 debug

asyncio.gather() 的异常传播策略:默认“快速失败”

asyncio.gather() 默认只要有一个子协程出错,就立刻停止其余任务并抛出异常——这和你直觉中“等所有都跑完再汇总错误”不一样。

参数差异:return_exceptions=True 是关键开关,它让失败的子协程返回异常实例而非抛出。

  • 默认行为(return_exceptions=False):第一个异常中断整个 gather,其他协程可能被取消(取决于是否已开始执行)
  • 设为 True 后,所有结果(含 Exception 实例)按原顺序返回,你需要手动检查每个元素是不是异常
  • 性能影响:设为 True 不影响并发度,但会多保留异常对象,内存开销略增
  • 容易踩的坑:以为 gather 天然聚合异常,结果线上某个请求失败导致整批请求被截断

未捕获的异步异常会变成 RuntimeError 并打印到 stderr

当一个 task 内部抛出异常,又没人 await 它、也没人调用 task.exception(),Python 会在 event loop 关闭时把它作为“未处理异常”兜底处理:转成 RuntimeError,附带原始异常信息,输出到 stderr,但不中断主流程。

这种异常很难被日志框架捕获,也不进 except 块,常出现在后台任务、信号处理、或忘记清理的 task 中。

  • 可通过 loop.set_exception_handler() 自定义处理逻辑(比如上报 sentry
  • 开发期建议开启 asyncio.get_event_loop().set_debug(True),它会让这类异常立刻报 Warning,而不是等到 loop 结束
  • 生产环境别依赖 stderr 日志——它可能被重定向、截断,或和其它输出混在一起
  • 最稳妥的做法:所有 create_task() 都配个 done callback 或包一层带日志的 wrapper

异步异常真正的复杂点不在语法,而在于“谁负责驱动、谁负责检查、谁负责清理”这三件事经常被拆开在不同模块里。一个 Task 创建于 A 模块,await 在 B 模块,错误处理逻辑在 C 模块——这时候异常传播路径就变成了协作契约,不是语言机制能兜住的。

text=ZqhQzanResources