Asyncio 爬虫踩坑全记录:从串行8分钟到并发40秒的血泪复盘

0 阅读8分钟

上周接了个私活,需求并不复杂:写个爬虫批量抓取几百个页面,然后做数据清洗入库。先用同步写法跑了一版,慢到让人怀疑人生——500 个请求串行跑了将近 8 分钟。果断决定上 asyncio 改异步。

然后就开启了一周漫长的踩坑之旅。

说实话,asyncio 这东西,文档看着挺友好,async def 加 await 就完事了。但真正写起来,各种反直觉的行为能让你觉得自己学了假的 Python。这篇文章就是过去一周亲身踩过的真实坑位记录,都是切身体验,希望能帮到正在入坑的朋友。

先看结论

坑位症状原因排查耗时
协程没被 await函数调用了但没执行忘了 await,只拿到协程对象10 分钟
同步代码阻塞事件循环整个程序卡住在 async 函数里调了 time.sleep2 小时
asyncio.run() 套娃报错 RuntimeError在已运行的事件循环里再调 asyncio.run()半天
gather 异常吞没部分任务静默失败默认不会抛出其他任务的异常1 天
aiohttp session 没关ResourceWarning 刷屏没用 async with 管理生命周期30 分钟
并发太猛被封 IP429 Too Many Requests没做并发控制大半天

坑 1:协程没 await,代码静悄悄地不执行

新手第一个会踩的坑,我也没能避开。

python

import asyncio

async def fetch_data():
    print("开始请求...")
    await asyncio.sleep(1)
    print("请求完成")
    return {"data": "hello"}

async def main():
    # ❌ 错误写法:忘了 await
    result = fetch_data()
    print(f"结果: {result}")

asyncio.run(main())

运行结果:

text

结果: <coroutine object fetch_data at 0x...>
RuntimeWarning: coroutine 'fetch_data' was never awaited

fetch_data 里的两个 print 一个都没执行。因为 fetch_data() 只是创建了一个协程对象,并没有真正运行它。

python

async def main():
    # ✅ 正确写法
    result = await fetch_data()
    print(f"结果: {result}")

这个坑虽然简单,但在复杂项目里特别隐蔽。比如你在某个回调里调用了协程函数但忘了 await,那段逻辑就直接被跳过了,还不报错(只有个 Warning),debug 的时候会让人崩溃。

坑 2:同步阻塞炸掉整个事件循环

这个坑真让我排查了两小时。

python

import asyncio
import time

async def task_a():
    print(f"[{time.strftime('%H:%M:%S')}] Task A 开始")
    # ❌ 用了同步的 time.sleep
    time.sleep(3)
    print(f"[{time.strftime('%H:%M:%S')}] Task A 完成")

async def task_b():
    print(f"[{time.strftime('%H:%M:%S')}] Task B 开始")
    await asyncio.sleep(1)
    print(f"[{time.strftime('%H:%M:%S')}] Task B 完成")

async def main():
    await asyncio.gather(task_a(), task_b())

asyncio.run(main())

你猜输出什么?

text

[14:00:00] Task A 开始
[14:00:03] Task A 完成   # 注意:BA 完成后才开始!
[14:00:03] Task B 开始
[14:00:04] Task B 完成

time.sleep(3) 直接把事件循环阻塞了,Task B 根本没法并发。asyncio 是单线程的协作式并发,用了同步阻塞调用,就相当于一个人霸占了整条路,别人谁都过不去。

text

是 - 让出控制权
        ↓
否 - 同步阻塞
        ↓
事件循环 - 单线程
        ↓
当前协程是否 await?
    ↙          ↘
切换到其他就绪的协程      整个事件循环卡住
    ↓                       ↓
多个协程交替执行 ✅        所有其他协程都在等
                            ↓
                         变成串行执行 ❌

正确做法:

python

async def task_a():
    print(f"[{time.strftime('%H:%M:%S')}] Task A 开始")
    # ✅ 用异步的 sleep
    await asyncio.sleep(3)
    print(f"[{time.strftime('%H:%M:%S')}] Task A 完成")

但现实中不只是 sleep 的问题。你用了 requests 库发 HTTP 请求,用了 open() 读大文件,用了某个不支持异步的数据库驱动——这些全是同步阻塞操作,会把你的事件循环卡得死死的。

如果实在要用同步库,用 run_in_executor 扔到线程池:

python

import asyncio
import requests

async def fetch_sync_api(url):
    loop = asyncio.get_event_loop()
    # 把同步的 requests.get 扔到线程池执行
    response = await loop.run_in_executor(None, requests.get, url)
    return response.text

坑 3:asyncio.run() 嵌套调用直接炸

这个坑我是在 Jupyter Notebook 里踩的。

python

import asyncio

async def inner():
    return "hello"

async def outer():
    # ❌ 在协程里再调 asyncio.run()
    result = asyncio.run(inner())
    return result

asyncio.run(outer())

直接报:RuntimeError: asyncio.run() cannot be called from a running event loop

原因很简单:asyncio.run() 会创建一个新的事件循环,但你已经在一个事件循环里了。一山不容二虎,一个线程不容两个事件循环。

Jupyter Notebook 里更麻烦,因为 Notebook 自带一个运行中的事件循环,你在 cell 里直接写 asyncio.run() 就会炸。

解决方案:

python

# 在协程内部,直接 await 就行了,不要再 run
async def outer():
    result = await inner()  # ✅
    return result

# 在 Jupyter Notebook 里:
# 方案一:直接 await(Jupyter 支持顶层 await)
result = await inner()

# 方案二:用 nest_asyncio(不太优雅但管用)
import nest_asyncio
nest_asyncio.apply()
asyncio.run(outer())

坑 4:asyncio.gather 的异常处理黑洞

这个坑害我丢了一天数据,真的痛。

python

import asyncio

async def task_ok():
    await asyncio.sleep(0.5)
    return "成功"

async def task_fail():
    await asyncio.sleep(0.1)
    raise ValueError("出错了!")

async def task_also_ok():
    await asyncio.sleep(0.3)
    return "也成功了"

async def main():
    # ❌ 默认行为:一个任务抛异常,其他任务的结果就拿不到了
    try:
        results = await asyncio.gather(
            task_ok(), task_fail(), task_also_ok()
        )
    except ValueError as e:
        print(f"捕获到异常: {e}")
        # 但是 task_ok 和 task_also_ok 的结果呢?没了。

asyncio.run(main())

gather 在默认行为下,遇到第一个异常就会把它抛出来,其他任务的结果你拿不到(虽然它们其实已经执行了或者还在执行)。

加上 return_exceptions=True

python

async def main():
    # ✅ 异常作为返回值,不会中断其他任务
    results = await asyncio.gather(
        task_ok(), task_fail(), task_also_ok(),
        return_exceptions=True
    )
    
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            print(f"任务 {i} 失败: {result}")
        else:
            print(f"任务 {i} 成功: {result}")

asyncio.run(main())

输出:

text

任务 0 成功: 成功
任务 1 失败: 出错了!
任务 2 成功: 也成功了

2026 年了,更推荐用 TaskGroup(Python 3.11+ 引入的):

python

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            t1 = tg.create_task(task_ok())
            t2 = tg.create_task(task_fail())
            t3 = tg.create_task(task_also_ok())
    except* ValueError as eg:
        for exc in eg.exceptions:
            print(f"捕获: {exc}")

TaskGroup 配合 except*(ExceptionGroup 语法)用起来更清晰,异常处理逻辑更可控。

坑 5:并发数不控制,直接被封

最后一个大坑。我一开始写爬虫的时候,500 个请求直接 gather 一把梭:

python

# ❌ 500 个请求同时发出去
tasks = [fetch(url) for url in urls]  # 500 个
results = await asyncio.gather(*tasks)

结果瞬间收到一堆 429,IP 还被临时封了。

用 asyncio.Semaphore 控制并发数:

python

import asyncio
import aiohttp

async def fetch(session, url, semaphore):
    async with semaphore:  # 信号量控制并发
        async with session.get(url) as response:
            return await response.text()

async def main():
    semaphore = asyncio.Semaphore(10)  # 最多同时 10 个请求
    
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url, semaphore) for url in urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)
    
    success = sum(1 for r in results if not isinstance(r, Exception))
    failed = sum(1 for r in results if isinstance(r, Exception))
    print(f"成功: {success}, 失败: {failed}")

asyncio.run(main())

顺带一提:aiohttp.ClientSession 一定要用 async with 来管理。手动创建但忘了 close,退出时会收到一堆 ResourceWarning: Unclosed client session,不影响功能,但看着烦。

我总结的 asyncio 心智模型

学了一周,我觉得理解 asyncio 的关键就一句话:它是单线程的协作式并发,所有协程共享一个线程,靠 await 主动让出执行权。

对比维度多线程 threading异步 asyncio
并发模型抢占式协作式
切换时机OS 随时切换遇到 await 才切换
线程数多个1 个
竞态条件容易出现几乎不会
阻塞影响只阻塞当前线程阻塞整个事件循环
适用场景CPU 密集 + IO高并发 IO
调试难度高(竞态问题)中(异步思维)

代码 IO 密集(网络请求、数据库查询、文件读写),asyncio 能给你显著的性能提升。我那个爬虫改完之后,500 个请求从 8 分钟降到了 40 秒,提升了 12 倍。

CPU 密集型的(大量计算、图像处理),asyncio 帮不了你,老老实实用 multiprocessing

开发辅助工具推荐

在实际调试异步代码时,单靠 print 往往不够直观。不少开发者会借助集成环境中的多模型调试接口,在遇到复杂异常时快速获得代码审计与修复建议。比如通过 星链4SAPI 这类多模型接入网关,可以按需调用不同的大模型来分析异步逻辑中的隐式依赖问题,只需调整请求的 base_url 即可在同一套代码中切换不同模型的输出结果,省去逐一适配各家接口规范的麻烦。

整体调用链路示意:

text

你的应用代码
    │
    ├─ 方案一:官方 API 直连
    ├─ 方案二:云服务商托管
    └─ 方案三:星链4SAPI 聚合接入
           ├─ DeepSeek V4
           ├─ Claude Opus 4.6
           ├─ GPT-5
           ├─ Gemini 3
           └─ GLM-5 / Qwen 3

小结

回头看这一周,asyncio 的核心概念并不多,难的是那些反直觉的行为和隐蔽的 bug。几条实操建议:

  • 先搞清楚事件循环的运行机制,不要急着写代码
  • 检查所有库是否支持异步,不支持的用 run_in_executor 包一层
  • gather 一定要加 return_exceptions=True,不然数据丢了你都不知道
  • 并发数必须控制,Semaphore 是你的好朋友
  • Python 3.11+ 尽量用 TaskGroup 替代 gather,异常处理更优雅

踩完这些坑之后,asyncio 用起来其实挺顺手的。起码比 threading 那套锁来锁去的操作省心多了——毕竟单线程,不用操心竞态条件。