asyncio.gather()会撑爆内存,因其将全部协程同时调度进事件循环,为每个协程分配栈帧、闭包和Future对象,导致内存线性增长;实测10k协程可占用500MB+。

为什么 asyncio.gather() 会把内存撑爆?
直接用 asyncio.gather(*[fetch(url) for url in urls]) 处理几千个 URL,本质是把全部 Task 同时调度进事件循环——即便它们还没真正发请求,Python 也会为每个协程对象分配栈帧、闭包和等待状态,加上大量 Future 实例,内存占用呈线性增长。实测 10k 个简单协程可轻易吃掉 500MB+ 内存。
这不是 event loop 的 bug,而是「全量并发」的设计使然:它默认假设你控制了上游并发数(比如已用连接池限流),但多数人只写了协程没设限。
用 asyncio.Semaphore 控制并发数最稳妥
asyncio.Semaphore 是唯一被 asyncio 官方推荐用于限制并发的原语,它不阻塞线程,也不影响事件循环调度,只在 acquire() 时让协程挂起等待空闲许可。
实操要点:
- 初始化时传入期望的并发上限,比如
sem = asyncio.Semaphore(10)表示最多 10 个任务同时执行 - 每个协程开头
async with sem:,离开作用域自动释放,不用手动release() - 务必把
sem实例作为参数传入协程或定义在外部作用域,避免每次新建一个空的Semaphore - 别用
threading.Semaphore或queue.Queue替代——它们不是 awaitable,会阻塞整个 event loop
示例片段:
import asyncio <p>sem = asyncio.Semaphore(20)</p><p>async def fetch_with_limit(url): async with sem: # 这里会等,直到有 ≤20 个任务在跑 return await aiohttp.get(url)</p><p>tasks = [fetch_with_limit(url) for url in urls] results = await asyncio.gather(*tasks) </p>
分批 await 比 Semaphore 更省内存?不一定
有人倾向用 for i in range(0, len(urls), batch_size): 切片 + await asyncio.gather(...) 分批执行,看似可控,但隐患明显:
- 每批内部仍是全量并发,比如
batch_size=100,每批仍会瞬时创建 100 个协程对象,内存峰值未必比单次gather低多少 - 批次间有空档,整体吞吐下降,尤其当部分请求极快、部分极慢时,CPU/IO 利用率波动大
- 错误处理麻烦:某一批出错,得重试整批,还是跳过?逻辑易混乱
- 如果批大小设得过大(如 500),依然可能触发内存警戒线
真正适合分批的场景是:下游服务明确要求「每分钟最多 N 次调用」,且你愿意牺牲速度换合规性;否则优先选 Semaphore。
别忽略 aiohttp.ClientSession 的连接池配置
光控协程并发还不够——aiohttp 默认的 TCPConnector 会为每个 ClientSession 创建独立连接池,若 session 复用不当,连接句柄和缓冲区会二次放大内存压力。
关键配置项:
-
limit:单个 session 最大并发连接数(默认 100),应 ≤Semaphore值,否则无意义 -
limit_per_host:对同一 host 的最大连接数(防止单站点打爆),建议设为 3–10 - 始终复用同一个
ClientSession实例,不要在每个协程里async with aiohttp.ClientSession() as session: - 若需不同 timeout / headers,用
session.request(...)参数传,而非新建 session
错误写法:async with aiohttp.ClientSession() as s: ... 出现在协程内部 → 每个任务新建 session → 连接池翻倍 → 内存暴涨。
内存是否真的下来,取决于你有没有同时管住「协程数量」和「连接数量」——Semaphore 控并发,connector 限连接,两者缺一不可。漏掉 connector 配置,就像开着消防栓浇花,光捏住水龙头没用。
Python免费学习笔记(深入):立即使用
在学习笔记中,你将探索 Python 的核心概念和高级技巧!











