Python asyncio 任务管理实战:取消、超时与结构化并发
本文目录
这篇接在 Python 学习路线 后面,解决异步代码进入服务端之后最容易被忽略的三件事:任务由谁负责、超时后谁会被取消、异常发生时其他任务怎样收尾。示例面向 Python 3.11 及以上版本,因为 asyncio.TaskGroup 和 asyncio.timeout() 从 3.11 开始提供。较旧版本不能直接运行这些 API;先对照目标 Python 版本的文档。
先分清协程、任务和任务组
调用 async def 函数会得到协程对象,但不会自动开始执行。await coroutine() 把控制权交给这个协程;asyncio.create_task() 则把它排进当前事件循环,形成可以独立运行和取消的 Task。创建 Task 就产生了管理责任:谁等待它、读取它的异常、在退出时取消它,都要有明确答案。
对于同一操作范围内的一组并发工作,优先考虑 asyncio.TaskGroup。离开 async with 时任务组会等待子任务结束;若一个子任务以普通异常失败,任务组会取消并等待其他子任务,再把错误作为异常组抛出。任务的生命周期因此有清楚的边界。
“后台任务”不等于“无人管理的任务”。如果确实需要脱离当前请求继续运行,应交给有停止流程、异常记录和容量限制的应用级任务管理器,而不是在请求中随手 create_task() 后丢弃返回值。
取消是一项协作请求
task.cancel() 不会强制终止 Python 代码。它会请求在任务下一次能暂停的位置注入 asyncio.CancelledError。任务可以在 finally 中释放自己持有的资源;清理后通常应让取消异常继续传播。不要用 except BaseException 或 except asyncio.CancelledError: pass 把取消吞掉,TaskGroup 和超时上下文都依赖取消机制工作。
async def wait_until_cancelled(released):
try:
await asyncio.Event().wait()
finally:
released.set()
finally 适合做短小、确定的清理,例如释放锁、标记关闭状态或退出资源上下文。网络写入、长时间刷新、需要重试的持久化不应假设一定能在取消期间完成;要通过事务、幂等键或独立的受管任务处理。若显式捕获 CancelledError 记录诊断,完成必要清理后应重新抛出。
选择符合语义的超时
超时不仅是“等多久”,也决定取消的对象和后续清理行为:
| API | 超时时的行为 | 适合的边界 |
|---|---|---|
asyncio.timeout(seconds) | 取消当前任务,并在上下文退出时把由该期限触发的取消转换成 TimeoutError | 限制一段 async with 代码的总耗时 |
asyncio.wait_for(awaitable, seconds) | 取消目标 awaitable,并等待它处理取消;实际总耗时可能超过秒数 | 需要对一个目标 awaitable 施加独立期限 |
asyncio.wait(tasks, timeout=seconds) | 返回 done 和 pending,不会因为期限到达自动取消 pending | 需要先收割已完成任务,再自行决定 pending 的命运 |
使用 asyncio.timeout() 时,TimeoutError 要在上下文外捕获。若被调用的业务函数也可能自行抛出 TimeoutError,可以检查本次上下文是否真的到期,避免把内部错误误判成外层期限:
class RequestDeadlineExceeded(TimeoutError):
pass
async def call_with_deadline(operation, seconds):
deadline = asyncio.timeout(seconds)
try:
async with deadline:
return await operation()
except TimeoutError as error:
if not deadline.expired():
raise
raise RequestDeadlineExceeded("请求超过总耗时限制") from error
超时不是强制的墙钟上限:协程必须响应取消,清理阶段也会耗时。wait_for() 文档明确说明它会等待目标真正取消,因此总时长可能超过传入的 timeout。不要把 asyncio.shield() 当作通用修复;它会让被 shield 的任务在调用方取消后继续运行,调用方仍会收到 CancelledError,而留下来的任务仍需要被持有、等待并观察异常。
用 TaskGroup 管理并发和失败
一个批次有很多项时,直接为每项创建 Task 会让 Task 数量随输入一起增长。Semaphore 可以限制同时进入临界区的数量,但并不会减少已经创建的 Task 数量。下面的有限批次示例固定 worker 数量,并用队列分配任务;输入和结果仍占用 O(n) 内存,因此它不适合无限流。
async def bounded_map(items, limit, process):
if limit < 1:
raise ValueError("并发数必须大于 0")
items = list(items)
queue = asyncio.Queue()
for index, item in enumerate(items):
queue.put_nowait((index, item))
results = {}
async def worker():
while True:
try:
index, item = queue.get_nowait()
except asyncio.QueueEmpty:
return
results[index] = await process(item)
async with asyncio.TaskGroup() as group:
for _ in range(min(limit, len(items))):
group.create_task(worker())
return [results[index] for index in range(len(items))]
如果输入来自长期运行的生产者,应改用带 maxsize 的 asyncio.Queue,让生产者在队列满时等待,形成背压;并实现明确的停止信号和 task_done()/join() 协议。并发上限限制同一时刻的工作数,速率限制控制单位时间的请求数,两者不是一回事。
TaskGroup 遇到第一个非取消异常时会请求取消同组其余任务,等待清理完成,再抛出 ExceptionGroup。处理时可以用 except* 按异常类型拆分;不认识的异常要继续上抛。若不同任务的失败可以互相独立,应明确设计部分成功语义,而不是默认用一个 gather() 代替任务所有权。
一个重要差异:在 return_exceptions=False 的默认模式下,asyncio.gather() 会把第一个异常传播给等待方,但其他 awaitable 通常继续运行。异常传播并不意味着剩余工作已经停掉。任务组则把同组任务纳入一个明确的生命周期。
下面的函数演示一个子任务失败时,同组的慢任务会收到取消,并且在错误向外传播前完成 finally 清理:
async def task_group_cancels_siblings():
cleaned = asyncio.Event()
async def slow_task():
try:
await asyncio.Event().wait()
finally:
cleaned.set()
async def failing_task():
await asyncio.sleep(0)
raise ValueError("example failure")
try:
async with asyncio.TaskGroup() as group:
group.create_task(slow_task())
group.create_task(failing_task())
except ExceptionGroup as errors:
contains_expected_error = any(isinstance(error, ValueError) for error in errors.exceptions)
return contains_expected_error and cleaned.is_set()
return False
可运行例子与测试
把前四个 Python 示例按顺序放进 asyncio_guide.py,并在文件顶部添加 import asyncio;把最后一个 Python 示例保存为 test_asyncio_guide.py。使用 Python 3.11 或更新版本运行:
python -m unittest -v test_asyncio_guide.py
import asyncio
import unittest
from asyncio_guide import (
RequestDeadlineExceeded,
bounded_map,
call_with_deadline,
task_group_cancels_siblings,
wait_until_cancelled,
)
class AsyncioGuideTests(unittest.IsolatedAsyncioTestCase):
async def test_cancellation_runs_finally_and_propagates(self):
released = asyncio.Event()
task = asyncio.create_task(wait_until_cancelled(released))
await asyncio.sleep(0)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
self.assertTrue(released.is_set())
async def test_deadline_is_distinct_from_operation_timeout(self):
async def slow_operation():
await asyncio.sleep(0.05)
return "late"
with self.assertRaises(RequestDeadlineExceeded):
await call_with_deadline(slow_operation, 0.005)
async def operation_with_its_own_timeout():
raise TimeoutError("operation failure")
with self.assertRaisesRegex(TimeoutError, "operation failure"):
await call_with_deadline(operation_with_its_own_timeout, 1)
async def test_worker_count_is_bounded_and_results_keep_input_order(self):
active = 0
peak = 0
async def process(item):
nonlocal active, peak
active += 1
peak = max(peak, active)
try:
await asyncio.sleep(0)
return item * 2
finally:
active -= 1
result = await bounded_map([3, 1, 4, 2, 5], 2, process)
self.assertEqual(result, [6, 2, 8, 4, 10])
self.assertLessEqual(peak, 2)
async def test_task_group_cancels_siblings_and_waits_for_cleanup(self):
self.assertTrue(await task_group_cancels_siblings())
if __name__ == "__main__":
unittest.main()
检查点不只是“最后返回了结果”:应验证输入顺序、同时运行数、超时错误来源、取消后的清理,以及一个子任务失败时同组任务是否退出。接真实 HTTP 客户端时,还要核对连接池/响应体是否在上下文中关闭,服务退出时任务是否被收拢,重试是否仅作用于幂等请求。
服务端落地清单
- 为每个 Task 指定所有者和结束条件;请求范围的并发任务优先使用
TaskGroup。 - 将连接、文件、锁等资源放进异步上下文或
try/finally,取消后释放路径能被测试观察。 - 在总请求边界和下游调用边界分别定义期限;传递剩余 deadline,避免每一层重新获得完整预算。
- 为输入队列设容量上限;单独配置并发数、速率限制、队列等待时长和过载响应。
- 只对可重试且幂等的失败做有限重试;不要重试
CancelledError,也不要让重试超过请求总期限。 - 记录任务名、请求关联 ID、超时来源和取消原因;日志避免包含令牌、完整个人数据或请求正文。
- 在测试中触发客户端取消、超时、子任务异常和服务关闭,验证没有悬挂任务、未取回的异常或未释放连接。
官方资料
这些页面按 3.11 固定版本引用,便于对应 TaskGroup 和 timeout() 首次进入标准库的边界。更新项目 Python 版本后,应再检查 API 行为和依赖库的取消约定。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。