上个月我被产品经理堵在茶水间,劈头就是一句:“数据看板又超时,能不能别让老板天天刷新等着?”当时我们那套指标同步脚本跑一次要 11 分钟,200 个三方 API 一个个串行请求,日志里全是一条条“等待响应”的记录。我没敢多解释,回工位打开编辑器,心里只剩下一个念头:这玩意必须异步。
一周后重构上线,同样是 200 个接口,稳定跑进 14 秒。监控告警直接弹窗——运维以为上游被 DDoS。这篇文章就来讲讲那次重构里,asyncio 到底怎么用、怎么坑、以及如何体面地填坑。
事件循环怎么就把时间“偷”走了
asyncio 的核心不是什么高深魔法,就是一个单线程里的事件循环。你可以把它想象成一个只干一件事的超级调度员:当 Task A 发出 HTTP 请求后进入等待,调度员立刻把它挂起,转身去执行 Task B,等 A 的网络字节到了再切回来。这个过程里没有线程切换开销,也没有 callback hell,所有逻辑都写在 async/await 里。
import asyncio
import aiohttp
import time
# 模拟一次 API 调用
async def fetch_api(session, url: str) -> dict:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
return await resp.json()
一个协程函数定义时只是“蓝图”,真正让它跑起来必须创建任务交给事件循环。最常见的并发启动姿势是 asyncio.gather,一行就能同时点火多个协程,总耗时不再累加,而是看最慢的那个。
async def main():
urls = [f"https://api.example.com/data/{i}" for i in range(200)]
async with aiohttp.ClientSession() as session:
start = time.time()
# 同时发出 200 个请求,总耗时 ≈ 最慢的一个
tasks = [fetch_api(session, url) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
elapsed = time.time() - start
print(f"完成 {len(urls)} 个请求,耗时 {elapsed:.2f}s")
同步版本 200 个请求按序执行是串行累加;换成上面这段,同一时刻所有连接都进入等待状态,事件循环只花一次最坏情况的 IO 时间。这就是 asyncio 对我业务最直接的改变——从“排队等餐”变成了“同时端上来”。
控制并发与超时,避免“好心办坏事”
一开始我偷懒用了 gather 一次性全发,结果直接把对方网关的 rate limit 打穿,收到一堆 429。后来引入 asyncio.Semaphore,精确控制同时最多 20 个并发请求,配合超时和重试,整个管线才算真正生产可用。
import asyncio
import aiohttp
from asyncio import Semaphore
CONCURRENCY = 20
MAX_RETRIES = 2
async def fetch_with_limit(sem, session, url):
async with sem: # 超过 20 个协程会在这一行排队
for attempt in range(MAX_RETRIES + 1):
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
resp.raise_for_status()
return await resp.json()
except Exception as e:
if attempt == MAX_RETRIES:
return {"error": str(e), "url": url}
await asyncio.sleep(2 ** attempt) # 指数退避
async def main_controlled():
urls = [...]
sem = Semaphore(CONCURRENCY)
async with aiohttp.ClientSession() as session:
tasks = [fetch_with_limit(sem, session, u) for u in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
Semaphore 就像一个门禁,每次只放 20 个协程进去干活,其余的在外面 await sem 排队。加上超时、状态码检查和后退重试,既不会炸掉下游,也能容忍临时抖动。
踩坑实录:三个小时只为找一条漏网之鱼
1. 忘了 await,协程变成“幽灵代码”
有一次我发现管线的网络请求根本没发出,日志却显示“执行成功”。查了半小时才发现写了 fetch_data(url) 而不是 await fetch_data(url)。不 await 的协程就只是个生成器对象,事件循环压根不管它。Python 3.8+ 虽然会报 RuntimeWarning,但在大段逻辑里容易被忽略。 解决方法是配上 python -W error::RuntimeWarning 把警告变成异常,或者使用 asyncio.Task 显式创建任务。
2. 在协程里混用同步阻塞库
一开始我复用旧代码,在 async def 里直接调了 requests.get。结果事件循环被阻塞住,所有并发退化成串行。记住一条铁律:async 函数里不能用任何会阻塞线程的同步操作,要么换成对应的 aio 库(aiohttp, aiofiles),要么用 loop.run_in_executor 把阻塞调用丢进线程池。
3. 忘记设置超时,协程永远挂起
上面代码里 ClientTimeout(total=5) 不是可有可无的装饰。线上曾出现过个别接口永远不返回,导致整个 gather 卡死。任何 IO 操作必须设置超时上限,否则生产环境就是定时炸弹。
4. Windows 上的事件循环细节
同样是 asyncio,在 Windows 下 ProactorEventLoop 与 Unix 的 SelectorEventLoop 行为不完全一致。如果你用 asyncio.subprocess 做事情,跨平台测试前最好固定 asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()),避免奇怪的 hang。
一句话点亮 asyncio
用 asyncio 不是为了炫技,而是用单线程的事件调度榨干 IO 等待的时间碎片,让你的 Python 程序在数据管线场景里真正快起来。
#Python #asyncio #异步编程 #性能优化 #后端