aiohttp 连接池:那个 limit 参数到底在限什么
aiohttp 文档里有一行参数:TCPConnector(limit=100)。很多人把它当成"并发上限"用,觉得设了 100 就是"最多 100 个请求并发"。结果跑起来发现,要么被对面服务拉黑,要么 CPU 暴涨,要么直接 OOM。
问题在于,limit 限的是连接数,不是协程数。这两者不是一回事。
连接池到底是什么
先说连接池是什么。HTTP 请求底层是 TCP 连接。每次新建连接都要走三次握手,慢启动还会吃一段性能。如果业务上要发很多请求,连接池就出现了------它把建好的连接"留着",下次用的时候直接拿,省掉握手和慢启动的开销。
aiohttp 的 TCPConnector 就是这个东西的体现。limit=100 意思是这个连接池最多持有 100 个连接。当某个协程发请求时,aiohttp 会从池里拿一个空闲连接给它用;请求结束后,连接不会被销毁,而是放回池里,等下一个协程用。
听起来很完美,对吧。但这里有个隐含前提------"池里有空闲连接可用"。
当协程数超过连接数会怎样
假设你起了 500 个协程,但连接池上限是 100,会发生什么?
前 100 个协程拿到连接,开始跑。后 400 个协程呢?它们不会消失,而是去等待一个叫 acquire 的 future,等池里有连接释放出来自己就能拿。
听起来也合理。但实际上,这 400 个协程并不是"在 IO 上阻塞"------它们仍然在事件循环里挂着,每个都是一个 task 对象,持有自己的状态机和 future。事件循环在每个 tick 都要去检查这 400 个 future 的状态,这本身就是开销。
更糟的是这 400 个协程都是"瞬时塞进去的"。比如你写 asyncio.gather(*[fetch(url) for url in urls]),500 个 task 瞬间被 create,然后一起跑向连接池。结果是:
- 100 个拿到了连接
- 400 个在 acquire 上挂着
- 事件循环调度成本瞬间爆表
- 100 个跑完后释放连接,400 个一起涌上来,瞬时并发 400
这个"瞬时并发 400"才是真正打爆对面服务的原因。
limit 和 limit_per_host 的区别
aiohttp 的 TCPConnector 有两个看起来很像的参数:
limit:总连接数上限,跨所有 hostlimit_per_host:每个 host 的连接数上限
默认 limit=100,limit_per_host=0(也就是不限制 per host)。
这两个参数什么时候会不一样?比如你的业务里有 5 个不同的 API 服务,你调 a.com 调得最多。如果你设 limit=100, limit_per_host=20,那 a.com 最多占 20 个连接,剩下 80 个连接给其他 host 用。这样可以防止某一个 host 吃光所有连接。
我之前做的一个项目就是这个问题------我只调一个服务,limit=100 完全够用。后来业务扩展到 3 个服务,没注意 limit_per_host,结果 a 服务把 100 个连接吃光了,b 和 c 拿不到连接直接超时。
合理做法:
- 单 host 业务:
limit设成你需要的并发数 - 多 host 业务:设
limit_per_host防止某个 host 吃满
连接池满的时候,到底发生了什么
很多教程讲到这块都是一笔带过:"连接池满了会等"。但实际表现是什么样的?
当池里 100 个连接都被占用,新来的协程调 session.get(url),aiohttp 会在 acquire 这个 future 上 await。这个 await 是非阻塞的------它不会卡住事件循环,只是把这个协程挂起,等连接释放时被唤醒。
问题在于,事件循环里"被挂起的协程"也是有成本的。每个协程都是一个 task 对象,Python 用生成器实现 task,每个 task 持有栈帧、局部变量、future 链。这些全加起来,5000 个挂着的协程轻松吃掉 1-2 GB 内存。
我之前那次翻车就是这种情况:起了 5000 个协程去抢 100 个连接,结果 4900 个挂在 acquire 上,内存直接涨了 800 MB。
正确的限流方式
如果你要保证"最多 100 个请求并发",单纯设 limit=100 是不够的------它只能保证"最多 100 个连接被同时使用",但不能保证"最多 100 个协程在活跃"。
正确的做法是双层限流:
python
connector = aiohttp.TCPConnector(limit=100) # 连接池上限
sem = asyncio.Semaphore(100) # 协程并发上限
async def fetch(url, session):
async with sem: # 协程先过 sem
async with session.get(url) as resp: # 再从连接池拿连接
return await resp.text()
这样写:
- sem 保证活跃协程数不超过 100
- 连接池保证实际建立的连接数不超过 100
- 超出 sem 的协程在 sem 上挂着,不进事件循环抢调度
但即便这样也有问题------gather 一瞬间把 5000 个 task 全 create,5000 个协程还是在内存里挂着。
更稳的做法是用 Queue 配合固定 worker:
python
queue = asyncio.Queue(maxsize=200)
async def worker(session):
while True:
url = await queue.get()
if url is None:
queue.task_done()
return
try:
async with session.get(url) as resp:
await resp.text()
finally:
queue.task_done()
async def main(urls):
async with aiohttp.ClientSession(connector=connector) as session:
workers = [asyncio.create_task(worker(session)) for _ in range(100)]
for url in urls:
await queue.put(url)
for _ in range(100):
await queue.put(None)
await asyncio.gather(*workers)
这套写法里,内存里只有 100 个 worker 协程 + Queue 里的待处理任务。Queue 有 maxsize,生产者满了会 await,内存不会爆。
几个我常用的排查命令
最后说几个排查连接池问题常用的命令:
python
# 看当前活跃连接数
connector._acquired_per_host
# 看到底有多少协程挂着
len(asyncio.all_tasks())
# 看连接池具体状态
connector._acquired_per_host
第一个是 aiohttp 内部属性,第二个是 Python 3.8+ 的标准 API。出事的时候先看这两个,能快速判断是"连接不够"还是"协程太多"。
小结
把今天说的串一下:
- aiohttp 的
limit限的是连接数,不是协程数 - 协程数 > 连接数时,超出部分会挂在 acquire 上,不会自动退出事件循环
- 瞬时并发打爆对面服务是常见问题,gauge 数量不代表实际并发
- 多 host 业务要设
limit_per_host - 想真正限流,配合 Semaphore + Queue 比单纯设 limit 更稳
搞清楚这些,aiohttp 才用得明白。不然就只是"看着像在用异步",实际上踩了一堆看不见的坑。