事情是这样的:上周老板丢给我一个“简单”需求——同时拉取 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.wait 的 FIRST_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(),但这是最后手段,不建议在正式项目里滥用。我一般在本地调试时加上,赶工一时爽,上线火葬场心里得有点数。
踩坑小本本:四个铁律
- 永不阻塞:协程里只用
await,别调同步 IO。如果实在绕不开,用loop.run_in_executor(None, sync_func)扔给线程池。 - 必须
await:调用async def函数后,如果不await或create_task,它永远不会执行。 - 异常隔离:批量并发时,务必
return_exceptions=True或用asyncio.wait的FIRST_EXCEPTION,别让一个故障接口拉垮全部请求。 - 看清循环:确认当前环境是否已有事件循环,选择合适的启动方式。
写在最后
asyncio 能让 IO 密集型任务的效率比串行提升一个数量级,但它是一把双刃剑——偏离了“非阻塞”和“显式等待”两个原则,代码跑起来就会比你想象的更慢、更怪。那 3 小时的折磨教会我一件事:异步编程不是把 async/await 关键字撒进代码里就万事大吉,理解事件循环的调度逻辑才是关键。 希望你能避开这些坑,早点下班。
#Python #asyncio #爬虫 #性能优化 #编程踩坑