在 asyncio 里,结构化并发不是 gather 的语法糖,而是一套建立在取消(cancellation)之上的协议。TaskGroup 靠取消子任务来收束,asyncio.timeout 靠取消当前任务来实现超时——两者都要求你写的每一层代码把 CancelledError 原样放行。只要有一个 except BaseException,或者一个 finally 里的裸 await,整套语义就会以很难复现的方式塌掉。Python 3.15 补上了最后一块拼图 TaskGroup.cancel();按 PEP 790,3.15.0rc1 于 2026 年 8 月 4 日发布,final 定在 2026 年 10 月 1 日(这一版其余的破坏性变更见 Python 3.15 迁移指南)。
两个原语共用一套取消计数
asyncio.TaskGroup 和 asyncio.timeout() 都是 Python 3.11 加入的,同期加入的还有 Task.cancelling() 和 Task.uncancel()——后两个才是理解前两个的钥匙。
Task.cancel() 不会立刻杀死任务,它把计数加一,并安排在下一轮事件循环里往协程中扔一个 CancelledError。cancelling() 返回「cancel 次数减 uncancel 次数」。TaskGroup 和 timeout 就靠这个计数区分「这次取消是我发起的」还是「外面有人在取消我,我得让路」。官方文档写得很直接:结构化并发组件内部就是用取消实现的,协程一旦吞掉 CancelledError,它们会行为异常。
timeout 在哪一刻把 CancelledError 换成 TimeoutError
看 Lib/asyncio/timeouts.py 的 __aexit__,关键只有一行:
if self._task.uncancel() <= self._cancelling and exc_type is not None:
if issubclass(exc_type, exceptions.CancelledError):
raise TimeoutError from exc_val
elif exc_val is not None:
self._insert_timeout_error(exc_val)
if isinstance(exc_val, ExceptionGroup):
for exc in exc_val.exceptions:
self._insert_timeout_error(exc)
self._cancelling 是进入时快照的取消计数。退出时先 uncancel(),若结果没超过快照值,说明这期间没有新的外部取消请求,那这次 CancelledError 就是超时自己造成的,转成 TimeoutError。三个必须记住的结论:
一,TimeoutError 只能在 async with 外面捕获。 块内部流动的是 CancelledError,在里面写 except TimeoutError 永远抓不到东西。
二,asyncio.timeout() 必须在 Task 里用。 __aenter__ 里 current_task() 为 None 时直接 raise RuntimeError("Timeout should be used inside a task")。
三,只有异常是 CancelledError 时才抛 TimeoutError。 其他异常走 _insert_timeout_error,只是把一个 TimeoutError 插进 __context__ 链。这直接引出下面这个坑。
timeout 包 TaskGroup:except TimeoutError 会漏
这是生产代码里最常见、也最容易写错的组合:
try:
async with asyncio.timeout(5):
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_a())
tg.create_task(fetch_b())
except TimeoutError:
...
超时发生、且所有子任务都干净地响应了取消时,TaskGroup 抛 CancelledError,timeout 转成 TimeoutError,一切正常。
但只要有一个子任务在被取消的过程中抛了别的异常(比如清理阶段的 ConnectionResetError),TaskGroup 抛出的就是 ExceptionGroup 而不是 CancelledError。此时 timeout 走 _insert_timeout_error 分支——TimeoutError 只被插进这个 ExceptionGroup 本身及其各子异常的 __context__ 链上,外层 except TimeoutError 完全抓不到,ExceptionGroup 一路飞出去。所以 except TimeoutError 和 except* Exception 两条分支必须都写。
如果需求本来就是「每个子任务各自不超过 N 秒」,把 timeout 放进每个子协程里,语义清楚得多,也没有这个组合问题。
吞取消的唯一正确姿势
真的需要拦下取消(比如某个幂等补偿写入必须完成)时,光 except asyncio.CancelledError 不够,必须把取消状态一起清掉:
try:
return await work()
except asyncio.CancelledError:
if not can_absorb():
raise
asyncio.current_task().uncancel() # 少这行,外层计数就错位
return await compensate()
Python 3.13 改了 uncancel() 的行为:计数降到 0 时会撤销尚未投递的取消请求(重置内部 _must_cancel)。3.12 及更早没有这一步,同样的代码在 3.12 上可能刚 uncancel() 完又吃到一发 CancelledError。跨版本库要注意。
finally 里的清理同样危险:任务已处于取消状态时,finally 里的 await 会立刻再吃一次 CancelledError,清理只做一半。asyncio.shield(cleanup()) 只保护被包住的那个任务,外层协程仍会在 await 处收到 CancelledError。真正要求「无论如何跑完」的清理应交给组外任务,在进程退出前统一 join;长连接里客户端断开就会持续触发这类取消,SSE 场景的处理见 FastAPI SSE 生产指南。
从 gather / wait 迁过来
gather 和 wait 不是结构化的,它们的默认行为就是泄漏任务:
gather(..., return_exceptions=False)(默认):第一个异常立刻向外抛,其余 awaitable 不会被取消,继续在后台跑。你的except块跑完了,泄漏的任务还活着——Web 框架里这类脱缰任务的生命周期见 FastAPI 后台任务指南。gather(..., return_exceptions=True):CancelledError被当成普通结果收进列表——这就是一种吞取消。asyncio.wait():超时不取消任何 future,只是把它们放进pending返回;wait()自己被取消时,传进去的 future 也不取消。它是观察工具,不是并发管理工具。
| 旧写法 | 新写法 |
|---|---|
await gather(a(), b()) |
async with TaskGroup() as tg: tg.create_task(...) |
gather(return_exceptions=True) 后逐个判断 |
except* SomeError as eg: |
await wait_for(x(), 5) |
async with asyncio.timeout(5): await x() |
wait(..., FIRST_COMPLETED) 抢跑 |
3.15:tg.cancel() |
wait_for 从 3.12 起就是用 asyncio.timeout 重写的(gh-96764),语义已经统一;从 3.11 起它抛内建 TimeoutError,asyncio.TimeoutError 只是别名。3.11 起也禁止直接把协程对象传给 wait()。
迁移最容易翻车的一点:TaskGroup 抛的是 ExceptionGroup,原来的 except ValueError 全部失效。要么改 except*,要么在子任务内部就处理干净。
3.15 的 TaskGroup.cancel()
3.15 之前,「拿到第一个结果就收工」在 TaskGroup 里没有官方出口,只能靠自定义异常穿出去再 suppress 掉。3.15 加了 TaskGroup.cancel()(gh-127214,John Belmonte 提出,2026 年 4 月 24 日由 Guido van Rossum 合入):
async def first_wins(urls):
result = None
async with asyncio.TaskGroup() as tg:
async def run(url):
nonlocal result
result = await fetch(url)
tg.cancel()
for url in urls:
tg.create_task(run(url))
return result
文档给的语义:cancel() 会对组内所有未完成任务以及组的 body(parent task)调用 cancel(),而上下文管理器退出时不会抛 CancelledError。它是幂等的;在进入 async with 之前调用则进入时立即取消,于是可以把一个还没用的 TaskGroup 交给别处做远程取消。
和 trio 的 nursery.cancel_scope.cancel() 的差别正在这里:asyncio 版本在退出时对 parent task 做了 uncancel(),取消不穿透组边界。
还有哪些没修
gh-134471:asyncio.timeout(0) 会吞掉进入之前就已发出的外部取消,把它转成 TimeoutError,任务于是继续跑——_cancelling 快照看不到同一轮里已置位的 _must_cancel。issue 于 2025 年 5 月 21 日提出,影响 3.11 到 main,撰稿时仍是 open。结论:别拿 asyncio.timeout(0) 当「立即取消」的开关,那是 Task.cancel() 的活。
eager task 的取消泄漏(gh-128588)已修,做法是移掉 3.12 里那个引入错误取消的 eager 优化并回合;若线上钉在 3.12 早期补丁版且开了 eager_task_factory,升补丁版本。
落地清单
- grep 所有
except BaseException和裸except:,凡包住await的都确认CancelledError会 re-raise。 except asyncio.CancelledError后若不raise,必须current_task().uncancel()。asyncio.timeout外同时备好except TimeoutError和except*两条路径。- 把
gather(return_exceptions=True)当技术债——它把CancelledError静默降级成数据。 - 排查用
python -m asyncio pstree PID(3.14 新增)看真机上的任务树。