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

3 阅读1分钟

事情是这样的:上周老板丢给我一个“简单”需求——同时拉取 120 个内部 API 的数据,汇总后生成报表。我心想,这不就是 IO 密集型吗,Python 的 asyncio 我最熟不过了。于是大笔一挥,10 分钟写完第一版代码,结果发现实际运行时间居然比串行还慢,而且有几个接口的数据永远没返回。那个下午,我盯着终端输出来回改了 3 小时,直到看到代码里那个不起眼的调用时,才恍然大悟。

如果你也在用 asyncio 做并发,下面这些陷阱很可能会让你怀疑人生。


一、罪魁祸首:在协程里塞进了同步阻塞调用

我先贴出第一版“天真”的代码,你能一眼看出问题吗?

import asyncio
import time
import requests  # 注意:经典的同步库

async def fetch_api(url):
    """协程函数:获取 API 数据"""
    print(f"Starting {url}")
    # 模拟获取数据 —— 这里埋了一颗大雷
    resp = requests.get(url, timeout=10)  # 同步阻塞调用!
    data = resp.json()
    print(f"Finished {url}")
    return data

async def main():
    urls = [f"https://httpbin.org/delay/1?req={i}" for i in range(10)]
    start = time.time()
    results = await asyncio.gather(*[fetch_api(u) for u in urls])
    elapsed = time.time() - start
    print(f"10 请求耗时: {elapsed:.2f}s")
    return results

asyncio.run(main())

运行结果让我傻眼:10 个请求居然用了 10 秒以上,跟串行一模一样。道理其实很简单——requests.get()同步阻塞的,它在等待网络 IO 时会死死占住线程,事件循环根本没有机会切换到其他协程。你在 async def 里写同步代码,就像给跑车装了个拖拉机发动机。asyncio 的真谛是:所有 IO 操作都必须是异步的

修复方案有两种:要么换用异步库 aiohttp,要么用 loop.run_in_executor 把同步任务丢进线程池。推荐前者:

import aiohttp
import asyncio
import time

async def fetch_api(session, url):
    async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
        return await resp.json()

async def main():
    urls = [f"https://httpbin.org/delay/1?req={i}" for i in range(10)]
    start = time.time()
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_api(session, u) for u in urls]
        results = await asyncio.gather(*tasks)
    elapsed = time.time() - start
    print(f"10 请求耗时: {elapsed:.2f}s")
    return results

asyncio.run(main())

切换后,10 个请求耗时瞬间降到 1.5s 左右,老板的眉头终于舒展了。


二、忘记 await,协程根本没被调度

这个坑出现的次数比我想承认的多。看下面这个经典错误:

async def say_hello():
    await asyncio.sleep(1)
    print("Hello")

async def main():
    # 事故现场:创建协程对象,但忘了 await
    say_hello()          # 只会返回一个 coroutine object,不会执行
    await asyncio.sleep(2)
    print("End")

运行后你会发现终端只输出 End,那个 Hello 永远等不来。Python 不会报错,只是默默创建一个协程对象然后放进虚空。正确的写法是 await say_hello(),或者用 asyncio.create_task(say_hello()) 托管给事件循环。我的个人习惯:凡是调用一个 async def 函数,要么前面加 await,要么用 create_task 包装,绝不允许裸奔。


三、gather 的异常处理:一颗老鼠屎坏了一锅汤

接手那个需求时,120 个接口中偶尔有几个会超时或返回 500。我用 asyncio.gather 一跑,发现只要有一个任务抛异常,其余所有已完成和未完成的任务都会被取消,拿不到任何有效数据。

# 错误示范:一个炸,全家炸
async def bad_request():
    await asyncio.sleep(0.5)
    raise ValueError("接口挂了")

async def good_request():
    await asyncio.sleep(1)
    return "正常数据"

async def main():
    try:
        results = await asyncio.gather(bad_request(), good_request())
    except ValueError:
        print("捕获异常,但 good_request 的结果也丢了")

解决办法就是在 gather 里加上 return_exceptions=True

results = await asyncio.gather(
    task1, task2, ...,
    return_exceptions=True
)
for r in results:
    if isinstance(r, Exception):
        log_error(r)      # 单独处理异常
    else:
        process(r)        # 正常数据

这样每个任务是独立的,异常不会相互污染。配合 asyncio.waitFIRST_EXCEPTION 策略还能更灵活,但大多数场景 gather(..., return_exceptions=True) 够用了。


四、在已有事件循环的环境中调用 asyncio.run

如果你在 Jupyter Notebook 或某些 Web 框架里直接写 asyncio.run(main()),很可能会遇到 RuntimeError: asyncio.run() cannot be called from a running event loop。这是因为这些环境已经启动了一个事件循环,asyncio.run 专为入口脚本设计,它内部会新建循环然后关闭,不能嵌套。

修复方案:

  • 脚本环境:继续用 asyncio.run(main()),完全没问题。
  • Jupyter / IPython:直接用 await main(),因为 IPython 内核本身就运行在异步上下文里。
  • 必须嵌套的场景:安装 nest_asyncio 并在代码里调用 nest_asyncio.apply(),但这是最后手段,不建议在正式项目里滥用。我一般在本地调试时加上,赶工一时爽,上线火葬场心里得有点数。

踩坑小本本:四个铁律

  1. 永不阻塞:协程里只用 await,别调同步 IO。如果实在绕不开,用 loop.run_in_executor(None, sync_func) 扔给线程池。
  2. 必须 await:调用 async def 函数后,如果不 awaitcreate_task,它永远不会执行。
  3. 异常隔离:批量并发时,务必 return_exceptions=True 或用 asyncio.waitFIRST_EXCEPTION,别让一个故障接口拉垮全部请求。
  4. 看清循环:确认当前环境是否已有事件循环,选择合适的启动方式。

写在最后

asyncio 能让 IO 密集型任务的效率比串行提升一个数量级,但它是一把双刃剑——偏离了“非阻塞”和“显式等待”两个原则,代码跑起来就会比你想象的更慢、更怪。那 3 小时的折磨教会我一件事:异步编程不是把 async/await 关键字撒进代码里就万事大吉,理解事件循环的调度逻辑才是关键。 希望你能避开这些坑,早点下班。

#Python #asyncio #爬虫 #性能优化 #编程踩坑