事故现场:一个超时引发的雪崩
某个凌晨三点,数据管道突然大面积超时报警。排查发现,其中一个上游接口偶发 5 秒延迟,导致 asyncio.gather 等待所有任务完成才抛出异常,后面的清理逻辑没来得及执行,连接池耗尽,最终整组任务全部卡死。修复方案很简单:把 gather 换成 TaskGroup,问题不再复现。但深入分析后发现,很多人对 TaskGroup 的异常传播和取消机制理解停留在"自动取消"的错觉上,生产环境里反而踩了更多坑。
这篇文章从真实事故出发,拆解 TaskGroup 的语义、异常传播路径、取消行为、超时控制与资源清理的正确写法,最后给出生产可观测性的实践建议。
1. TaskGroup 与 create_task/gather 的语义差异
1.1 核心区别:谁负责管理任务的生命周期
| 方案 | 创建方式 | 异常传播 | 取消行为 | 资源清理 |
|---|---|---|---|---|
create_task |
手动 asyncio.create_task |
不自动传播,需 await 或 result() |
手动 task.cancel() |
手动 try/finally |
gather |
传入协程列表 | 收集所有异常,抛出第一个 | 一个失败不会取消其他 | 需额外 return_exceptions=True 加手动清理 |
TaskGroup |
上下文管理器 async with |
收集后抛出 ExceptionGroup |
一个失败自动取消组内所有未完成任务 | finally 保证清理 |
create_task 像"散养",你需要自己跟踪每只"羊"(任务)的状态。gather 像"集体放牧",但一只羊出事不会拉回其他羊。TaskGroup 是"军事化管理"------一个任务异常,立即发出撤退信号并确保所有资源回收。
1.2 代码对比:同样场景下三者的行为
python
import asyncio
async def slow_task(name: str, delay: float, fail: bool = False):
try:
await asyncio.sleep(delay)
if fail:
raise ValueError(f"{name} failed")
return f"{name} ok"
except asyncio.CancelledError:
print(f"[{name}] cancelled")
raise
finally:
print(f"[{name}] clean")
async def demo_gather():
# gather 不会取消其他未完成的任务
try:
results = await asyncio.gather(
slow_task("A", 1, fail=False),
slow_task("B", 2, fail=True),
slow_task("C", 3, fail=False),
)
except ValueError as e:
print(f"gather exception: {e}")
# 此时任务 C 仍在运行,直到 3 秒后才被 GC 回收
async def demo_taskgroup():
# TaskGroup 一个失败立即取消所有未完成
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(slow_task("A", 1, fail=False))
tg.create_task(slow_task("B", 2, fail=True))
tg.create_task(slow_task("C", 3, fail=False))
except* ValueError as e:
print(f"TaskGroup exception: {e}")
asyncio.run(demo_taskgroup())
输出差异明显(运行 demo_taskgroup 时,任务 C 会在 B 失败后立即收到 CancelledError,最终输出 [C] cancelled + [C] clean)。而 gather 版本中 C 会正常完成,但异常只在 gather 返回时被抛出,此时 C 已经执行了 3 秒,浪费了资源。
关键注意事项 :TaskGroup 的取消是 "尽力而为" 的------它会给每个未完成的任务发 cancel()信号,但任务如果不响应(例如在不可取消的同步阻塞中),仍然不会立即退出。生产环境中必须确保协程都是可取消的(避免使用同步 I/O 库如 requests)。
2. 异常传播:ExceptionGroup 如何影响上层调用
2.1 ExceptionGroup 的结构
当 TaskGroup 中的多个任务都抛出异常时,TaskGroup 会收集所有异常并打包成一个 ExceptionGroup 抛出。异常栈比较特殊:你无法用常规的 except ValueError 捕获,必须用 except* 语法(Python 3.11+)或手动拆包。
python
import asyncio
async def worker(name: str, fail_with: type[BaseException] | None):
if fail_with:
raise fail_with(f"{name} raises {fail_with.__name__}")
await asyncio.sleep(0.1)
return f"{name} done"
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(worker("A", ValueError))
tg.create_task(worker("B", TypeError))
tg.create_task(worker("C", None)) # 成功
# 这里不会执行到,因为 TG 会抛出异常
except* ValueError as eg:
print(f"Caught ValueError group: {eg.exceptions}")
except* TypeError as eg:
print(f"Caught TypeError group: {eg.exceptions}")
# 如果用了 Python 3.10 及以下,只能用 BaseExceptionGroup 捕获
# except BaseExceptionGroup as eg:
# for e in eg.exceptions:
# print(f"Caught: {e}")
asyncio.run(main())
注意:except* 是 Python 3.11 引入的语法。如果你的项目仍跑在 3.10,可以用 BaseExceptionGroup 加类型判断,但代码会变丑。建议升级到 3.11+,except* 的语义完全匹配 TaskGroup 的设计。
2.2 上层调用者的陷阱:误吞异常
很多人习惯在 TaskGroup 外面包一层 try/except Exception,结果把本该传播的异常吞掉,导致问题被隐藏。
python
# 错误写法:吞掉所有异常
async def bad_wrapper():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(worker("X", ValueError))
tg.create_task(worker("Y", RuntimeError))
except: # 这里捕获了所有异常,但不处理,静默消失
pass
正确做法:要么在 except* 中明确处理特定异常类型,要么让异常继续传播,由顶层 asyncio.run 打印日志。生产环境中应该在 except* 里做结构化日志记录,并重新抛出或转为自定义异常。
3. 取消机制:一个任务失败时其他任务如何退出
3.1 TaskGroup 内部取消流程
当 TaskGroup 中的任何一个任务抛出未被捕获的异常时,TaskGroup 会:
- 立即向组内其他所有尚未完成 的任务发送
cancel()信号。 - 等待所有任务完成(包括被取消的任务完成清理)。
- 收集所有异常(成功任务无异常,取消任务有
CancelledError,但CancelledError被自动吞掉),打包成ExceptionGroup抛出。
关键点:CancelledError 不会被放入 ExceptionGroup 。如果某个任务自己抛出了非 CancelledError 的异常,而其他任务被取消时也抛出了 CancelledError,最终只有非取消的异常被聚合。
3.2 任务如何优雅响应取消
任务内部必须对 CancelledError 做出响应,否则取消信号不会生效。最常见的错误是在协程里用 await 一个不支持取消的同步操作(例如 time.sleep 而不是 asyncio.sleep)。
python
import asyncio
import time # 错误:不要用同步 sleep
async def bad_worker():
time.sleep(5) # 同步阻塞,CancelledError 无法被中断
return "done"
async def good_worker():
await asyncio.sleep(5) # 异步 sleep 可取消
return "done"
async def demo_cancel():
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(bad_worker())
t2 = tg.create_task(good_worker())
await asyncio.sleep(0.1)
# 取消 t1 不会立即生效,因为 time.sleep 阻塞了事件循环
t1.cancel() # 这个 cancel() 会被排队到同步操作结束后
生产建议 :如果必须调用同步阻塞库(如 requests、pymysql),使用 asyncio.to_thread 或 loop.run_in_executor 将其放入线程池,这样 CancelledError 可以打断 await 等待,但线程内的操作本身不会被取消(线程无法被强制终止)。更安全的做法是给同步操作设置超时。
4. 超时与资源清理:asyncio.timeout 和 finally 的正确写法
4.1 在 TaskGroup 外层套 timeout
TaskGroup 本身不提供超时功能。你需要在外层用 asyncio.timeout() 包裹。
python
import asyncio
async def fetch_data(url: str) -> str:
# 模拟网络请求
await asyncio.sleep(1)
return f"data from {url}"
async def process_with_timeout(timeout_sec: float):
try:
async with asyncio.timeout(timeout_sec):
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_data("a.com"))
tg.create_task(fetch_data("b.com"))
except TimeoutError:
print("TaskGroup timed out, all tasks cancelled")
# 注意:timeout 导致 TaskGroup 上下文退出时,
# 内部所有任务会被自动取消,无需手动 cancel
此处有一个细节:asyncio.timeout() 会在超时后向当前协程抛出 TimeoutError,而 TaskGroup 的 __aexit__ 会捕获这个异常吗?不会------__aexit__ 只处理组内任务抛出的异常。所以超时后,TaskGroup 上下文管理器会正常退出(因为组内任务被取消了,它们抛出的 CancelledError 被 TG 内部处理掉了),然后 TimeoutError 继续向上传播。
4.2 任务内部的资源清理:finally vs async with 上下文管理器
资源清理最保险的方式是使用 async with 上下文管理器管理资源(如 aiohttp session、数据库连接),而不是依赖 try/finally。因为 finally 在 CancelledError 时也会执行,但如果你在 finally 中做了 await 操作,可能再次被取消。
python
import asyncio
class AsyncResource:
async def __aenter__(self):
print("acquire resource")
return self
async def __aexit__(self, *args):
print("release resource")
async def worker_with_resource():
async with AsyncResource() as res:
await asyncio.sleep(5) # 可能被取消
return "ok"
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(worker_with_resource())
await asyncio.sleep(1)
# 取消整个组,worker_with_resource 的 __aexit__ 会被触发
__aexit__ 在协程被取消时也会被调用(只要协程已经进入了 async with 块),这是最可靠的资源释放方式。如果用 try/finally,要注意 finally 中不要执行长时间阻塞的清理操作,否则取消信号被延迟。
5. 生产实践:限流、日志、指标与可观测性建议
5.1 限流:用信号量保护 TaskGroup
TaskGroup 本身不提供并发数限制。如果需要限制同时运行的任务数(比如 API 并发限制 10),可以结合 asyncio.Semaphore。
python
import asyncio
async def rate_limited_task(sem: asyncio.Semaphore, task_id: int):
async with sem:
await asyncio.sleep(1)
return task_id
async def main():
sem = asyncio.Semaphore(10) # 最多 10 并发
async with asyncio.TaskGroup() as tg:
for i in range(100):
tg.create_task(rate_limited_task(sem, i))
注意:信号量的 async with 必须在任务内部,而不是在创建任务时。否则信号量会在创建时被占用,而不是在真正执行时才占用。
5.2 日志:记录每个任务的异常
TaskGroup 抛出的是聚合异常,你需要在 except* 里遍历并记录每个异常的子异常。
python
import logging
logger = logging.getLogger(__name__)
async def safe_worker(name: str):
# 业务逻辑,可能抛出各种异常
raise ValueError(f"error in {name}")
async def main_with_logging():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(safe_worker("task1"))
tg.create_task(safe_worker("task2"))
except* ValueError as eg:
for exc in eg.exceptions:
logger.error("Task failed: %s", exc, exc_info=True)
# 重新抛出,让顶层处理
raise
5.3 指标:统计任务成功/失败/取消
利用 TaskGroup 的生命周期回调或装饰器,可以统计任务状态。但最轻量的方式是:
- 在每个任务内部用 try/except 包裹业务逻辑,统计成功/失败。
- 在任务开头和结尾打点(利用
asyncio.current_task().get_name()作为标签)。
python
import asyncio
import time
metrics = {"success": 0, "fail": 0, "cancel": 0}
async def monitored_worker(name: str):
start = time.monotonic()
try:
# 业务逻辑
await asyncio.sleep(0.5)
metrics["success"] += 1
return name
except asyncio.CancelledError:
metrics["cancel"] += 1
raise
except:
metrics["fail"] += 1
raise
finally:
elapsed = time.monotonic() - start
# 输出延迟指标,可用 statsd/prometheus 等
print(f"{name} took {elapsed:.3f}s")
5.4 可观测性:关联 Trace ID
一个常见的生产问题是:当 TaskGroup 抛出 ExceptionGroup 时,如何将每个子异常和对应的 trace id 关联?解决方法:在创建任务时,将上下文(如 request_id)通过 contextvars 传递,或者使用装饰器打印。
python
import asyncio
import contextvars
request_id_var = contextvars.ContextVar("request_id")
async def worker():
rid = request_id_var.get()
try:
raise ValueError(f"error in {rid}")
except:
logger.error("Failure for request %s", rid)
raise
async def main():
request_id_var.set("req-123")
async with asyncio.TaskGroup() as tg:
tg.create_task(worker())
总结与建议
- 业务场景选择 :如果任务之间没有依赖关系,且希望一个失败立即停止所有,直接使用
TaskGroup;如果需要部分成功结果,考虑gather + return_exceptions=True手动处理。 - 异常处理 :必须用
except*(Python 3.11+)或BaseExceptionGroup捕获,不要用裸except吞掉异常。 - 取消响应 :所有协程内部避免同步阻塞,确保
CancelledError能及时处理;资源清理用async with而非finally。 - 超时控制 :在
TaskGroup外层套asyncio.timeout(),注意TimeoutError不会被 TG 吞掉。 - 生产监控 :在任务内部打点统计成功/失败/取消,记录每个子异常的完整 trace,利用
contextvars传递业务标识。
最后一句实话:TaskGroup 不是银弹。它解决了"一个失败自动取消其他"的核心痛点,但并发度控制、超时、指标统计仍需开发者手动搭建。理解它的异常传播路径和取消时机,才能在生产事故中快速定位问题,而不是被 ExceptionGroup 的嵌套搞晕。