上周老板让我把公司一个老爬虫项目提速,我心想这不简单,直接上 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 或被放进事件循环任务(如 gather、create_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:避免一颗老鼠屎坏了一锅粥。- 协程必须
await或create_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 #爬虫 #并发编程 #后端