asyncio 任务、超时与取消

用 Task 管理并发协程生命周期;以 timeout 与 cancel 实现截止与协作式退出,避免孤儿任务与资源泄漏。

#type / concept #status / growing #tech / dev #resource / python #tech / lang / python

[!info] 关联笔记

asyncio 任务、超时与取消

这个概念为什么出现

会写 await asyncio.gather(...) 只够演示。线上故障长这样:

  • 某个上游卡住,请求线程/协程被拖死,没有超时
  • 客户端已断开,服务端还在跑完整条 LLM 调用链
  • create_task 火后不管,异常在后台静默,资源不放

asyncio 的并发单元是 Task。正确性不只是“结果算对”,还包括:

  1. 什么时候必须结束(超时/截止)
  2. 如何协作退出(取消)
  3. 谁负责回收异常与清理(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

结合场景再看四个关注点

  1. 超时是产品语义,不是可选项。
  2. CancelledError 在 await 点出现——协作式,不是杀线程。
  3. cancel 后仍要 await,才能确认清理跑完、异常不丢。
  4. 降级返回与“继续傻等”是两种产品;实现必须匹配。

核心概念与准确模型

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. 取消语义

  1. task.cancel(msg=...) 请求取消
  2. 任务在下一个 await 点(可取消点)收到 CancelledError
  3. 若任务吞掉 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+ 起 CancelledErrorBaseException 的子类(不是普通 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,并仍要能响应取消(线程中断有限——设计时避免长阻塞)

推荐默认

  1. 外部 I/O 必有 timeout
  2. 后台 task 有 owner
  3. 优先 TaskGroup(版本允许)
  4. 不随意 shield

可验证实验

实验 1:主路径

运行示例,确认出现 timeoutquote cancelled; cleanup

实验 2:放宽超时

wait_for(..., 0.1) 改为 1.0,应打印 result: quote:100 且无 cancelled。

实验 3:吞掉 CancelledError(反例)

slow_quoteexcept CancelledErrorreturn "nope" 不 raise,观察取消语义被破坏(可能不再按预期结束)。看完改回 re-raise。

实验 4:TaskGroup(3.11+)

用 TaskGroup 同时拉两个慢调用,让其中一个失败,观察另一个是否被取消。

实验 5:对比漏 await

create_task 后超时仅 return,不 cancel/await;用日志/debug 观察任务仍可能跑完 sleep(副作用残留)。

本节总结

  • Task 是生命周期对象,不是“随便丢后台”的句柄。
  • 超时与取消是异步正确性的一等公民。
  • 协作式清理靠 await 点 + finally;except Exception 不能当万能网。

自测题

概念题

  1. 为什么 cancel 后还要 await task
  2. 为什么 except Exception 不应包住取消?

代码推理题

  1. wait_for(sleep(1), 0.1) 超时后,sleep 任务通常处于什么状态?

工程思考题

  1. 网关已有 3s 超时,服务内是否还要设下游超时?为何?
参考答案
  1. 确认任务结束并跑完清理,避免孤儿任务与未回收异常。
  2. CancelledError 不是 Exception 子类;误捕会破坏取消传播。
  3. 通常被取消(cancelled/清理中),不应再当成功结果用。
  4. 要;网关超时后服务可能仍在工作。分层预算避免无效计算与资源占用。

延伸阅读与资料来源

资料类型支撑内容
Tasks and coroutines文档Task/cancel/wait_for/TaskGroup
Timeouts文档超时 API
PEP 3156PEP异步 IO 背景
Trio nursery 思想文档结构化并发对照(概念)

笔记元信息

  • 角色:原子概念(asyncio 正确性关键)
  • 前置必读:python-async-programming
  • 对照:Go context 传播 vs Task 取消
创建于 2026/7/15 更新于 2026/7/15