登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python asyncio.timeout 嵌套取消与异常传播

来源:17golang原创

时间:2026-09-28 20:19:23 373浏览 收藏

我在排查异步请求“超时后偶尔还在继续跑”时,最容易混淆的不是秒数,而是取消由哪一层负责。asyncio.timeout() 会在截止时间到达时取消当前 Task,并在对应上下文退出时把这次取消转换成 TimeoutError。它可以嵌套,但每一层的预算、捕获位置和清理责任要分开看。

记住一条边界:TimeoutError 通常在 async with asyncio.timeout(...) 外捕获;CancelledError 则应在清理完成后继续抛出,不能用一个宽泛的 except Exception 把取消信号藏掉。
要点速览
  • 内层 timeout 适合约束单个依赖,外层 timeout 适合约束整段请求预算。
  • 超时异常的转换发生在上下文管理器退出时,捕获位置决定你看到的是哪种异常。
  • 主动取消与超时不是同一件事,清理资源后要保留 CancelledError 的传播。

asyncio.timeout 嵌套时,先看谁拥有取消责任

嵌套场景可以这样分层:外层给整个操作一个总预算,内层只给某个慢依赖更短的局部预算。内层到期时,当前 Task 会在内层等待点收到取消;内层上下文退出后,才有机会把这次取消转换为 TimeoutError。如果内层没有处理,异常会继续向外冒泡;如果外层自己的截止时间先到,外层负责终止剩余工作。

import asyncio

async def load_profile():
    # 外层限制整次请求,内层只限制画像服务这一段
    try:
        async with asyncio.timeout(5):
            try:
                async with asyncio.timeout(2):
                    # 示例调用可能因依赖变慢而触发内层取消
                    await asyncio.sleep(3)
            except TimeoutError:
                # 内层已经退出,局部降级可以放在这里
                return {"profile": None, "source": "fallback"}
            return {"profile": "loaded"}
    except TimeoutError:
        # 外层超时表示整段预算耗尽,不能再假设下游已完成
        return {"profile": None, "source": "outer-timeout"}
Python asyncio.timeout 嵌套中 outer timeout、inner timeout 与 CancelledError 到 TimeoutError 的责任边界说明图
图1:asyncio.timeout 嵌套责任边界说明图,不是运行截图或执行证据。

这里的关键不是“内层一定优先”,而是哪个 deadline 先到、哪个上下文仍在作用域内。内层降级后,外层还有剩余预算就可以继续;若降级逻辑本身也耗尽外层预算,最终仍会由外层超时收口。

TimeoutError 必须放在上下文外捕获

asyncio.timeout() 内部收到的是取消注入,转换动作在上下文管理器的退出逻辑里完成。因此,把 except TimeoutError 写进 async with 里面,通常捕获不到这次超时;正确做法是让 try 包住整个上下文。

async def request_with_budget(do_request):
    # try 包住 async with,才能接到上下文退出后转换出的 TimeoutError
    try:
        async with asyncio.timeout(1.5):
            return await do_request()
    except TimeoutError:
        # 这里只处理预算耗尽;业务异常仍按原类型继续传播
        return {"ok": False, "reason": "timeout"}

还要留意超时与业务异常的顺序:如果 do_request() 先抛出自己的 ValueError,它不会被无条件改写成 TimeoutError。只有截止时间触发的取消,才会走 timeout 的转换路径。

CancelledError 要清理,但不要被吞掉

主动调用 task.cancel() 时,协程在下一次可取消的等待点收到 asyncio.CancelledError。这和 timeout 为了实现截止时间而发出的取消机制相同,但语义可能不同:前者是调用方撤销任务,后者是局部预算耗尽。清理代码应覆盖两者,结束后仍把取消信号交回调用方。

async def consume(stream):
    resource = await stream.open()
    try:
        # 处理循环可能因超时或外部 cancel 被中断
        return await stream.read(resource)
    except asyncio.CancelledError:
        # 记录必要上下文后继续抛出,不能把取消伪装成普通失败
        raise
    finally:
        # finally 必须幂等,重复进入清理路径也不能破坏状态
        await stream.close(resource)
Python asyncio Task.cancel 经过 cleanup 和 finally 后重新 raise CancelledError 到调用方的异常传播说明图
图2:取消清理与异常传播关系说明图,不是运行截图或执行证据。

不要为了“让任务成功返回”而在 except asyncio.CancelledError 中直接返回。结构化并发组件依赖取消信号协作;吞掉它会让上层误以为任务正常完成,也可能让嵌套 timeout 或 TaskGroup 的内部状态难以判断。

动态 deadline 与排查清单

如果预算要等配置或上游响应后才知道,可以先用 asyncio.timeout(None),拿到绝对截止时间后调用 reschedule()。退出后用 expired() 记录上下文是否真的越过 deadline。这个方式适合把“等待预算”与“业务结果”分开保存。

现象优先检查处理方向
内层 except 没接到 TimeoutError捕获是否写在 async with 内把 try/except 移到上下文外
外部 cancel 后任务像成功结束是否返回或吞掉 CancelledErrorfinally 清理后 raise
嵌套超时难以判断来源每层 deadline 与日志字段记录层级、截止时间和 expired()
清理后仍持续占用资源finally 是否覆盖所有退出路径让 close/release 操作幂等

相关问题

asyncio.timeout 和 asyncio.wait_for 该怎么选?

timeout() 用上下文表达一段代码的总预算,适合组合多个 await;wait_for() 更像给一个 awaitable 设置等待上限。选择时先看你要约束的是代码块还是单个等待对象。

为什么 TimeoutError 不能在 timeout 内部捕获?

因为上下文内部首先收到的是 CancelledError,TimeoutError 是退出上下文时才转换出来的,所以捕获点要放在 async with 外。

捕获 CancelledError 后一定要 raise 吗?

如果只是做日志、关闭连接或释放锁,清理结束后应继续 raise。只有确实要抑制取消时,才需要完整理解任务取消状态和上层协作关系。

把每层 timeout 当成一个有边界的责任域,嵌套取消就不会只剩下一串难以解释的异常日志:局部依赖可以局部降级,整段预算可以统一收口,而外部取消始终能到达真正的调用方。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>