异步协程给我们一种错觉:只要把请求丢进事件循环,吞吐量就会自动拉满。但在 aiohttp 的实际压测中,很多程序 CPU 占用不到 30%,并发任务数也足够多,单机 QPS 却卡在一个不高不低的位置,随后开始出现 Connection reset、读取超时、甚至连接池耗尽。问题通常不在业务代码本身,而在 aiohttp 的连接管理、并发控制、超时设置和响应释放机制。理解这些底层细节,才能让异步请求真正发挥出应有的并发能力。

一、TCPConnector 的连接池限制与连接复用
aiohttp 的每一次请求都依赖底层的 TCP 连接。默认情况下,TCPConnector 维护的连接池总大小只有 100 条,单个域名对应的连接上限则更低。这个数字对于普通脚本完全够用,但在大规模并发场景下,一旦同时发起的请求超过这个阈值,多余的请求不会立刻创建新连接,而是排队等待前面的连接释放。结果就是任务虽然已经创建,实际却在连接等待阶段消耗了大量时间,对外表现为延迟升高、吞吐下降。
优化连接池的第一步就是根据目标服务器的承受能力和本机资源调整 limit 与 limit_per_host。limit 控制整个连接池的最大连接数,limit_per_host 控制对单个域名的最大连接数。如果请求集中在少数几个域名,可以适当增大 limit_per_host;如果目标站点分布广泛,则需要提升总连接池容量。除此之外,DNS 解析缓存也很重要。默认情况下 DNS 解析可能频繁发生,给高并发请求增加额外延迟,配置 ttl_dns_cache 可以让解析结果缓存一段时间,减少重复解析带来的开销。
import aiohttp
connector = aiohttp.TCPConnector(
limit=200, # 连接池总大小
limit_per_host=50, # 单个域名最大连接数
ttl_dns_cache=300, # DNS缓存时长,单位为秒
use_dns_cache=True,
keepalive_timeout=60, # 空闲连接保持时间
)
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get("https://ipipp.com") as resp:
print(resp.status)
连接复用同样需要仔细设置。HTTP 的 keep-alive 机制允许同一个 TCP 连接承载多次请求,aiohttp 默认开启这一能力,但空闲连接如果长时间不活动,会被服务端或中间设备关闭,客户端再次复用时就会收到 Broken pipe。通过 keepalive_timeout 控制空闲连接的存活时间,可以让连接池中的连接保持在一个相对健康的生命周期内,减少无效连接带来的重试成本。
二、异步信号量与超时策略配合使用
调大连接池之后,并不意味着可以无限制地同时发起请求。即使 limit 设置成 500,事件循环中仍然可能堆积成千上万个等待获取连接的协程。每多一个等待任务,调度器就要多处理一次上下文切换,同时目标服务器也可能因为瞬时流量过大而触发限流或拒绝服务。更合理的做法是在业务层面引入 asyncio.Semaphore,把真正进入请求流程的并发数控制在一个稳定区间。
信号量的作用类似于令牌桶:每次发起请求前先获取一个令牌,请求结束后归还。与连接池的硬限制不同,信号量是协作式的,可以跨多个 ClientSession 共享,也可以针对不同接口设置不同阈值。当一个批次的任务数量远大于信号量容量时,超出的任务会在 async with semaphore 处等待,而不是继续创建请求对象,从而避免大量无意义的协程堆积。
import asyncio
import aiohttp
semaphore = asyncio.Semaphore(100)
async def fetch(session, url):
async with semaphore:
try:
async with session.get(url) as resp:
return await resp.text()
except asyncio.TimeoutError:
return None
async def main():
urls = [f"https://ipipp.com/api/{i}" for i in range(500)]
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks)
超时设置是另一个必须主动控制的点。aiohttp 默认的总超时时间较长,如果在高并发下某个请求卡住,它会长时间占用连接和协程资源,拖慢整个批次。建议显式配置 ClientTimeout,把连接建立、套接字读写和总耗时分开设置。这样既能保证正常请求有足够时间完成,又能让异常请求快速退出并释放资源。
timeout = aiohttp.ClientTimeout(
total=30, # 整个请求周期总超时
connect=5, # 建立TCP连接超时
sock_connect=5, # 套接字连接超时
sock_read=10, # 等待响应数据超时
)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get("https://ipipp.com/data") as resp:
data = await resp.read()
total 负责兜底,connect 和 sock_connect 控制建连阶段,sock_read 则关注服务端响应过程中的停顿。很多请求超时并不是建立连接失败,而是服务端接受连接后长时间不返回数据,此时 sock_read 能比 total 更早发现问题。超时异常捕获后,最好能配合重试机制,但要给重试设置上限,防止雪崩效应。
三、事件循环优化与阻塞调用规避
aiohttp 跑在 asyncio 事件循环之上,循环本身的调度效率会直接影响并发吞吐。标准库中的事件循环实现偏保守,应对大量小任务时,切换开销会逐渐显现。安装 uvloop 之后,在创建事件循环前调用 uvloop.install(),可以让 asyncio 使用 libuv 作为底层实现,通常能带来 20% 到 50% 的性能提升,尤其适合短连接、高并发、I/O 密集的请求场景。
import asyncio
import uvloop
async def main():
# 请求逻辑
pass
if __name__ == "__main__":
uvloop.install()
asyncio.run(main())
真正让事件循环卡死的,往往不是外部 I/O,而是代码中混入了同步阻塞调用。比如在协程里直接调用 requests.get、执行同步数据库查询、读取大文件,或者在循环里调用 time.sleep。这些操作会占据当前线程,事件循环无法切换到其他协程,整个进程的并发能力瞬间归零。必须把这些阻塞操作转移到线程池或进程池中执行。
import asyncio
import time
def blocking_parse(data):
time.sleep(0.1) # 模拟CPU密集操作
return len(data)
async def handle(session, url):
async with session.get(url) as resp:
data = await resp.read()
return await asyncio.to_thread(blocking_parse, data)
asyncio.to_thread 是 Python 3.9 之后提供的便捷入口,内部使用线程池执行同步函数,不会阻塞事件循环。如果是 CPU 密集且耗时较长的操作,线程池可能受 GIL 影响,此时可以考虑 loop.run_in_executor 配合进程池。但进程池会带来额外的序列化和进程创建开销,需要根据实际负载测试决定。
四、响应体释放、连接泄漏与背压设计
aiohttp 的响应对象在使用后必须释放,否则底层连接不会回到连接池。很多性能问题就是由于忘记调用 resp.release() 或者没有使用 async with,导致连接池中的可用连接越来越少,最后所有请求都在等待连接,程序表现为假死或大量超时。推荐的写法是始终用 async with session.get(url) as resp 包裹请求,让上下文管理器自动处理释放流程。
# 推荐:async with 自动释放响应
async with session.get("https://ipipp.com/page") as resp:
text = await resp.text()
# 错误:未读取或释放,连接无法复用
resp = await session.get("https://ipipp.com/page")
# 必须手动调用 resp.release() 或完整读取响应体
连接泄漏还有一个隐蔽来源:响应体没有完全读取。即使用 async with 拿到了响应对象,如果只读取了前几行就返回,剩余数据仍然残留在连接上,连接也无法安全复用。可以通过 await resp.read() 将整个响应体读取完毕,或者在不需要内容时调用 await resp.release() 显式丢弃剩余数据。
当任务规模达到数万甚至数十万时,一次性创建所有协程任务本身就会占用大量内存。更适合的方案是使用 asyncio.Queue 构建生产者-消费者模型,用固定数量的 worker 从队列中取 URL 并发请求。队列设置 maxsize 后,生产者放入数据的速度会被自动限制,从而形成背压,避免内存无限膨胀。
import asyncio
import aiohttp
async def producer(queue, urls):
for url in urls:
await queue.put(url) # 队列满时自动等待
async def worker(queue, session, results):
while True:
url = await queue.get()
try:
async with session.get(url) as resp:
results.append(await resp.text())
finally:
queue.task_done()
async def main():
queue = asyncio.Queue(maxsize=500)
urls = [f"https://ipipp.com/item/{i}" for i in range(10000)]
results = []
async with aiohttp.ClientSession() as session:
workers = [asyncio.create_task(worker(queue, session, results)) for _ in range(50)]
await producer(queue, urls)
await queue.join()
for w in workers:
w.cancel()
这种结构把并发请求数量、队列长度和 worker 数量解耦,方便单独调整。worker 数量对应实际同时处理的请求数,队列长度决定能容纳多少待处理 URL。当目标服务响应变慢时,生产者会因为队列满而自然降速,不会继续向系统塞入更多任务。配合前面提到的信号量、超时和连接池配置,aiohttp 在大规模并发请求下可以保持稳定且可控的吞吐表现。