上周五下午五点半,我正打算合上电脑下班,运营同事突然发来需求:把竞品价格监控脚本提速,现在串行跑一圈要 40 分钟,数据还没入库老板微信已经来催了。我一看代码,100 多个 API 请求,全是 requests.get 一个接一个等。这能忍?我二话不说把核心抓取逻辑改成 asyncio,想着“并发一开,五分钟收工”——结果一直调到晚上八点半,中间踩的坑比我过去一周写的 bug 都多。下面就把这些血泪教训记下来,希望能让你少走弯路。
你以为你懂了 asyncio,其实是它在玩你
asyncio 的核心思想不复杂:一个事件循环在一个线程里调度所有协程,当某个协程 await 一个 IO 操作时,事件循环就把它挂起,扭头去执行另一个协程。所以 IO 密集型任务才是它的主场,CPU 计算你硬塞进去反而会堵死整个循环。
import asyncio
import time
async def fetch_price(symbol: str) -> tuple:
# 模拟一次网络请求,耗时 0.5~1.5 秒
await asyncio.sleep(0.5 + hash(symbol) % 10 * 0.1)
return symbol, round(100 + hash(symbol) % 50, 2)
async def main_naive():
"""❌ 乍看是并发,实际上还是串行"""
symbols = ["AAPL", "GOOGL", "MSFT", "AMZN", "META",
"TSLA", "NVDA", "BABA", "JD", "PDD"]
tasks = []
for sym in symbols:
# 错误:在这里 await 就等于顺序执行!
price = await fetch_price(sym)
tasks.append(price)
return tasks
async def main_better():
"""✅ 用 gather 真正并发"""
symbols = ["AAPL", "GOOGL", "MSFT", "AMZN", "META",
"TSLA", "NVDA", "BABA", "JD", "PDD"]
coros = [fetch_price(sym) for sym in symbols]
results = await asyncio.gather(*coros)
return results
start = time.time()
# asyncio.run(main_naive()) # 耗时 ≈ 所有请求时间之和
asyncio.run(main_better()) # 耗时 ≈ 最慢那一个请求
print(f"耗时: {time.time() - start:.1f}s")
上面这段我一开始就写错了。在 for 循环里直接 await,相当于亲手把并发的翅膀折了——你等着一个协程跑完才创建下一个,事件循环全程在围观。正确姿势是先把协程对象攒成列表,一股脑交给 gather,事件循环才会同时调度它们。
但这就够了吗?太天真了。
真正的坑:并发是把双刃剑,不加束缚直接崩
当我把 gather 加上的那一刻,控制台刷得飞快,5 秒后直接炸了:
aiohttp.client_exceptions.ClientOSError: [Errno 24] Too many open files
100 个请求几乎同时发出去,操作系统默认的文件描述符上限根本扛不住,而且对方服务器也毫不客气地回敬了一波 429 Too Many Requests。我这才意识到——并发不等于无限并行,你得给它套上缰绳。
解决方案是 asyncio.Semaphore,一个协程版本的信号量:
import asyncio
import aiohttp
MAX_CONCURRENT = 5 # 同时最多 5 个请求
semaphore = asyncio.Semaphore(MAX_CONCURRENT)
async def fetch_with_limit(session, url):
"""用信号量限制并发数,并做好异常重试"""
async with semaphore: # 超过上限的协程会在这里等待
try:
async with session.get(url, timeout=10) as resp:
resp.raise_for_status()
return await resp.json()
except asyncio.TimeoutError:
print(f"[超时] {url}")
except aiohttp.ClientError as e:
print(f"[请求错误] {url}: {e}")
return None
async def main_controlled():
urls = [f"https://api.example.com/price/{sym}" for sym in SYMBOLS]
async with aiohttp.ClientSession() as session:
tasks = [fetch_with_limit(session, u) for u in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 过滤掉失败的请求
return [r for r in results if r is not None and not isinstance(r, Exception)]
这里有两个关键点:
async with semaphore保证同一时刻最多只有MAX_CONCURRENT个协程在执行请求,其余全部排队等待。这既保护了服务器也保护了自己,连接数不会再炸。return_exceptions=True告诉gather:就算个别协程抛异常,也别把整个批次都给扬了,把异常对象放进结果列表,我们后面自己处理。不传这个参数的话,一个协程报错,整个gather立刻抛出异常,其他还在跑的协程大概率变成僵尸任务。
TaskGroup:gather 的优雅接班人
很多老教程还在推 gather,实际上 Python 3.11 引入的 asyncio.TaskGroup 才是更安全的选择。差异在于:当某个任务失败时,TaskGroup 会自动取消同组内其他尚未完成的任务,防止资源泄漏。这对那种“一个任务挂了,继续跑别的也没意义”的场景尤其有用(比如先拿 token 再查详情)。
async def main_with_taskgroup():
urls = [...]
async with aiohttp.ClientSession() as session:
async with asyncio.TaskGroup() as tg:
tasks = [
tg.create_task(fetch_with_limit(session, u))
for u in urls
]
# 上下文退出时所有任务都已完成(或报错)
# 提取结果
return [t.result() for t in tasks if not t.exception()]
习惯 TaskGroup 之后,我基本告别 gather 了。代码更清晰,异常行为也更符合直觉。
踩坑心得:这 4 个问题,你可能也会遇到
1. 不要在协程里塞同步阻塞代码。
比如你在 async def 里调了 time.sleep() 或同步的 requests.get(),事件循环会被你钉死,所有并发直接废掉。遇到不得不用的同步库,用 asyncio.to_thread() 把它丢到线程池里去跑。
2. 连接器参数千万不要用默认值。
aiohttp.ClientSession 默认连接池上限是 100,但如果你并发数超过这个值,多余的连接会排队。当我用信号量限制为 5 并发时没问题,但如果你需要更高并发,记得调整 connector=aiohttp.TCPConnector(limit=50)。
3. 不要盲目信任 asyncio.create_task 创建的“后台任务”。
如果忘掉 await 它,任务可能在你主流程结束后被事件循环直接杀掉。凡是 create_task 出去的火种,最终都要有人把它收回来。
4. 调试时用 PYTHONASYNCIODEBUG=1 开启调试模式。
能帮你揪出“协程未被 await”“事件循环关闭时还有任务在跑”这类隐藏问题。早点开,早下班。
一句话总结
asyncio 是银弹,但不瞄准就开枪,打中的只会是你的脚背——用好信号量、管好异常、拥抱 TaskGroup,才算真正入门。
#Python #asyncio #并发编程 #爬虫实战 #性能优化