上周五下午五点,我正准备合上电脑开溜,告警群里突然炸了——线上数据采集服务超时率飙到40%,下游报表全白。我打开日志一看,上千个 URL 的爬虫任务还在用老旧的同步 requests 逐个爬,单个请求平均 1.2 秒,一轮下来要将近 20 分钟,而业务要求 5 分钟内必须跑完。当时脑子里只有一个念头:上 asyncio 重构成并发,下班前搞定。结果这一搞,踩了三个大坑,差点把服务搞崩。现在把血泪经验写出来,希望能帮你省下这三小时。
为什么 asyncio 是 IO 密集型任务的正解
asyncio 的核心是事件循环(Event Loop)加上协程(Coroutine)。你可以把事件循环想象成一个不断轮询的调度员,而每个协程就是一个可以主动暂停、交出控制权的任务。当某个协程在等待网络响应(IO)时,事件循环会立刻切换到其他就绪协程,CPU 几乎不会空转。这和传统多线程模型最大的区别是:asyncio 是单线程内的协作式调度,避免了线程切换开销和 GIL 锁竞争,尤其适合网络请求密集的场景。
我们常用的模式是:用 async def 定义协程函数,在里面 await 异步 IO 操作;然后用 asyncio.gather() 把多个协程同时丢给事件循环执行。总耗时取决于最慢的那个任务,而不是所有任务的时间累加。
但“懂原理”和“写对代码”之间,隔着好几条人命。
代码实战:从“同步坑”到“异步真香”
坑1:在协程里用了同步阻塞调用
刚开始我写了个最简单的并发爬虫,大意如下:
import asyncio
import requests # 同步库,不能用!
async def fetch(url):
# 错误示范:直接把同步的 requests 放在协程里
resp = requests.get(url, timeout=5) # 这次调用会阻塞整个线程!
return resp.status_code
async def main():
urls = ["https://httpbin.org/delay/1"] * 10
tasks = [fetch(url) for url in urls]
results = await asyncio.gather(*tasks)
print(results)
asyncio.run(main())
运行时你会发现,所有请求依然是串行的,效果跟同步代码一模一样。原因很简单:requests.get() 是同步阻塞调用,它在等待网络 IO 的期间并不会交出控制权给事件循环,导致同一时间只有一个协程在跑。事件循环形同虚设。
正确做法:换用异步 HTTP 客户端,比如 aiohttp 或 httpx.AsyncClient。
import asyncio
import aiohttp
async def fetch(session, url):
# 使用 aiohttp 的异步请求,await 时将控制权交还事件循环
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
return await resp.text()
async def main():
urls = ["https://httpbin.org/delay/1"] * 10
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks)
print(f"完成 {len(results)} 个请求")
asyncio.run(main())
这段代码才真正用到了事件循环的并发能力。10 个延迟 1 秒的请求,总耗时大约 1 秒出头,而不是 10 秒。我的爬虫任务从 20 分钟直接压到 2 分钟以内。
坑2:gather 遇到异常直接炸全家
当任务量增加到几百个 URL 时,偶尔会有几个请求超时或 DNS 解析失败。我发现只要其中一个协程抛异常,gather() 会把异常立刻向上传播,导致所有其他正在执行的协程也被取消,整个批次前功尽弃。这正是线上第一次部署时碰到的情景:一个小域名解析不到,全部任务熔断,下游又白屏了。
修复办法是使用 gather(..., return_exceptions=True),让它把异常作为结果对象返回,而不是中断流程。
async def fetch_with_sem(sem, session, url):
async with sem: # 限制并发数,防止瞬间占满文件描述符
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
return url, await resp.text()
except Exception as e:
return url, f"ERROR: {e}"
async def main():
urls = [...] # 几百个 URL
sem = asyncio.Semaphore(50) # 限制并发,避免触发系统或服务端限制
async with aiohttp.ClientSession() as session:
tasks = [fetch_with_sem(sem, session, url) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True) # 关键!
for url, content in results:
if isinstance(content, Exception):
print(f"{url} 失败: {content}")
else:
process(content)
加上 Semaphore 和有异常吞掉后的重试队列,服务才真正稳下来。
踩坑 & 注意事项:这些才是真正的杀手
1. 别在协程里调用 time.sleep()
time.sleep() 会让整个线程睡过去,事件循环完全停摆。一定要用 await asyncio.sleep() 来模拟异步等待。
2. 注意事件循环的生命周期
如果是在已有的同步 Web 框架(比如 Flask)里引入 asyncio,不要直接在视图里调用 asyncio.run(),因为每次请求会创建并销毁事件循环,开销巨大且状态不可控。正确做法是维护一个全局的 ClientSession,并用 asyncio.ensure_future() 或消息队列解耦。对于 FastAPI 这类原生异步框架,直接用 async def 视图即可。
3. aiohttp 的 ClientSession 必须复用
每次请求时临时创建 ClientSession 会因为 TCP 连接无法复用而导致性能骤降,还可能触发连接数耗尽。要在整个应用生命周期内(或极长的时间窗口内)重用同一个 session,并在程序退出时主动关闭。
4. 并发数不是越高越好
我曾经图快直接把 Semaphore 设成 200,结果目标域名因为承受不住从而反压限流,服务端大量抛出 ConnectionResetError。一般从 30~50 开始压测,逐步上调,找到吞吐和错误率的平衡点。
这些坑踩过来,我深切体会到:asyncio 不是银弹,它要求整个调用链必须全是异步的,一个同步阻塞调用就能毁掉整个并发模型。
总结
asyncio 的并发魔法,全藏在“协作式交出控制权”这一哲学里;阻塞是它最大的敌人,异常是它最容易背叛你的队友。
#Python #异步编程 #asyncio #爬虫 #性能优化