上周,我面对那个『祖传』新闻聚合器终于忍无可忍:同步爬取 200 个站点耗时 8 分钟,中间还卡了两次数据库超时。运维同事抱怨“这玩意儿比乌龟还慢”,产品经理说“能不能砍到 1 分钟以内?”我说:给我半天时间,我用 asyncio 重写。
结果?总耗时从 487 秒降到 32 秒,飙升 15 倍性能。 老板路过我工位时看了一眼屏幕,直呼“卧槽,这才是正常速度”。今天就和你复盘这次重构,不写教科书式的教程,只讲真正落地的干货。
为什么是 asyncio,而不是多线程?
面对 IO 密集型任务,很多老哥的第一反应是 concurrent.futures 开线程池。但线程有 GIL 开销、上下文切换成本,而且爬虫任务 99% 的时间都在等网络响应——用操作系统线程去“等 IO”就像雇了一群司机只让他们在车里发呆。
asyncio 换了一种思路:单线程 + 事件循环。当一个协程在等待网络响应时,它会主动让出控制权(await),事件循环立即切换到另一个就绪的协程。没有线程切换开销,没有锁竞争,内存占用极低。
核心就三个要素:
- 事件循环:调度中心,谁好了就执行谁。
- 协程:
async def定义的函数,用await挂起。 - Future/Task:协程的包装,可以等待结果。
和我们写同步代码完全不同——你得习惯“并发思维”。
重构实战:从同步阻塞到异步并发
先看看我接手时的同步爬虫(简化的核心逻辑):
import time
import requests
URLS = [f"https://httpbin.org/delay/1?id={i}" for i in range(10)]
def fetch_sync(url: str) -> str:
# 每个请求阻塞 1 秒(模拟网络 IO)
resp = requests.get(url, timeout=5)
return resp.json()["url"]
start = time.perf_counter()
results = [fetch_sync(url) for url in URLS]
elapsed = time.perf_counter() - start
print(f"同步耗时: {elapsed:.2f}s, 结果数: {len(results)}")
# 输出:同步耗时: 10.12s, 结果数: 10
10 个请求,每个 1 秒,顺序执行自然是 10 秒。这谁受得了?
改造成 asyncio,核心就两步:把 IO 函数换成异步版本,然后并发调度。
import asyncio
import aiohttp
import time
URLS = [f"https://httpbin.org/delay/1?id={i}" for i in range(10)]
async def fetch_async(session: aiohttp.ClientSession, url: str) -> str:
# aiohttp 异步请求,await 时让出控制权
async with session.get(url, timeout=5) as resp:
data = await resp.json()
return data["url"]
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch_async(session, url) for url in URLS]
results = await asyncio.gather(*tasks) # 并发执行所有协程
return results
start = time.perf_counter()
results = asyncio.run(main())
elapsed = time.perf_counter() - start
print(f"异步耗时: {elapsed:.2f}s, 结果数: {len(results)}")
# 输出:异步耗时: 1.05s, 结果数: 10
asyncio.gather() 同时启动 10 个协程,总耗时 ≈ 最慢的那个请求(1 秒),而不是累加。这就是事件循环的魅力——当协程 1 在等网络 IO 时,事件循环已经跑去执行协程 2、3……直到某个协程响应就绪,再切回来继续执行。
更深一层:信号量与错误处理,别让异步变成“灾难”
如果你以为上面这段代码就能直接上生产,那大概率会翻车。我踩的第一个坑就是无限制并发。当 URL 数量从 10 变成 2000 时,目标服务器直接把我 IP 给封了——因为瞬时创建了 2000 个 TCP 连接。
解决办法:asyncio.Semaphore,限制同时并发数。
async def fetch_with_limit(session, url, sem, retries=3):
async with sem: # 信号量控制,最多 N 个协程同时运行
for attempt in range(retries):
try:
async with session.get(url, timeout=5) as resp:
if resp.status == 200:
return await resp.json()
else:
raise Exception(f"HTTP {resp.status}")
except Exception as e:
if attempt == retries - 1:
print(f"请求失败: {url}, 错误: {e}")
return None
await asyncio.sleep(2 ** attempt) # 指数退避
async def main_with_limit():
sem = asyncio.Semaphore(50) # 最多 50 并发
async with aiohttp.ClientSession() as session:
tasks = [fetch_with_limit(session, url, sem) for url in URLS]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
return_exceptions=True 是另一个关键点:如果不加,任何一个协程抛异常,gather 会直接抛出,其他协程可能还没完成就被取消了。设置为 True 后,异常会被封装到结果列表中,你可以统一处理而不中断整个批次。
踩坑实录:这 3 个问题坑了我大半天
1. 同步库在异步中作祟
我在协程里偷偷用了 requests.get(),整个事件循环直接卡死,一个协程阻塞导致全队列瘫痪。记住:异步环境里,所有 IO 必须是非阻塞的。用 aiohttp 替代 requests,用 aiofiles 替代 open,用 asyncpg 替代 psycopg2。如果你不得不用同步库,用 asyncio.to_thread() 把它扔进线程池。
2. 事件循环的生命周期混乱
asyncio.run() 内部会创建并关闭事件循环。如果你在已经运行的事件循环里再次调用 asyncio.run()(比如 Jupyter notebook 里),会报 RuntimeError: asyncio.run() cannot be called from a running event loop。解决:在 notebook 里直接用 await,或在脚本入口确保只调用一次 asyncio.run()。
3. 忘记 await,协程默默无闻
fetch_async(session, url) 只是创建协程对象,不会执行!必须 await 或包装成 Task。我多次因为漏写 await,结果列表里全是 <coroutine object>,调试时一脸懵。开启调试模式 asyncio.run(main(), debug=True) 可以帮你抓到“协程未await”的告警。
总结
用 asyncio 不是“更快”,而是“更聪明的等待”——单线程就能优雅调度成百上千个 IO 任务,这正是 Python 异步的魅力。
#Python #异步编程 #asyncio #爬虫实战 #性能优化