asyncio 任务、超时与取消
用 Task 管理并发协程生命周期;以 timeout 与 cancel 实现截止与协作式退出,避免孤儿任务与资源泄漏。
[!info] 关联笔记
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 超时后不管底层 task,让它继续打上游。
正确写法:超时后 cancel 并 await 收尾,让连接/会话有机会关闭。
# asyncio_timeout_demo.py
# 业务意图:上游超时则失败,并确保后台报价协程被取消清理。
# 教学点:
# - create_task 并发启动;
# - wait_for 超时;
# - cancel + await 收尾;
# - 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")
task.cancel()
try:
await task # 等待取消完成,避免孤儿任务
except asyncio.CancelledError:
pass
return "quote:degraded"
async def main() -> None:
print("result:", await fetch_with_budget())
if __name__ == "__main__":
asyncio.run(main())
建议运行:
python asyncio_timeout_demo.py
期望输出:
timeout
quote cancelled; cleanup
result: quote:degraded
结合场景再看四个关注点
- 超时是产品语义,不是可选项。
CancelledError在 await 点出现——协作式,不是杀线程。- cancel 后仍要 await,才能确认清理跑完、异常不丢。
- 降级返回与“继续傻等”是两种产品;实现必须匹配。
核心概念与准确模型
1. Task 生命周期
stateDiagram-v2
[*] --> Pending: create_task
Pending --> Running: 循环调度
Running --> Pending: await 等待
Running --> Cancelling: cancel()
Cancelling --> Cancelled: 在 await 点抛出并结束
Running --> Done: return
Running --> Done: 其它异常
Done --> [*]
done()/cancelled()/result()/exception()- 从未 await 的 done task,其异常可能只在回调/日志中显现(版本与调试模式有关)——工程上应显式回收
2. 超时 API
| API | 作用 |
|---|---|
asyncio.wait_for(aw, timeout) | 超时则取消 aw(通常)并抛 TimeoutError |
asyncio.timeout(seconds)(3.11+) | 上下文管理器风格截止 |
| 自建 deadline | 用循环时间计算剩余预算 |
注意:超时异常类型在文档中为 TimeoutError(Python 3 内置);不要与已更名历史混淆。
3. 取消语义
task.cancel(msg=...)请求取消- 任务在下一个 await 点(可取消点)收到
CancelledError - 若任务吞掉
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())
- 组内一个失败,常取消其余任务
- 离开上下文时任务应收束
- 比“一堆 fire-and-forget create_task”更安全
5. gather vs TaskGroup vs 手动 task
| 方式 | 优点 | 风险 |
|---|---|---|
gather | 简单 | 异常/取消策略要查清 |
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 状态
通常被取消;若用 shield 包住,内层可能仍在运行——必须另案回收。
4. CancelledError 继承关系
在 3.8+ 起 CancelledError 是 BaseException 的子类(不是普通 Exception)。
except Exception: 抓不住它——这是刻意设计,避免误吞取消。
5. 任务里再创建子任务
取消父任务不会自动完美取消你手动挂起的所有子图,除非你用 TaskGroup 或显式级联 cancel。
常见误区
[!warning] 常见误区:超时后不管 task 错误理解:函数返回即可。
正确模型:cancel + await 收尾,否则泄漏与重复副作用。
[!warning] 常见误区:
except Exception包住整段协程 错误理解:统一错误处理。
正确模型:会漏掉/干扰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" 不 raise,观察取消语义被破坏(可能不再按预期结束)。看完改回 re-raise。
实验 4:TaskGroup(3.11+)
用 TaskGroup 同时拉两个慢调用,让其中一个失败,观察另一个是否被取消。
实验 5:对比漏 await
create_task 后超时仅 return,不 cancel/await;用日志/debug 观察任务仍可能跑完 sleep(副作用残留)。
本节总结
- Task 是生命周期对象,不是“随便丢后台”的句柄。
- 超时与取消是异步正确性的一等公民。
- 协作式清理靠 await 点 + finally;
except Exception不能当万能网。
自测题
概念题
- 为什么 cancel 后还要
await task? - 为什么
except Exception不应包住取消?
代码推理题
wait_for(sleep(1), 0.1)超时后,sleep 任务通常处于什么状态?
工程思考题
- 网关已有 3s 超时,服务内是否还要设下游超时?为何?
参考答案
- 确认任务结束并跑完清理,避免孤儿任务与未回收异常。
CancelledError不是Exception子类;误捕会破坏取消传播。- 通常被取消(cancelled/清理中),不应再当成功结果用。
- 要;网关超时后服务可能仍在工作。分层预算避免无效计算与资源占用。
延伸阅读与资料来源
| 资料 | 类型 | 支撑内容 |
|---|---|---|
| Tasks and coroutines | 文档 | Task/cancel/wait_for/TaskGroup |
| Timeouts | 文档 | 超时 API |
| PEP 3156 | PEP | 异步 IO 背景 |
| Trio nursery 思想 | 文档 | 结构化并发对照(概念) |
笔记元信息
- 角色:原子概念(asyncio 正确性关键)
- 前置必读:python-async-programming
- 对照:Go context 传播 vs Task 取消