我用 asyncio 重构了一个 100 接口的爬虫,性能飙升 10 倍,老板看了直呼内行

4 阅读1分钟

上周四下午,老板丢给我一个“小需求”:把公司竞品监控系统的数据采集周期从 4 小时压到 15 分钟以内。打开祖传代码,一个 200 行的 requests.get 循环,串行爬 100+ 个接口,跑一次 20 分钟。我当时心里就一个字:凉。但转念一想,这不正是 asyncio 的用武之地?重构完上线,原本 1200 秒的任务缩到 90 秒,CPU 占用反而更低了。这篇就把整个心路历程和关键技术点盘一遍。

为什么同步爬虫撑不住

祖传代码的核心逻辑其实特别简单:

import time
import requests

def fetch_all(urls):
    results = []
    for url in urls:
        resp = requests.get(url, timeout=10)
        results.append(resp.json())
    return results

urls = [f"https://api.example.com/item/{i}" for i in range(100)]
start = time.time()
data = fetch_all(urls)
print(f"耗时: {time.time() - start:.1f}s")

这段代码跑在 100 个接口上耗时 200 秒以上,其中 99% 的时间在等网络 IO,CPU 闲得喝茶。每一个 requests.get 都是阻塞调用,必须等上一个请求完全返回才能发下一个,相当于在高速公路上单车道排队。不管你服务器带宽多大、客户端硬件多强,时间被串行等待吃得死死的。

用 asyncio 实现并发爬虫,一句话讲清核心

asyncio 的核心思想是:单线程内跑一个事件循环,遇到 IO 等待就把控制权让给其他任务。它不是多线程,没有 GIL 竞争;它不创建新进程,内存开销极低。对于成千上万个网络请求这类 IO 密集型场景,asyncio 是性价比最高的并发方案。

改造后的版本如下:

import asyncio
import time
import aiohttp

async def fetch_one(session, url):
    try:
        # aiohttp 的 session.get 返回一个协程,await 交出控制权
        async with session.get(url, timeout=10) as resp:
            return await resp.json()
    except Exception as e:
        return {"error": str(e)}

async def fetch_all(urls):
    # TCPConnector 控制连接池大小,避免对端被压垮
    connector = aiohttp.TCPConnector(limit=50)
    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = [fetch_one(session, url) for url in urls]
        # asyncio.gather 并发执行所有任务,总耗时 ≈ 最慢的那个请求
        results = await asyncio.gather(*tasks, return_exceptions=True)
    return results

async def main():
    urls = [f"https://api.example.com/item/{i}" for i in range(100)]
    start = time.time()
    data = await fetch_all(urls)
    print(f"耗时: {time.time() - start:.1f}s, 获取 {len(data)} 条")

if __name__ == "__main__":
    asyncio.run(main())

这里有几个关键点:

  1. async def + await:定义协程函数,遇到 await 时挂起当前任务,事件循环立刻调度其他就绪任务。await 后面必须跟 Awaitable 对象(协程、Task、Future),这是编译器级别的约束,写错直接报红,比多线程里忘加锁安全多了。
  2. asyncio.gather:同时启动所有协程,等待全部完成。总耗时约等于最慢的一个请求,而不是累加。如果你的 100 个接口平均响应 2 秒,同步需要 200 秒,异步只需要 2 秒出头——实际会受带宽、连接池等限制,但依然是指数级的提升。
  3. aiohttp.TCPConnector(limit=50):限制并发连接数,防止对端服务器被你一波带走。默认是 100,可以根据对方反爬策略调整。
  4. asyncio.run(main()):3.7+ 的标准入口,自动创建事件循环、执行协程、清理资源。不要自己手动 loop = asyncio.get_event_loop(),那是老版本的坑。

上面这个版本,相同环境下跑 100 个接口,耗时从 200+ 秒掉到 18 秒左右,直接 10 倍以上的提升。而且全程单线程,内存占用和调试难度比多线程低几个数量级。

踩坑实录:这三个小时差点给我送走

坑 1:在 async 函数里用了 time.sleep

刚开始为了模拟延迟做测试,顺手写了个 time.sleep(1),结果整个事件循环直接卡死,所有协程都冻结。原因是 time.sleep 阻塞了 OS 线程,事件循环没机会调度。正确姿势是 await asyncio.sleep(1),它会把控制权还给循环,时间到了再恢复执行。同理,别在协程里调用 requests 库或其他同步 IO,要用对应的异步库(aiohttp, aiofiles, aiomysql 等),否则一个阻塞调用毁掉所有并发。

坑 2:忘记 await,协程变成“幽灵”

async def demo():
    asyncio.sleep(1)         # 返回一个协程对象,但没执行!
    await asyncio.sleep(1)   # 真正等待 1 秒

第一行只是创建了一个协程对象,既没调度也没等待,通常会导致逻辑错误,运行时还会报 RuntimeWarning: coroutine was never awaited。解决办法非常简单:凡是调用 async 函数,前面必须加 await,或者用 asyncio.create_task 包装。如果你想让任务后台运行,后续不需要它的返回值,那就 create_task,但一定要持有 task 引用,防止被 GC 提前回收。

坑 3:asyncio.gather 的异常处理玄学

默认情况下,如果某一个子任务抛异常,gather 会立即抛出,其他还在跑的任务会被取消,结果你拿不到任何数据。在爬虫场景里,单接口超时几乎是家常便饭,一个失败不应该拖垮整批请求。务必设置 return_exceptions=True,这样异常会作为返回值放在结果列表里,你可以在后面统一处理。

坑 4:Windows 上的 SelectorEventLoop 问题

Windows 默认用 SelectorEventLoop,对子进程支持较差。如果你在协程里跑 subprocess,可能会遇到 RuntimeError。解决办法是在代码文件开头(其它 import 之前)加:

import asyncio
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

不过在纯网络 IO 场景一般不会触发。记住这个坑就行,别像我当年一样在凌晨三点对着错误栈怀疑人生。

总结

IO 密集用 asyncio,CPU 密集用多进程,其它情况先 profile 再动手。 这句话我在掘金简介里挂了一年,就是因为这轮爬虫重构太香了。

#Python #异步编程 #爬虫 #性能优化 #asyncio