题干与适用场景
一个异步请求先读取路由元数据,之后才知道下游调用的剩余预算。实现要求:统一使用事件循环单调时钟,允许先创建无截止时间的上下文、随后设置绝对截止时间;超时后只能在上下文外处理 TimeoutError,调用方主动取消仍要继续传播。
Python 文档说明 asyncio.timeout() 返回可重排的异步上下文管理器,timeout_at() 接受事件循环时钟上的绝对时间。上下文内部的取消会在边界外转换为 TimeoutError。
面试官考察点
考察候选人能否区分相对延迟与绝对截止时间、业务超时与外部取消;是否理解 reschedule()、expired()、嵌套上下文和 wait_for() 的取消语义,并能给出共享预算与资源清理方案。
回答前需要澄清的问题
- 预算由哪个组件拥有,是否已经消耗了读取元数据的时间?
- 下游客户端、数据库和队列是否支持超时或取消?
- 子调用需要共享一个绝对 deadline,还是各自拥有独立预算?
- 超时是可降级结果、重试,还是直接返回错误?
- 运行环境最低 Python 版本是否包含
asyncio.timeout?
30 秒回答框架
“我用事件循环的 loop.time() 计算绝对 deadline。先用 asyncio.timeout(None) 建立作用域,读到元数据后调用 cm.reschedule(deadline);上下文内部发生取消,退出时会转换成 TimeoutError,所以只在外层捕获。外部 CancelledError 不转换,清理后继续抛出。下游 I/O 同样接收剩余预算,嵌套调用不能重新发放完整超时。需要观察 cm.expired() 区分到期和其他退出。”
分步骤深入解答
第一步:用单调时钟表达绝对截止时间
不要用墙上时钟计算剩余时间,因为系统校时会跳变。用 loop.time() 得到单调值,统一把 deadline 传给所有下游调用。
loop = asyncio.get_running_loop()
deadline = loop.time() + 2.0
async with asyncio.timeout_at(deadline):
await call_dependency(deadline)第二步:未知预算时先创建可重排上下文
当请求开始时还不知道预算,可用 asyncio.timeout(None) as cm。读取元数据后计算绝对 deadline,再调用 cm.reschedule(deadline);不要销毁上下文重建,避免中间工作失去统一边界。
第三步:把剩余预算传到下游
每个客户端根据 deadline - loop.time() 计算剩余秒数,并把非正值视为立即失败。这样路由、数据库和 HTTP 调用共享一个预算,不会层层重新计时。
第四步:理解取消到超时的转换
超时上下文在内部取消当前任务,并在上下文退出时将该取消转换为 TimeoutError。因此 TimeoutError 只能在 async with 外捕获;在内部捕获它会失效。
try:
async with asyncio.timeout(1.0):
await slow_call()
except TimeoutError:
return degraded_result()第五步:保留外部取消
请求被客户端断开或父任务取消时,CancelledError 不是业务超时。清理连接、锁和临时文件后继续抛出,不能把用户取消包装成降级成功。
第六步:比较 timeout、timeoutat 与 waitfor
timeout(delay) 使用相对秒数且支持重排;timeoutat(when) 直接使用绝对单调时间,适合跨多层传递 deadline。waitfor(aw, timeout) 针对一个 awaitable,超时会取消该 awaitable,并可能等待其完成取消,组合多个调用时更容易形成预算漂移。
第七步:处理嵌套和过期状态
超时上下文可以嵌套,内层应使用不晚于外层的 deadline。退出后通过 cm.expired() 判断是否真的到期;普通异常、外部取消和业务返回都不应被误报为 timeout。
第八步:验证竞态与清理
测试元数据迟到、deadline 已过、内层先超时、外部取消与超时同时发生、下游忽略取消、reschedule(None)、重复重排和清理失败。记录 deadline、剩余预算、取消原因和下游耗时,不记录敏感请求内容。
高质量示范回答
“我用 loop.time() 生成绝对 deadline,并把它传给所有下游。预算未知时先建立 timeout(None),元数据到达后 reschedule;内部取消由上下文在退出时转成外部 TimeoutError,所以只在上下文外捕获。外部 CancelledError 保持传播。嵌套调用共享最早 deadline,客户端和数据库也接收剩余预算。通过竞态测试确认无任务和资源泄漏。”
常见错误
- 用
time.time()计算 deadline → 校时导致预算跳变 → 使用loop.time()。 - 在 timeout 上下文内部捕获 TimeoutError → 捕获不到正确异常 → 在上下文外处理。
- 把外部取消当超时 → 请求取消被错误降级 → 区分 CancelledError 与 TimeoutError。
- 每层重新给完整 timeout → 总延迟超过契约 → 传递绝对 deadline。
- 忽略 wait_for 的取消等待 → 实际耗时超过数字 → 评估取消收敛和驱动行为。
- 只测成功和单一超时 → 竞态资源泄漏 → 覆盖同时取消、重排和清理失败。
追问及应对
追问一:为什么 timeout_at 比逐层 timeout 更稳定?
所有层都对同一个绝对时刻负责,不会因每层重新计算相对秒数而累积误差或超支。
追问二:reschedule 到过去的 deadline 会怎样?
上下文会在事件循环下一次机会触发超时;调用方应在重排前检查剩余预算并避免继续发起不可取消的 I/O。
追问三:如何让数据库也遵守 deadline?
将剩余秒数传给驱动的 statement timeout 或取消 API;Python task 被取消并不自动停止服务器端查询。
追问四:外部取消与 timeout 同时到达记录哪个?
保留取消计数和父任务原因,优先向上传播外部取消;把上下文是否 expired 作为诊断字段,而非把所有情况归为业务超时。
追问五:何时使用 wait_for?
单个 awaitable 需要简单相对超时时可用;跨多个子调用、需要动态截止时间或共享预算时,优先使用 timeout/timeout_at。