Python TaskGroup实战:异常传播与取消机制解析

事故现场:一个超时引发的雪崩

某个凌晨三点,数据管道突然大面积超时报警。排查发现,其中一个上游接口偶发 5 秒延迟,导致 asyncio.gather 等待所有任务完成才抛出异常,后面的清理逻辑没来得及执行,连接池耗尽,最终整组任务全部卡死。修复方案很简单:把 gather 换成 TaskGroup,问题不再复现。但深入分析后发现,很多人对 TaskGroup 的异常传播和取消机制理解停留在"自动取消"的错觉上,生产环境里反而踩了更多坑。

这篇文章从真实事故出发,拆解 TaskGroup 的语义、异常传播路径、取消行为、超时控制与资源清理的正确写法,最后给出生产可观测性的实践建议。

1. TaskGroup 与 create_task/gather 的语义差异

1.1 核心区别:谁负责管理任务的生命周期

方案 创建方式 异常传播 取消行为 资源清理
create_task 手动 asyncio.create_task 不自动传播,需 awaitresult() 手动 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 会:

  1. 立即向组内其他所有尚未完成 的任务发送 cancel() 信号。
  2. 等待所有任务完成(包括被取消的任务完成清理)。
  3. 收集所有异常(成功任务无异常,取消任务有 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() 会被排队到同步操作结束后

生产建议 :如果必须调用同步阻塞库(如 requestspymysql),使用 asyncio.to_threadloop.run_in_executor 将其放入线程池,这样 CancelledError 可以打断 await 等待,但线程内的操作本身不会被取消(线程无法被强制终止)。更安全的做法是给同步操作设置超时。

4. 超时与资源清理:asyncio.timeoutfinally 的正确写法

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。因为 finallyCancelledError 时也会执行,但如果你在 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 的生命周期回调或装饰器,可以统计任务状态。但最轻量的方式是:

  1. 在每个任务内部用 try/except 包裹业务逻辑,统计成功/失败。
  2. 在任务开头和结尾打点(利用 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())

总结与建议

  1. 业务场景选择 :如果任务之间没有依赖关系,且希望一个失败立即停止所有,直接使用 TaskGroup;如果需要部分成功结果,考虑 gather + return_exceptions=True 手动处理。
  2. 异常处理 :必须用 except*(Python 3.11+)或 BaseExceptionGroup 捕获,不要用裸 except 吞掉异常。
  3. 取消响应 :所有协程内部避免同步阻塞,确保 CancelledError 能及时处理;资源清理用 async with 而非 finally
  4. 超时控制 :在 TaskGroup 外层套 asyncio.timeout(),注意 TimeoutError 不会被 TG 吞掉。
  5. 生产监控 :在任务内部打点统计成功/失败/取消,记录每个子异常的完整 trace,利用 contextvars 传递业务标识。

最后一句实话:TaskGroup 不是银弹。它解决了"一个失败自动取消其他"的核心痛点,但并发度控制、超时、指标统计仍需开发者手动搭建。理解它的异常传播路径和取消时机,才能在生产事故中快速定位问题,而不是被 ExceptionGroup 的嵌套搞晕。