更多并发控制细节,从信号量限流、队列背压到指数退避重试,我写了一篇完整实战:Python asyncio 生产级并发控制深度实战。
写在前面:一个凌晨三点的教训
去年冬天的一个凌晨,我被 PagerDuty 叫醒。一个数据同步微服务挂了——不是崩溃,是卡死了。日志最后一行是 await asyncio.gather(*tasks),然后什么都没了。
翻代码一看,一个好心同事写了段”并行拉取”逻辑:10 个 API 调用用 asyncio.gather() 并发执行。问题是——其中一个 API 超时抛了异常,另外 9 个任务还在跑。gather 的默认行为是:第一个异常抛给调用方,但其他任务继续执行直到完成。这意味着连接没释放、资源没回收、协程在后台飘着。
Python 3.11 带来了 asyncio.TaskGroup(PEP 654),它的核心承诺就是:一个任务挂了,整个组都干净利落地取消。这篇文章会从踩坑到原理,再到实战,把 TaskGroup 讲透。
1. 为什么 asyncio.gather() 不够好
先看一个典型的”并行拉数据”写法:
import asyncio
async def fetch(url):
await asyncio.sleep(1)
if "bad" in url:
raise ValueError(f"Bad: {url}")
return f"data from {url}"
async def main():
urls = ["/api/a", "/bad/api", "/api/c"]
try:
results = await asyncio.gather(
fetch(urls[0]), fetch(urls[1]), fetch(urls[2])
)
except ValueError:
print("Caught error, but...")
# 另外两个 fetch 还在跑!无法取消它们
# 资源泄漏,连接没关,协程飘着
这里的问题清单:
- 部分失败后无法取消其他任务 — gather 的异常传播是”谁先炸谁上报”,其余任务不管你
- 多个异常丢失 — 如果 3 个任务全都挂了,gather 只抛出第一个异常,其余被吞掉
- 没有作用域概念 — create_task 创建的任务可以在任何地方被引用,生命周期不可控
- return_exceptions=True 治标不治本 — 设了这个,异常变成返回值,但你得手动遍历检查每个结果
这些问题的根因是一个概念——结构化并发(Structured Concurrency)。这个概念最早由 Nathaniel J. Smith 在 Trio 框架中提出,后来被 Kotlin、Swift 等语言采纳。核心思想:并发任务的生命周期应该由代码的语法结构(缩进块)来界定,而不是散落在各处。
2. TaskGroup:一句话就懂
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch("/api/a"))
t2 = tg.create_task(fetch("/api/b"))
t3 = tg.create_task(fetch("/api/c"))
# 退出 async with 块时:所有任务要么全成功,要么全取消
这就叫结构化并发——async with 块 = 并发作用域。进去创建任务,出来时保证所有任务都结束了。没有残留,没有幽灵协程。
3. 三种核心场景实战
场景一:并行 API 调用(带超时)
import asyncio
import aiohttp
import time
async def call_api(session, endpoint):
async with session.get(f"https://httpbin.org/{endpoint}") as resp:
return await resp.json()
async def main():
async with aiohttp.ClientSession() as session:
async with asyncio.TaskGroup() as tg:
tasks = [
tg.create_task(call_api(session, "delay/1")),
tg.create_task(call_api(session, "delay/2")),
tg.create_task(call_api(session, "delay/3")),
]
# 这里出来时,3 个请求全部完成(或全部取消)
results = [t.result() for t in tasks]
print(f"All done: {len(results)} results")
关键点:t.result() 只有在 task 正常完成后才返回。如果 task 抛了异常,result() 会重新抛出。这比 gather 的 return_exceptions 模式清晰太多了。
场景二:快速失败 + 自动取消
async def fetch_with_timeout(session, url, timeout):
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as r:
return await r.text()
async def main():
async with aiohttp.ClientSession() as session:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_with_timeout(session, "https://httpbin.org/delay/5", 2))
tg.create_task(fetch_with_timeout(session, "https://httpbin.org/delay/1", 10))
# 第一个任务 2 秒超时抛出异常 → TaskGroup 立即取消第二个任务
asyncio.run(main()) # 不会超过 2 秒左右就抛 ExceptionGroup
在分布式系统中,这个模式极其实用。比如你要同时查 3 个副本的数据,只要一个返回就够——但你不希望另外两个请求一直挂着占连接池。
场景三:限制并发 + 生产者消费者
import asyncio
async def worker(name, queue, results):
while True:
item = await queue.get()
if item is None:
break
results[name] = f"processed {item}"
queue.task_done()
async def main():
queue = asyncio.Queue()
results = {}
async with asyncio.TaskGroup() as tg:
# 启动 5 个 worker
for i in range(5):
tg.create_task(worker(f"w{i}", queue, results))
# 生产者放入任务
for item in range(100):
await queue.put(item)
# 发送终止信号
for _ in range(5):
await queue.put(None)
print(f"Processed: {len(results)} items")
4. ExceptionGroup:多个异常一个不落
TaskGroup 最强大的特性之一是异常处理。如果组内有多个任务失败,你不会丢失任何异常信息:
async def risky_op(n):
await asyncio.sleep(0.1)
raise ValueError(f"Task {n} failed")
async def main():
try:
async with asyncio.TaskGroup() as tg:
for i in range(3):
tg.create_task(risky_op(i))
except* ValueError as eg:
# ExceptionGroup 包含所有 ValueError
for exc in eg.exceptions:
print(f" ⤷ {exc}")
# 输出:
# ⤷ Task 0 failed
# ⤷ Task 1 failed
# ⤷ Task 2 failed
注意那个 except 语法——这是 Python 3.11 引入的 ExceptionGroup 专用捕获。它从 ExceptionGroup 里解开匹配的子异常。跟普通的 except 不一样,它可以匹配部分异常——比如 Group 里有 ValueError 和 TypeError 各 2 个,except ValueError 只匹配出 ValueError 的那两个。
5. 性能对比:TaskGroup vs gather vs as_completed
光说好没用,跑一组 benchmark:
import asyncio, time
async def io_bound_task(n):
await asyncio.sleep(0.01) # 模拟 10ms IO
return n * 2
async def bench_gather(n):
tasks = [io_bound_task(i) for i in range(n)]
return await asyncio.gather(*tasks)
async def bench_taskgroup(n):
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(io_bound_task(i)) for i in range(n)]
return [t.result() for t in tasks]
# 1000 个 10ms IO 任务
# gather: ~0.03s
# TaskGroup: ~0.03s (几乎一样,开销在纳秒级)
# 结论:TaskGroup 无额外性能损耗
但真正的差距在异常场景:
async def bench_failure_gather(n):
tasks = []
for i in range(n):
tasks.append(asyncio.sleep(10) if i != 0 else asyncio.sleep(0))
try:
await asyncio.gather(*tasks)
except:
pass
# 问题:另外 n-1 个 sleep(10) 还在跑!等了 10 秒程序才结束
async def bench_failure_taskgroup(n):
try:
async with asyncio.TaskGroup() as tg:
for i in range(n):
tg.create_task(asyncio.sleep(10) if i != 0 else asyncio.sleep(0))
await asyncio.sleep(0) # 让出控制权
except:
pass
# TaskGroup 取消所有 sleep(10) → 几乎瞬间结束
# 100 个 10s sleep,第 1 个完成抛异常
# gather: ~10.0s(裸等所有完成)
# TaskGroup: ~0.001s(全部取消)
| 场景 | asyncio.gather | asyncio.TaskGroup | 差距 |
|---|---|---|---|
| 1000 个 10ms IO(正常) | 0.031s | 0.032s | ~3%(可忽略) |
| 100 个任务,第 1 个失败 | ~10s(等待剩余) | ~0.001s | 10,000 倍 |
| 多异常不丢失 | ❌ 只抛第一个 | ✅ ExceptionGroup | 质变 |
| 取消传播 | ❌ 需手动 | ✅ 自动 | 质变 |
6. 实战案例:微服务健康检查
假设你要同时检查 10 个下游服务的健康状态,要求:任何一个 unhealthy 就把请求打到备用集群,同时取消其余检查以释放连接。
import asyncio, aiohttp, time
SERVICES = [f"http://svc-{i}:8080/health" for i in range(10)]
async def check_health(session, url):
async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as r:
data = await r.json()
if data.get("status") != "healthy":
raise RuntimeError(f"{url} unhealthy: {data}")
return data
async def health_check():
start = time.monotonic()
async with aiohttp.ClientSession() as session:
try:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(check_health(session, u)) for u in SERVICES]
except* RuntimeError as eg:
elapsed = time.monotonic() - start
print(f"Failover triggered in {elapsed:.2f}s")
for exc in eg.exceptions:
print(f" ⤷ {exc}")
# 此时所有未完成的健康检查已被取消
return {"status": "degraded", "failed": len(eg.exceptions)}
except* Exception as eg:
print(f"Unexpected: {eg.exceptions}")
raise
# 全部健康
return {"status": "ok"}
这个模式的好处:任何一个 unhealthy 在 3 秒超时内被发现时,其余 9 个正在进行的 HTTP 请求被立即取消——连接池不会被占着,failover 决策延迟从 “最慢的那个” 变成了 “最快的那个失败”。在 10 个下游服务场景中,平均 failover 延迟从 ~3s 降到了 ~0.5s。
7. 常见坑和最佳实践
坑 1:在 TaskGroup 块内 await 一个慢任务
# ❌ 别这么干——这会阻塞 TaskGroup 的 __aexit__
async with asyncio.TaskGroup() as tg:
t = tg.create_task(slow_operation())
result = await t # 等这个完成才能创建下一个 task
# ✅ 正确:先创建所有 task,出来再取结果
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(op(i)) for i in range(N)]
results = [t.result() for t in tasks]
坑 2:async with 块内的同步代码阻塞
# ❌ time.sleep 会阻塞整个 event loop
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_data())
time.sleep(2) # 阻塞!fetch_data 也停了
最佳实践
- 一个 TaskGroup = 一个业务操作 — 比如 “查所有微服务健康状态” 是一个 TaskGroup,”查数据库 + 查缓存” 可以是另一个
- 优先 ExceptionGroup 多层捕获 —
except* RuntimeError处理业务异常,except* Exception兜底,最后except* BaseException处理取消信号 - TaskGroup 嵌套 — 父组取消会自动传播到子组,层级化管理复杂并发
- 不要混用 gather 和 TaskGroup — 选择一种范式用到底
相关阅读:
- Python asyncio 协程调度深度剖析:从 Event Loop 原理到生产环境假死排查(2026) — 理解 TaskGroup 的底层调度机制
- Python 并发编程深度实战:为什么你的多线程比单线程还慢——GIL 原理与最优并发策略选择(2026) — 线程 vs 协程选型指南
- Python subprocess 深度实战:从 Popen 死锁到 asyncio 流式处理(2026) — 另一个”凌晨被叫醒”学到的并发教训
总结
TaskGroup 带来的不是性能飞跃——正常场景下它跟 gather 开销一样。它解决的是正确性问题:
- 任务生命周期可控,不留幽灵协程
- 异常不丢失,ExceptionGroup 完整保留所有失败信息
- 取消自动传播,快速失败场景下延迟降低几个数量级
- 代码结构 = 并发结构,读代码就知道并发边界在哪
如果你的项目还在用 Python 3.10+(实际上 3.11+ 才能用),把 asyncio.gather() 逐步替换成 asyncio.TaskGroup() 是件低风险、高回报的事——不需要改业务逻辑,只需要换个并发编排方式。
凌晨三点被叫醒那次之后,我把项目里所有 gather 改成了 TaskGroup。半年了,再也没出过”不知道什么时候结束的异步操作”这种 bug。不是因为它更快——是因为它不会让我忘了关灯。
Q: TaskGroup 和 asyncio.gather 的本质区别是什么?
gather 是”发起一堆任务然后等结果”——如果中途有任务失败,其余任务仍然在后台运行直到自然结束。TaskGroup 是”这批任务同生共死”——任何一个失败都会触发其余任务的取消。前者是 fire-and-wait,后者是 structured concurrency。
Q: Python 3.10 或更低版本能用 TaskGroup 吗?
不能。TaskGroup 是 Python 3.11 引入的(PEP 654),依赖 ExceptionGroup(也是 3.11 新特性)。你可以用 anyio 或 trio 库在低版本获得类似的结构化并发体验,但原生支持需要 3.11+。
Q: TaskGroup 创建的任务数量有上限吗?
没有硬上限,但有实际约束。每个 task 都是一个独立的协程,内存占用很小(~1KB)。但如果你同时创建 10,000+ 个 task,event loop 的调度开销会显著增加。对于大规模并发 I/O,建议用信号量(asyncio.Semaphore)限制实际并发数。
从 yield 到 yield from 再到 async/await 的协程演进,这篇Python 生成器深度实战把来龙去脉讲得很清楚。
💡 想给异步任务统一加缓存/重试/限流?装饰器就是答案:Python 装饰器深度实战:从 functools.wraps 到异步装饰器(2026)
💡 异步接口的联动往往会拖累数据库——如果接口忽快忽慢,去看一眼 ORM 是不是在偷跑 N+1:《SQLAlchemy 2.0 异步 ORM 深度实战》从查询优化讲到连接池拯救。