asyncio.gather() 一次性处理上万个任务会爆内存,需用aiohttp.ClientSession配合信号量限流、连接复用和DNS缓存控制,并分层设置超时与重试,结果收集推荐预分配数组或asyncio.Queue。

asyncio.gather() 会爆内存,别直接用
一次性把上万个 asyncio.create_task() 塞进 asyncio.gather(),大概率触发 MemoryError 或让事件循环卡死。不是因为协程本身重,而是默认 TCP 连接池没节制、DNS 查询堆积、SSL 握手并发过高导致系统资源耗尽。
真正能跑通的方案必须做三件事:限流、复用连接、控制 DNS 缓存。推荐用 aiohttp.ClientSession 配合 semaphore 控制并发数,而不是裸写 asyncio.sleep() 或靠 gather 硬扛。
- 并发数建议设为 50–200(取决于目标服务器抗压能力和本机 ulimit)
- 一定要传
connector=aiohttp.TCPConnector(limit_per_host=10, ttl_dns_cache=300) - 禁用重定向(
allow_redirects=False)能显著减少无效请求和超时等待
aiohttp.ClientSession 必须复用,不能每次 new
每个 aiohttp.ClientSession() 实例内部维护连接池和 DNS 缓存。如果在循环里反复 ClientSession(),不仅开销大,还会绕过连接复用,触发大量 TIME_WAIT 和端口耗尽(OSError: [Errno 24] Too many open files)。
正确做法是整个检测任务只创建一个 session,传给所有协程共用:
立即学习“Python免费学习笔记(深入)”;
async def check_url(session, url):
try:
async with session.get(url, timeout=5) as resp:
return url, resp.status
except Exception as e:
return url, str(e)
<p>async def main(urls):
connector = aiohttp.TCPConnector(limit_per_host=10, ttl_dns_cache=300)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [check_url(session, u) for u in urls[:10000]]
results = await asyncio.gather(*tasks, return_exceptions=True)
超时和重试要分层设置,不能全靠 try/except
单纯用 try/except asyncio.TimeoutError 捕获不保险——有些请求卡在 DNS 解析、TCP 握手或 TLS 协商阶段,根本不会进入 session.get() 的 timeout 范围。这时候需要更细粒度的控制。
- 用
aiohttp.ClientTimeout(total=5, connect=3, sock_read=2)分开约束各阶段 - 对 5xx 错误可做最多 1 次重试(
retry库或手动 retry loop),但不要重试连接类错误(如ClientConnectorError) - 避免全局
asyncio.wait_for(..., timeout=5)包裹整个session.get(),它无法中断底层 socket 操作,只是扔异常
结果收集别用 list.append(),改用 asyncio.Queue 或预分配数组
上万级并发下,多个协程同时往同一个 list 里 append(),虽然 CPython GIL 保证原子性,但高竞争仍会导致明显性能下降(实测比预分配慢 20%+)。更糟的是,如果某个协程失败后你还要记录错误堆栈,list 会变成瓶颈。
两种轻量方案:
- 用
asyncio.Queue(maxsize=0)接收结果,另起一个消费者协程写入文件或数据库 - 提前初始化
results = [None] * len(urls),在协程里按索引赋值:results[i] = (url, status)
后者更快,但要求你能控制执行顺序或接受乱序;前者更灵活,适合持续写入日志或流式上报。


















