把 asyncio 写到能停下来:TaskGroup、取消、超时与背压
很多 Python 异步教程最后都会停在这里:同时创建几个协程,然后 gather 一下,看着总耗时从 5 秒变成 1 秒。
这当然没错,但它只回答了“任务能不能同时跑”,没有回答更难的问题:任务出错时怎么办?某个请求超时以后,其他任务要不要停?用户按下 Ctrl+C,程序能不能把手里的资源收拾干净?生产者比消费者快很多时,内存会不会一路涨上去?
异步程序真正难的部分不是 async 和 await,而是生命周期。
这篇文章从一个看似合理的 gather() 版本开始,逐步改造成一个可以控制的任务处理器。整个示例只使用 Python 标准库,目标不是实现一个完整爬虫,而是把并发系统中最容易被忽略的边界讲清楚。
版本范围
文中的基本写法适用于 Python 3.11 及以上版本:
asyncio.TaskGroup在 Python 3.11 加入;asyncio.timeout()在 Python 3.11 加入;ExceptionGroup和except*同样来自 Python 3.11;- 示例最后使用的
asyncio.Queue.shutdown()需要 Python 3.13 及以上。
如果项目仍然运行在 Python 3.11 或 3.12,可以把队列关闭部分替换为哨兵值,任务管理的核心思想不变。当前 Python 文档已经把 TaskGroup、取消传播和超时放在结构化并发的同一组 API 中;这不是偶然的,因为超时和任务组本质上都依赖取消机制。
一个能跑,但不够可靠的版本
先写一个最常见的并发处理器。这里用睡眠模拟 I/O,不访问真实网站,避免把网络不稳定误认为并发问题。
1 | import asyncio |
这个程序可以并发运行,但它有几个工程问题。
第一,bad 抛出异常后,gather() 默认会把异常交给调用方。其他任务是否已经完成、是否还在后台运行,需要调用方自己继续确认。第二,任务没有名字,出现问题时日志很难对应到输入。第三,没有超时,没有重试,也没有限制同时运行的数量。把 items 从 4 个换成 400 万个,程序会一次性创建 400 万个任务。
gather() 适合简单的“把几件独立的事一起等完”。一旦任务拥有共同的生命周期,就需要更明确的结构。
先理解取消:它是请求,不是按钮
调用 task.cancel() 并不意味着任务已经立刻结束。它只是向任务发出取消请求;任务下一次在可中断的 await 处恢复时,才会收到 asyncio.CancelledError。
1 | import asyncio |
这里有三个细节值得记住。
CancelledError 直接继承自 BaseException,不是普通的 Exception。一般的 except Exception 不会把它吞掉,这是好事;如果代码写了 except BaseException,就必须非常小心。
如果确实需要捕获取消异常,清理完成以后通常应该再次 raise。一个任务如果把取消吞掉,外层的超时和 TaskGroup 可能无法得到正确状态。Python 文档也特别提醒,结构化并发组件内部使用取消来协调任务;随意吞掉 CancelledError 会使它们行为异常。
TaskGroup:把任务放进同一个生命周期
TaskGroup 的核心不是少写几行代码,而是表达一个关系:这一组任务属于同一个工作单元。
1 | import asyncio |
当组内某个任务抛出非取消异常时,TaskGroup 会取消其他仍在运行的任务,等待它们完成清理,再把错误组合成 ExceptionGroup 抛出。这样就不会出现“主程序已经返回,某些任务还偷偷留在后台”的模糊状态。
这也是它和 gather() 的思路差异:
| 场景 | gather() |
TaskGroup |
|---|---|---|
| 几个互不相关的结果 | 简洁 | 也可以使用,但表达力稍重 |
| 一组任务共同成功或失败 | 需要自己编排 | 默认就是这个语义 |
| 一个任务失败后取消兄弟任务 | 需要额外处理 | 内置处理 |
| 等待所有任务清理完毕 | 容易遗漏 | 上下文退出时保证等待 |
| 失败信息 | 单个异常或返回值 | ExceptionGroup |
TaskGroup 并不会替你决定业务策略。比如“单个 URL 失败是否应该让整批任务失败”,仍然需要在 worker 内部捕获并记录;但它至少会让未处理的异常沿着明确的边界传播。
超时应该包住哪一段
超时不是给任务贴一个数字,而是给一段等待设置截止时间。现代写法是 asyncio.timeout():
1 | async def fetch_one(item: str, limit: float) -> str: |
注意 TimeoutError 应该在上下文管理器外面捕获。asyncio.timeout() 在截止时间到达时取消当前任务,并在上下文退出时把内部的 CancelledError 转换为 TimeoutError。如果在上下文内部捕获,通常捕获不到最终的 TimeoutError。
wait_for() 仍然有用,尤其是只需要等待一个 awaitable 时:
1 | result = await asyncio.wait_for(handle("one"), timeout=0.5) |
但在一段代码中需要嵌套多个 await、动态调整截止时间或和 TaskGroup 配合时,timeout() 更容易表达“这一段工作的总预算”。
还有一个容易误解的点:超时不等于底层工作已经瞬间消失。取消仍然需要在任务的下一个可中断位置发生,finally 清理也需要时间。所以一个严格的上层超时,不应该假设所有底层系统都能在同一纳秒停止。
有界队列:背压是稳定性功能
并发数和队列长度解决的是两个不同的问题。
- 并发数限制同时处理多少任务;
- 队列长度限制等待中的任务最多积累多少。
如果生产者不停地读取数据,消费者却因为网络或磁盘变慢,无界队列会把压力转化成内存增长。asyncio.Queue(maxsize=N) 在队列满时让 put() 等待,这就是背压:下游变慢,上游也必须慢下来。
1 | queue: asyncio.Queue[str] = asyncio.Queue(maxsize=100) |
maxsize=100 不是“优化参数”,而是系统的内存边界之一。它越大,吞吐可能更平滑,但故障时积压更多;它越小,背压来得更早,但生产端更容易等待。真正合适的值要结合任务对象大小、单个任务耗时和可接受的排队延迟测量,而不是照搬别人的数字。
一个完整的可控任务处理器
下面把几个概念组合起来。这个版本使用 Python 3.13 的 Queue.shutdown(),关闭生产端后,消费者会把队列里已经放入的项目处理完,再收到 QueueShutDown 退出。
1 | from __future__ import annotations |
这段代码里每个组件都有单一职责:produce() 只负责投递,worker() 只负责消费,run_one() 只负责一次带超时的业务操作,process() 负责把它们放进同一个生命周期。
还有三个值得注意的决定。
业务错误不一定要取消整组任务
run_one() 把预期中的超时和数据错误转成 Outcome。这样单个工作项失败不会让整批任务失效,最后可以统一统计成功与失败。
但如果连接池损坏、配置缺失或程序出现真正的编程错误,就不应该把异常全部转成字符串。让它穿过 worker() 进入 TaskGroup,兄弟任务会被取消,程序也会得到明确的失败信号。
换句话说,要区分:
1 | 业务失败:这个项目失败,但系统还能继续 |
task_done() 必须与 get() 配对
queue.join() 等待的是“每一次 put() 都对应一次 task_done()”。如果 worker 在 get() 之后直接抛出异常,没有进入 finally,主任务可能永远卡在 join()。
这也是为什么 task_done() 放在 finally 里。这里的语义是:任务已经从队列取出,本次处理生命周期结束;如果业务需要在取消时重新入队,则应该显式设计重试队列,而不是偷偷遗漏一次 task_done()。
正常关闭和立即停止是两种不同语义
1 | queue.shutdown() |
表示不再接受新项目,但允许已经入队的项目排空。适合正常收尾。
1 | queue.shutdown(immediate=True) |
表示尽快打断队列处理,已经排队的项目可能不会执行。适合进程即将被强制终止,或者任务已经失去继续处理的价值。它会破坏“每次 put 都最终 task_done”的直觉,因此不应该当作普通停止按钮。
如果使用 Python 3.12 或更早版本,可以用唯一的哨兵对象通知 worker 退出:生产者完成后向队列放入与 worker 数量相同的哨兵。哨兵方案能工作,但需要自己保证数量、顺序和取消路径;3.13 的 shutdown() 把这部分状态收进了队列对象。
外部取消如何传到最里面
实际程序通常还有一层服务入口:定时任务、命令行程序或 Web 请求会启动 process()。外部停止时,取消应该沿着调用栈向下传播。
1 | async def serve() -> None: |
不要在最外层写一个无条件的:
1 | except BaseException: |
它会同时吞掉 KeyboardInterrupt、系统退出信号和任务取消。程序表面上“没有报错”,实际上可能留下半开的连接、未刷新的文件和未提交的结果。
更可靠的关闭顺序通常是:
1 | 停止接收新任务 |
取消是控制流的一部分,不是异常日志里的噪音。
重试不应该和取消混在一起
网络任务常常需要重试,但“请求失败”和“上层要求停止”不是一回事。重试装饰器如果捕获了所有异常,就可能在程序已经关闭时继续睡眠和重试。
一个简单的重试循环应该把取消放在明确的分支里:
1 | async def retrying(job: Job, attempts: int = 3) -> Outcome: |
重试次数、退避时间和可重试异常应该是业务策略,不应由并发框架偷偷决定。对于不可重试的参数错误,应该立即失败;对于取消,应该立即传播。
用指标观察“可控”是否真的成立
只看总耗时,无法知道一个异步系统是否健康。至少应该记录:
- 队列峰值长度;
- 当前运行任务数;
- 每个任务等待时间和处理时间;
- 超时、重试、业务失败和系统失败次数;
- 取消发生时仍有多少任务在运行;
- 关闭后是否还有未完成队列项。
例如,可以在 put() 前后记录队列长度,在 run_one() 周围记录单调时钟:
1 | loop = asyncio.get_running_loop() |
不要用 time.time() 计算耗时,因为系统时钟可能被校准或回拨;事件循环提供的单调时钟才适合测量间隔。
gather() 什么时候仍然合适
写完 TaskGroup 后,不需要把 gather() 视为过时 API。它仍然适合以下场景:
- 一次并行读取几份互不相关的配置;
- 所有子任务都已经有明确的错误转换策略;
- 调用方只需要结果列表,不需要管理复杂的生命周期;
- 任务数量小且有明确上限。
真正应该避免的是:把 gather() 当成任务管理器,然后在它外面不断补取消、超时、重试、队列和清理逻辑。到了那个程度,代码其实已经在手写一个不完整的 TaskGroup 了。
最后的判断标准
一段异步代码是否成熟,不是看它用了多少个 create_task(),也不是看基准测试快了多少。可以用下面的问题检查它:
- 一个任务失败时,兄弟任务的命运是否明确?
- 上游速度超过下游时,内存是否有上限?
- 超时发生后,底层任务是否真的停止并完成清理?
- Ctrl+C 或外部取消能否到达最内层?
- 是否区分了业务失败、可重试失败和系统失败?
queue.join()是否一定能结束?- 任务、连接池、文件和临时资源是否拥有清楚的所有者?
- 关闭流程是否可以重复执行而不产生第二次破坏?
如果这些问题没有答案,程序可能只是“现在能跑”;如果答案写在代码结构里,程序才开始变得可维护。
异步编程最让人放心的时刻,不是任务同时完成,而是按下停止以后,它知道自己应该怎样停下来。