asyncio 任务、超时与取消
用 Task 管理并发协程生命周期;以 timeout 与 cancel 实现截止与协作式退出,避免孤儿任务与资源泄漏。
[!info] related notes
asyncio 任务、超时与取消
这个概念为什么出现
会写 await asyncio.gather(...) 只够演示。线上故障长这样:
- 某个上游卡住,请求线程/协程被拖死,没有超时
- 客户端已断开,服务端还在跑完整条 LLM 调用链
create_task火后不管,异常在后台静默,资源不放
asyncio 的并发单元是 Task。正确性不只是“结果算对”,还包括:
- 什么时候必须结束(超时/截止)
- 如何协作退出(取消)
- 谁负责回收异常与清理(await / TaskGroup / finally)
这与 Go 的 context 解决问题同族,但 API 与传播方式不同:Python 更常是“取消 Task + 在 await 点抛 CancelledError”。
[!abstract] 一句话理解
create_task把协程变成可并发调度的 Task;wait_for/timeout 限制等待;cancel协作式注入取消;必须有人回收 Task,并在finally中释放资源。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:查询上游报价,超过 100ms 放弃并取消后台任务
交易页给外部报价设置 100ms 等待预算。wait_for 到期会请求取消被等待任务,并等待取消处理完成;本例随后返回降级。清理耗时或吞掉取消都可能让实际等待超过 100ms,因此这不是硬性返回时限。
# asyncio_timeout_demo.py
# 业务意图:上游超时则失败,并确保后台报价协程被取消清理。
# 教学点:
# - create_task 并发启动;
# - wait_for 超时;
# - wait_for 已负责请求取消并等待收尾;
# - CancelledError 在 await 点被感知。
from __future__ import annotations
import asyncio
async def slow_quote() -> str:
# 业务:请求慢上游。用 sleep 模拟 RTT(不是锁)。
try:
await asyncio.sleep(0.5)
return "quote:100"
except asyncio.CancelledError:
# 关键:取消路径也要做清理(关连接、还连接池等)。
print("quote cancelled; cleanup")
raise
async def fetch_with_budget() -> str:
task = asyncio.create_task(slow_quote(), name="quote")
try:
# 100ms 到期请求取消;取消清理仍可能继续耗时
return await asyncio.wait_for(task, timeout=0.1)
except TimeoutError:
print("timeout")
return "quote:degraded"
async def main() -> None:
print("result:", await fetch_with_budget())
if __name__ == "__main__":
asyncio.run(main())
建议运行:
python asyncio_timeout_demo.py
期望输出:
quote cancelled; cleanup
timeout
result: quote:degraded
结合场景再看四个关注点
- 超时是产品语义,不是可选项。
CancelledError在 await 点出现——协作式,不是杀线程。- 手动 cancel 后应等待任务结束;本例的 wait_for 已完成这一步,无须在 except TimeoutError 中重复取消。
- 降级返回与“继续傻等”是两种产品;实现必须匹配。
核心概念与准确模型
1. Task 生命周期
stateDiagram-v2
[*] --> Pending: create_task
Pending --> Running: 循环调度
Running --> Pending: await 等待
Running --> Cancelling: cancel()
Cancelling --> Cancelled: 取消异常传播至任务结束
Cancelling --> Running: 吞掉取消并继续
Cancelling --> Done: 清理抛出其他异常或返回
Running --> Done: return
Running --> Done: 其它异常
Done --> [*]
done()/cancelled()/result()/exception()- 从未 await 的 done task,其异常可能只在回调/日志中显现(版本与调试模式有关)——工程上应显式回收
2. 超时 API
| API | 作用 |
|---|---|
asyncio.wait_for(aw, timeout) | 到期请求取消并等待;取消传播时转为 TimeoutError,任务吞取消后可能返回结果或抛其他异常 |
asyncio.timeout(seconds)(3.11+) | 到期取消当前任务;离开上下文时把自己的取消转为 TimeoutError,通常在上下文外捕获 |
| 自建 deadline | 用循环时间计算剩余预算 |
自 Python 3.11 起 wait_for 抛内置 TimeoutError,asyncio.TimeoutError 是其别名;本篇示例按 3.11+ 描述。被调用代码自己抛出的 TimeoutError 也会进入同一 except,不能只凭类型证明是预算到期。
3. 取消语义
task.cancel(msg=...)请求取消- 事件循环获得调度机会时向协程注入 CancelledError,通常表现为挂起的 await 抛出;完成对象的 await 未必让出执行权
- 若任务吞掉
CancelledError且继续运行,可能“取消失败”
清理模板:
try:
await work()
finally:
await close_resource()
异步清理本身也可能被再次取消或失败;这个模板不保证在强制退出、重复取消时完成全部清理。
4. 结构化并发(TaskGroup,3.11+)
async with asyncio.TaskGroup() as tg:
tg.create_task(coro1())
tg.create_task(coro2())
- 子任务以非 CancelledError 异常失败时,通常取消同组其他任务并等待;单个子任务被取消不等同于组失败。
- 离开上下文时等待子任务结束,普通失败汇总为 ExceptionGroup/BaseExceptionGroup;KeyboardInterrupt/SystemExit 有特殊重抛规则。
- 比“一堆 fire-and-forget create_task”更安全
5. gather vs TaskGroup vs 手动 task
| 方式 | 优点 | 风险 |
|---|---|---|
gather | 按输入顺序返回结果 | 默认首个异常立即传播,不自动取消其他任务;return_exceptions=True 把异常当结果 |
TaskGroup | 结构化 | 版本要求 3.11+ |
| 手动 task | 灵活 | 易漏回收 |
6. shield(高级,慎用)
asyncio.shield(aw) 保护 awaitable 不被外层取消直接取消。
超时场景误用 shield 可能导致“外层超时了,内层还在跑”。本篇主示例有意不使用 shield。
设计动机
事件循环不能强行杀掉任意 Python 帧(可能持有锁/不变量)。因此取消是协作式:
- 在明确的 await 边界插入取消
- 让代码用
try/finally维护资源安全
超时则是把“用户/下游等待预算”编码进控制流,与 Go context.WithTimeout 目标相近。
边界情况与反直觉行为
1. 取消点不在 CPU 空转
纯计算循环若无 await,会延迟响应取消。需要在循环中 await asyncio.sleep(0) 或把计算丢线程/进程。
2. wait_for 与已完成 task
已完成则直接返回结果;超时逻辑不会倒放。
3. 超时后 task 状态
默认 wait_for 抛出其超时异常时,通常已等到被取消任务结束;若任务吞取消,可能返回正常结果。shield 保护的内层可能仍运行,必须有 owner 后续回收。
4. CancelledError 继承关系
在 3.8+ 起 CancelledError 是 BaseException 的子类(不是普通 Exception)。
except Exception: 抓不住它——这是刻意设计,避免误吞取消。
5. 任务里再创建子任务
取消父任务不会自动完美取消你手动挂起的所有子图,除非你用 TaskGroup 或显式级联 cancel。
常见误区
[!warning] 常见误区:超时后不管 task 错误理解:函数返回即可。
正确模型:明确谁负责取消和等待;默认 wait_for 已负责,shield 或仅停止等待的设计则需独立 owner 收尾。
[!warning] 常见误区:
except Exception包住整段协程 错误理解:统一错误处理。
正确模型:Python 3.8+ 的 except Exception 不会捕获 asyncio.CancelledError;若显式捕获取消,清理后通常应重抛。
[!warning] 常见误区:fire-and-forget create_task 错误理解:后台跑就行。
正确模型:保存引用、回收异常、定义生命周期所有者。
[!warning] 常见误区:用 sleep 等待“应该取消了” 错误理解:sleep 0.1 再继续。
正确模型:await 被取消的 task 本身。
与相邻概念对比
| 机制 | 领域 | 传播方式 |
|---|---|---|
| asyncio 取消 | Python 协程任务 | Task.cancel → await 点 |
| thread Event | 线程 | 协作查 flag |
| process terminate | 进程 | 强杀/信号 |
| Go context | 调用树 | ctx.Done 传播 |
文字说明:异步服务关闭、请求取消、上游超时,应统一成“预算与取消图”,而不是各写各的 sleep。
工程实践
请求级预算
- 入口设置总超时
- 下游调用使用剩余预算
- 超时指标:
timeout_total/ 按依赖名
资源清理清单
- HTTP session / DB 连接
- 文件句柄
- 队列 consumer 的
task_done - 后台心跳 task
与 Web 框架
- 客户端断开时取消 handler task(框架相关)
- 阻塞库调用放
to_thread,并仍要能响应取消(线程中断有限——设计时避免长阻塞)
推荐默认
- 外部 I/O 必有 timeout
- 后台 task 有 owner
- 优先 TaskGroup(版本允许)
- 不随意 shield
可验证实验
实验 1:主路径
运行示例,确认出现 timeout 与 quote cancelled; cleanup。
实验 2:放宽超时
把 wait_for(..., 0.1) 改为 1.0,应打印 result: quote:100 且无 cancelled。
实验 3:吞掉 CancelledError(反例)
在 slow_quote 的 except CancelledError 里直接 return “nope”,wait_for 会得到这个结果而非超时降级;可见请求取消不等于任务最终处于 cancelled。实验后恢复重抛。
实验 4:TaskGroup(3.11+)
用 TaskGroup 同时拉两个慢调用,让其中一个失败,观察另一个是否被取消。
实验 5:对比漏 await
用 wait_for(shield(task), 0.1) 只限制外层等待,捕获超时后检查 task.done(),再显式 cancel 并 await 收尾。不要拿默认 wait_for 当作“漏取消”反例。
本节总结
- Task 是生命周期对象,不是“随便丢后台”的句柄。
- 超时与取消是异步正确性的一等公民。
- 协作式清理靠 await 点 + finally;
except Exception不能当万能网。
自测题
概念题
- 为什么 cancel 后还要
await task? - 为什么
except Exception不应包住取消?
代码推理题
wait_for(sleep(1), 0.1)超时后,sleep 任务通常处于什么状态?
工程思考题
- 网关已有 3s 超时,服务内是否还要设下游超时?为何?
参考答案
- 确认任务结束并跑完清理,避免孤儿任务与未回收异常。
- Python 3.8+ 中它不是 Exception 子类,该 except 不会吞取消;显式捕获后无意吞掉才会破坏传播。
- 普通 sleep 任务在 wait_for 抛 TimeoutError 前已完成取消;若任务包含异步清理,等待可能超过预算。
- 要;网关超时后服务可能仍在工作。分层预算避免无效计算与资源占用。
延伸阅读与资料来源
| 资料 | 类型 | 支撑内容 |
|---|---|---|
| Tasks and coroutines | 文档 | Task/cancel/wait_for/TaskGroup |
| Timeouts | 文档 | 超时 API |
| PEP 3156 | PEP | 异步 IO 背景 |
| Trio nursery 思想 | 文档 | 结构化并发对照(概念) |
笔记元信息
- 角色:原子概念(asyncio 正确性关键)
- 前置必读:python-async-programming
- 对照:Go context 传播 vs Task 取消