Python asyncio 并发控制实战:信号量、连接池与超时治理
一、并发数不是越大越好
把上万个 URL 丢进 asyncio.gather,最直观的写法是给每个 URL 建一个 Task。但这样做会同时打开成千上万个连接:文件描述符耗尽、对端限流、GC 压力上升,最终表现为大量超时与重试风暴。
真正可控的做法是把「并发」拆成三层:
- 任务层:
asyncio.Semaphore限制同时进入临界区的协程数 - 连接层:
TCPConnector.limit限制同时存在的 TCP 连接数 - 协议层:超时、重试与退避策略
三者不匹配,就会出现「信号量是 50、连接池是 100」这种自相矛盾的配置。
二、三个高频坑
1. 信号量与连接池大小不匹配
Semaphore 控制的是「同时进入临界区的协程数」,TCPConnector.limit 控制的是「同时存在的 TCP 连接数」。如果 limit 小于信号量,多出来的协程会卡在获取连接上,表现为「看着并发 100,实际吞吐只有 20」。
2. 只设 total 超时
ClientTimeout(total=10) 覆盖整个请求生命周期,包含排队等待连接的时间。拥塞时协程可能还没发出请求就被判定超时,重试又叠加新的排队,形成雪崩。生产上更稳的做法是分别声明 connect、sock_read 与 total。
3. gather 直接吞异常
asyncio.gather 在默认模式下,任一子任务抛异常会立即向上传播,但其余任务仍在后台运行,容易造成「上层已经失败,后台还在打请求」。要么显式 return_exceptions=True 收口,要么使用 3.11 起的 TaskGroup 做结构化并发。
三、可复用的骨架代码
import asyncio
import random
import aiohttp
async def fetch(session, url, sem, retries=3):
async with sem:
for attempt in range(retries):
try:
timeout = aiohttp.ClientTimeout(total=10, connect=3, sock_read=6)
async with session.get(url, timeout=timeout) as resp:
resp.raise_for_status()
return await resp.text()
except (aiohttp.ClientError, asyncio.TimeoutError):
if attempt == retries - 1:
raise
# 指数退避 + 抖动,避免重试同步化
await asyncio.sleep(0.2 * (2 ** attempt) + random.random() * 0.1)
async def run(urls, concurrency=50):
sem = asyncio.Semaphore(concurrency)
connector = aiohttp.TCPConnector(
limit=concurrency, # 与信号量对齐
limit_per_host=10, # 单域名保护
ttl_dns_cache=300,
)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [asyncio.create_task(fetch(session, u, sem)) for u in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
四、上线前要看的三个指标
- 在途请求数(in-flight):应稳定在信号量附近,持续打满说明后端已是瓶颈
- 连接复用率:
limit_per_host过小会频繁建连,TLS 握手开销会吃掉并发收益 - 超时分布:区分 connect 超时与 read 超时,前者多为网络或限流,后者多为后端慢查询
五、小结
asyncio 的并发能力来自事件循环,但「能并发」不等于「该并发」。把信号量、连接池、超时三者对齐,再配合带抖动的重试与结构化并发,才能在流量突增时保持可控。压测时建议从并发 10 开始逐步加档,观察在途请求数与 P99 延迟的拐点,而不是直接冲到一个看起来很爽的数字。
- 点赞
- 收藏
- 关注作者
评论(0)