asyncio 踩坑实录:一个并发爬虫竟让我排查了3小时

0 阅读1分钟

事情是这样的,上周老板丢过来一个需求:从 50 个第三方 API 拉数据做聚合报表。我心想小事一桩,写个循环 Requests 不就完了。结果跑起来直接傻眼——全是同步阻塞,50 个接口轮一圈要将近 80 秒。这时候我自然想到了 asyncio,Python 里专门解决 IO 密集型并发的神器。谁知兴冲冲地开干之后,一连串诡异现象让我在工位上排查了整整 3 个小时。

以为理解了 asyncio,其实只理解了皮毛

事件循环:单线程里的“时间管理大师”

asyncio 的核心是一个事件循环(Event Loop),它在单线程内调度所有协程。当一个协程在等网络、磁盘这种慢 IO 时,不会阻塞整个线程,而是把控制权交还给事件循环,由它去唤醒下一个就绪的协程。

async def 定义协程,用 await 主动让出执行权:

import asyncio

async def fetch_api(url: str) -> str:
    print(f"开始请求 {url}")
    await asyncio.sleep(1)        # 模拟网络 IO,实际会用 aiohttp
    return f"data from {url}"

真正的并发:gather 与 create_task

顺手把 50 个任务一起丢进去,用 asyncio.gather 并发执行,总耗时看最慢的那个,而不是所有请求时间相加:

async def main():
    urls = [f"https://api.example.com/item/{i}" for i in range(50)]
    tasks = [fetch_api(url) for url in urls]
    results = await asyncio.gather(*tasks)
    print(f"获取 {len(results)} 条数据")

asyncio.run(main())

从 80 秒直接降到 2 秒不到,我差点高兴得拍桌子。但坑,也在这时候排着队来了。

完整对比:同步 vs 异步,差距有多大

下面两段代码可以直接复制运行,强烈建议你跑一下感受区别。

同步版本(慢得离谱)

import time
import requests

def fetch_sync(url: str) -> str:
    resp = requests.get(url, timeout=5)
    return resp.status_code

def main():
    urls = ["https://httpbin.org/delay/1"] * 10  # 10个模拟慢接口
    start = time.perf_counter()
    results = [fetch_sync(url) for url in urls]
    elapsed = time.perf_counter() - start
    print(f"同步耗时: {elapsed:.2f}s, 结果数: {len(results)}")

if __name__ == "__main__":
    main()

异步版本(正确姿势)

import asyncio
import time
import aiohttp

async def fetch_async(session: aiohttp.ClientSession, url: str) -> int:
    async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
        return resp.status

async def main():
    urls = ["https://httpbin.org/delay/1"] * 10
    start = time.perf_counter()
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_async(session, url) for url in urls]
        results = await asyncio.gather(*tasks)
    elapsed = time.perf_counter() - start
    print(f"异步耗时: {elapsed:.2f}s, 结果数: {len(results)}")

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

同步 10 个接口耗时约 12 秒,异步直接压到 1 秒出头,差距肉眼可见。

我踩过的坑,一个比一个隐蔽

1. 忘记 await,协程变成了僵尸

tasks = [fetch_async(session, url) for url in urls]  # 只返回协程对象,不会运行!

没有 awaitasyncio.gather 包裹,这些协程压根没被调度。代码跑完时间几乎是 0,结果列表全是协程对象。解决方法就是老老实实用 gathercreate_task

2. 在协程里调用 time.sleep,整个循环卡死

async def buggy_fetch(url):
    import time
    time.sleep(1)          # 阻塞了线程,事件循环被冻结
    return "data"

time.sleep 是同步阻塞调用,会霸占唯一的线程,事件循环完全无法切换。必须用 await asyncio.sleep(n),或者把同步调用丢给 loop.run_in_executor

3. 并发数不加限制,被目标 API 拉黑

50 个协程同时发起请求,直接把对方服务器打崩,返回一堆 429。解决方法是 Semaphore 信号量

sem = asyncio.Semaphore(10)   # 最多同时 10 个并发

async def rate_limited_fetch(session, url):
    async with sem:
        return await fetch_async(session, url)

4. Windows 上 asyncio.run() 报错

在 Windows 使用 ProactorEventLoop,某些场景下会提示 RuntimeError。换成 asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 或在顶层用 if __name__ == "__main__" 里调用 asyncio.run(),避免 spawn 子进程时循环冲突。

5. aiohttp 连接器耗尽

默认连接池只有 100 个连接,复用不够时请求会排队。如果域名不同还好,相同域名高并发必须调整 connector

conn = aiohttp.TCPConnector(limit=200)
async with aiohttp.ClientSession(connector=conn) as session:
    ...

总结

异步编程的本质是“不让 IO 等待浪费 CPU”,但 Python 的 asyncio 把状态切换的主动权交给了开发者,一个不留神就是隐蔽的阻塞。你能写出 2 秒跑完 50 个 API 的优雅代码,也能写出死锁不动还不报错的僵尸程序。

#Python #异步编程 #asyncio #并发爬虫 #踩坑