asyncio 踩坑实录:这个问题坑了我3小时

4 阅读6分钟

上周老板让我把公司一个老爬虫项目提速,我心想这不简单,直接上 asyncio 并发拉取,理论上能把 200 个请求从串行的 40 秒压到 2 秒左右。结果写完第一版脚本,跑起来速度确实快了,但数据丢了一半,控制台还刷出一堆 RuntimeWarning: coroutine was never awaited。更崩溃的是,程序跑着跑着突然卡死,CPU 占用率 0%,活活等到超时。最后对着文档和源码调了 3 个小时,才把坑一个个填平。这篇文章就把我踩的坑和最终的最佳实践完整记录下来,让你不再走同样的弯路。


场景:200 个 API 数据拉取,从 40 秒到 2 秒

一开始的串行代码像这样,简单但慢得离谱:

import time
import requests

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

urls = [f"https://api.example.com/item/{i}" for i in range(200)]
start = time.time()
data = fetch_all(urls)
print(f"耗时: {time.time() - start:.2f}s")
# 输出: 耗时: 41.23s

200 个请求串行下来 40 多秒,体验极差。接下来我信心满满地开始用 asyncio 改造。


核心知识:事件循环 + 协程 = 非阻塞并发

asyncio 的运作方式和多线程完全不同。它是一个单线程的事件循环,所有协程在同一线程内调度。当某个协程遇到 IO 等待(网络、磁盘)时,主动通过 await 把控制权交还给事件循环,循环立即去执行其他已经就绪的协程。这样 CPU 永远不会因为等待 IO 空转。

最基础的写法:

import asyncio
import aiohttp

async def fetch(session, url):
    async with session.get(url) as resp:
        return await resp.json()

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)
    return results

urls = [f"https://api.example.com/item/{i}" for i in range(200)]
asyncio.run(main())

asyncio.gather 会把所有协程并发调度,总耗时约等于最慢的那个请求,而不是累加。理论很美好,我一跑却开始踩坑。


坑 1:协程里偷偷用了同步阻塞调用

最初我偷懒,在协程内部继续用 requests.get,想着只要套一层 async def 就好。结果事件循环在 requests.get 上直接卡死,所有并发失效。

# 错误示范
import requests

async def fetch_bad(url):
    resp = requests.get(url)   # 同步阻塞!事件循环被堵死
    return resp.json()

requests 库是同步 IO,一旦调用,整个线程就阻塞等待网络响应,事件循环完全失去控制权。asyncio 必须配套使用支持异步的库,比如 aiohttp 做 HTTP,aiomysql 做数据库查询。

解决:所有 IO 操作全部换成 async/await 生态的库。实在有没法替换的同步代码,用 loop.run_in_executor 甩到线程池:

import concurrent.futures

def sync_heavy_work(data):
    # 这是一个没法改写的同步 CPU 计算
    return sum(i * i for i in range(data))

async def run_in_thread(data):
    loop = asyncio.get_running_loop()
    with concurrent.futures.ThreadPoolExecutor() as pool:
        result = await loop.run_in_executor(pool, sync_heavy_work, data)
    return result

这样可以避免阻塞事件循环,但不建议大量使用,线程切换成本依然存在。


坑 2:gather 遇到异常直接 raise,导致部分结果丢失

200 个请求里偶尔有一两个超时或 500 很正常。我第一次用 gather 时,只要有一个任务抛异常,整个 gather 立即抛出异常,其他 190+ 个成功的响应全被丢弃,气得我拍大腿。

results = await asyncio.gather(*tasks)  # 一个挂了,全部白干

解决gather 提供了 return_exceptions=True 参数,异常不会向上抛出,而是把异常对象直接放进结果列表,像正常返回值一样处理:

results = await asyncio.gather(*tasks, return_exceptions=True)

for i, res in enumerate(results):
    if isinstance(res, Exception):
        print(f"任务 {i} 失败: {res}")
    else:
        process(res)

这样即使 10 个任务超时,剩下 190 个数据也能完整拿到。我还习惯在 fetch 里做超时和重试:

from aiohttp import ClientTimeout

async def fetch(session, url, retries=2):
    for attempt in range(retries):
        try:
            async with session.get(url, timeout=ClientTimeout(total=5)) as resp:
                resp.raise_for_status()
                return await resp.json()
        except Exception as e:
            if attempt == retries - 1:
                raise
            await asyncio.sleep(1)

坑 3:忘记 await,协程不执行

也许你和我一样,写完代码发现程序瞬间跑完,根本没发网络请求,终端还刷出一大串警告:

RuntimeWarning: coroutine 'fetch' was never awaited

原因很简单:async def 定义的协程函数,调用它只是创建了一个协程对象,不会执行任何一个语句。只有被 await 或被放进事件循环任务(如 gathercreate_task)时,才会被调度执行。

错误写法:

tasks = [fetch(session, url) for url in urls]   # 这只是一个协程对象列表
# 忘了传进 gather 或者 await

正确做法要么用 gather,要么显式创建 Task:

tasks = [asyncio.create_task(fetch(session, url)) for url in urls]
results = await asyncio.gather(*tasks)   # 或者逐个 await task

推荐用 create_task 以便在等待时追踪任务状态。


坑 4:没有正确关闭事件循环和 aiohttp 的 Session

早期代码里我习惯直接 session.get 而不使用 async with,结果程序结束后打印一大堆未关闭的连接警告,有时还导致文件描述符耗尽,整个程序僵死。

aiohttp 强烈建议用上下文管理器接管 Session 生命周期:

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [asyncio.create_task(fetch(session, url)) for url in urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)
    # 出 with 块后自动关闭连接器,释放 fd
    return results

对于事件循环,Python 3.7+ 用 asyncio.run() 就够了,它会自动创建、运行并关闭循环,省掉一堆手动清理的步骤。千万不要在已经运行的事件循环中再调用 asyncio.run(),否则会抛出 RuntimeError: This event loop is already running


踩坑心得汇总

  • 所有 IO 必须异步:同步库会锁死事件循环,看见 import requests 就立刻警惕。
  • gather 记得加 return_exceptions=True:避免一颗老鼠屎坏了一锅粥。
  • 协程必须 awaitcreate_task:别把协程对象晾在那,它不会自己跑。
  • async with 管理资源:aiohttp 的 Session、文件异步读写等都要用上下文,不然连接泄漏。
  • 控制并发量:200 个请求同时发,可能打爆目标服务器或本机资源。用 asyncio.Semaphore 限制并发数是最稳妥的做法。
sem = asyncio.Semaphore(20)   # 同时最多 20 个请求

async def fetch_with_limit(session, url):
    async with sem:
        return await fetch(session, url)

三小时踩完这些坑之后,爬虫终于稳稳地跑起来,200 个请求带重试和异常处理,总耗时 2.3 秒,数据零丢失。asyncio 确实是 IO 密集场景的利器,但它对代码规范和库选择的要求非常严,一个同步调用就能毁掉整个并发模型。如果你想把它用在生产里,务必把这几个坑刻在脑子里。

#Python #asyncio #爬虫 #并发编程 #后端