事情是这样的,上周老板丢过来一个需求:“把这 10 万个商品页的数据抓下来,今晚就要。”我心想,简单,不就是requests加个循环嘛。结果一算时间:按每个请求 0.5 秒,串行跑得 13.8 个小时。当场裂开。
于是祭出 asyncio,打算用协程并发把时间压到分钟级。结果连崩三次:第一次服务被目标站拉黑 IP,第二次内存爆到 8G 被 OOM Killer 带走,第三次才发现有一半请求返回的是异常,我却浑然不知。
踩完这些坑,我才算真正摸到 asyncio 的门道。这篇就把我的血泪经验记录下来。
事件循环:为什么它能这么快
很多人以为 asyncio 是多线程,其实它只在一个线程里跑。核心就是事件循环(Event Loop),它像一个不停轮转的调度器——当一个协程在等网络响应时,循环立刻切到另一个就绪的协程,CPU 几乎不空转。这就特别适合 IO 密集型任务:爬虫、API 调用、数据库查询。
用最简单的例子感受一下:
import asyncio
import time
async def fetch(url):
# 模拟网络 IO,不阻塞线程
await asyncio.sleep(0.5)
return f"{url} done"
async def main():
start = time.time()
# 10 个任务并发跑,总耗时约 0.5 秒,而不是 5 秒
results = await asyncio.gather(
*[fetch(f"https://page/{i}") for i in range(10)]
)
print(f"耗时: {time.time() - start:.2f}s")
print(results[:3])
asyncio.run(main())
如果换成串行 time.sleep(0.5),10 次就是 5 秒。asyncio.gather 把 10 个协程同时提交给事件循环,最快的返回不会被最慢的阻塞,总耗时只由最慢的那个决定——这就是异步并发的威力。
实战:一个能抗住 10 万请求的爬虫
上面的代码只有 asyncio.sleep,真正干活得发 HTTP。我用的是 aiohttp,它是 asyncio 生态里的 requests 替代品。但直接无脑并发 10 万协程,和 DDoS 没区别,对方服务器直接把你封掉,我的第一次崩溃就在这。
所以必须加**信号量(Semaphore)**限制并发数,一般 200-500 是安全范围:
import asyncio
import aiohttp
SEM_LIMIT = 200
async def fetch(session, url, sem):
async with sem: # 控制同时进行的协程数
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
text = await resp.text()
return url, len(text)
except Exception as e:
return url, str(e)
async def crawl(urls):
sem = asyncio.Semaphore(SEM_LIMIT)
# 复用同一个 TCP 连接池,避免反复握手
connector = aiohttp.TCPConnector(limit=SEM_LIMIT)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [fetch(session, url, sem) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
# 跑起来
urls = [f"https://httpbin.org/delay/1?page={i}" for i in range(1000)]
results = asyncio.run(crawl(urls))
print(f"完成 {len(results)} 个请求")
这段代码有几个关键点:
asyncio.Semaphore像一把锁,只允许 N 个协程同时进入,其余在外面排队,防止无限制创建连接。TCPConnector(limit=SEM_LIMIT)限制底层连接池大小,和信号量配合使用,避免端口耗尽。return_exceptions=True极其重要,后面踩坑细讲。aiohttp.ClientTimeout设置了总超时 10 秒,防止某个慢请求拖死整个批次。
真正让我崩溃的三个坑
坑1:gather 里一个异常炸飞全部
最初我没加 return_exceptions=True,结果爬了几分钟就被一个 500 错误搞挂——asyncio.gather 的默认行为是:任何一个子协程抛异常,它立刻取消其他所有任务并向上抛。10 万个请求里有一两个超时或 5xx 太正常了,结果整个批次白跑。解决方案就是加 return_exceptions=True,让 gather 把异常当作结果返回,不会中断其余任务。之后你再手动过滤和处理异常结果。
坑2:在协程里偷偷做了同步阻塞
我在解析 HTML 时用了 BeautifulSoup,这是 CPU 密集操作,但还好。真正要命的是早期版本我随手写了 time.sleep(0.1) 来做“反爬延时”。time.sleep 是同步阻塞的,它会让整个事件循环停摆,所有协程在这 0.1 秒内都无法推进。正确做法是用 await asyncio.sleep(0.1)。任何可能导致线程等待的操作(文件 IO、同步 requests、重CPU计算)要么放到线程池(loop.run_in_executor),要么用异步库替代,否则你的并发会被单个任务拖成串行。
坑3:连接池耗尽和 DNS 解析风暴
默认的 TCPConnector 连接数上限是 100,当你把 Semaphore 设到 500 时,会有 400 个协程卡在等待连接。而且每次新连接都要解析 DNS,aiohttp 默认用的是同步的 socket.getaddrinfo……又一个隐式阻塞。解决方案是装 aiodns,并在 TCPConnector 里指定 ttl_dns_cache=300 和 use_dns_cache=True,或者传入 resolver。同时把 limit 调到和 Semaphore 一致,彻底打通并发链路。
一句话总结
asyncio 的快,建立在“所有阻塞都要让路”这个铁律上,一个同步操作就会让你回到解放前。
#Python #异步编程 #爬虫 #性能优化 #aiohttp