在批量采集公开网页数据时,同步阻塞的requests写法会因为网络等待而大量浪费CPU时间。通过Python的asyncio与aiohttp构建并发爬虫,可以让单进程在IO等待期间调度成百上千个协程,从而把抓取吞吐量与效率提升到新的量级。下面先了解基础架构,再给出完整代码与调优手段。

为什么同步爬虫效率低下
最常见的初学者写法是使用requests库在for循环中逐个请求URL。这种写法逻辑简单,但每次调用requests.get都会阻塞当前线程,直到服务器返回响应或超时。如果目标站点平均响应时间为300毫秒,那么单线程每秒最多完成3到4个请求,采集一万个页面需要近一小时。
当采集规模扩大到数十万URL时,这种阻塞模型会成为明显瓶颈。增加线程数虽能缓解,但Python的线程受GIL限制,且系统线程切换开销大,线程数过多反而导致调度混乱与内存暴涨。此时更适合用协程将等待时间利用起来,而不是盲目堆线程。
asyncio与aiohttp核心原理
asyncio是Python标准库提供的事件循环框架。通过async def定义协程函数,在内部使用await挂起IO操作,事件循环便能在挂起期间执行其他就绪协程。aiohttp则是基于asyncio的异步HTTP客户端,其get请求不会阻塞线程,而是把socket读写的等待交还给事件循环。
简单说,同步请求像排队打水,前面的人没接完后面只能干等;异步协程像每个人接水时顺手去帮别人按开关,大家都在动。下面的代码展示最基础的异步抓取单个页面的方式,注意所有HTML特殊字符在pre块内均已转义。
import asyncio
import aiohttp
async def fetch_one(session, url):
async with session.get(url) as resp:
# 读取响应文本,await让出控制权
text = await resp.text()
return len(text)
async def main():
async with aiohttp.ClientSession() as session:
html_len = await fetch_one(session, 'https://ipipp.com')
print(html_len)
asyncio.run(main())
并发控制与完整爬虫示例
无限制地同时发起请求会压垮目标站或被封IP,因此必须用asyncio.Semaphore做并发数限制。以下示例抓取多个页面,并把最大并发控制在10,同时加入简单异常捕获。用列表推导式创建任务,再由事件循环统一调度。
相比同步版,该写法在千条URL测试中通常能把耗时从几分钟压缩到十余秒。代码中random是为了演示重试逻辑,实际可结合具体异常类型处理。注意在pre内部,小于号与amp均转义为<与&。
import asyncio
import aiohttp
async def fetch(session, url, sem):
async with sem:
try:
async with session.get(url, timeout=10) as resp:
if resp.status == 200:
text = await resp.text()
return (url, len(text))
return (url, 0)
except Exception as e:
# 简单重试一次
try:
async with session.get(url, timeout=10) as resp:
text = await resp.text()
return (url, len(text))
except Exception as e2:
return (url, -1)
async def main(urls):
sem = asyncio.Semaphore(10)
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, u, sem) for u in urls]
results = await asyncio.gather(*tasks)
return results
if __name__ == '__main__':
url_list = ['https://ipipp.com' for _ in range(20)]
data = asyncio.run(main(url_list))
print(data)
效率对比与调优建议
我们用一张简表对比同步与异步在1000个URL、平均响应250毫秒场景下的理论表现。实际数值受网络与机器影响,但数量级差异明显。
| 方案 | 并发模型 | 预估耗时 | 内存占用 |
|---|---|---|---|
| requests循环 | 同步阻塞 | 约250秒 | 低 |
| 多线程 | 50线程 | 约10秒 | 中 |
| asyncio+aiohttp | 协程 | 约5秒 | 低 |
调优时建议把信号量设在目标站可承受范围,例如10到50之间。若遇到429限流,应读取Retry-After头并await asyncio.sleep对应秒数。此外,使用代理池轮换出口IP能进一步降低封禁率,但需确保采集行为符合对方robots协议与法律规定。
另一个常见误区是把CPU密集解析逻辑写进协程。BeautifulSoup的解析会阻塞事件循环,应把抓取与解析分离,或用loop.run_in_executor把解析丢到线程池,保持主循环轻量。这样才能真正发挥并发爬虫的提升效率价值。
小结与落地注意
把同步爬虫改写为asyncio协程,核心是用await释放IO等待,用Semaphore约束并发,用gather聚合任务。它在单进程内实现高并发,资源占用远小于多进程,是Python提升抓取效率的优选方案。
上线前请确认目标网站允许抓取,控制频率避免影响对方服务。配合代理、重试与解析离线化,你的Python并发爬虫就能稳定且高效地完成大规模数据采集任务。