事情是这样的:上周领导让我优化一个数据聚合服务,这个服务需要调用 20 个下游 API,串行跑一次要 18 秒,用户等得想砸键盘。我一看,这明显是 IO 密集型任务,果断上 asyncio,想着半天搞定,结果从下午两点踩坑踩到五点,线上还差点挂掉。这篇文章就复盘一下我踩过的三个大坑,以及到底怎么写出真正能用的异步代码。
核心概念先掰扯清楚
asyncio 的核心是单线程事件循环(Event Loop),它就像一个时间管理大师,把所有协程排好队,谁在等 IO 就让谁去旁边蹲着,先执行那些 ready 的任务。关键语法就两个:async def 定义协程函数,await 交出控制权,告诉事件循环“这里要等一会,你先去干别的”。
但很多教程只给你看这种理想代码:
import asyncio
async def fetch(url):
await asyncio.sleep(1) # 模拟网络IO
return f"data from {url}"
async def main():
tasks = [fetch(f"api/{i}") for i in range(5)]
results = await asyncio.gather(*tasks) # 并发执行
print(results)
asyncio.run(main())
简单优雅,5个请求并行只要 1 秒。但一旦往真实项目里套,问题就来了。
坑一:在同步函数里乱 await——直接报错
我最开始直接在现有的 Flask 路由函数里加 await fetch(),结果抛了个 SyntaxError: 'await' outside async function。好,那就把路由函数改成 async def,心想这下成了。结果请求一进来,报 RuntimeError: There is no current event loop in thread 'Thread-1'。
原因:Flask 默认用线程池处理请求,每个线程没有自己的事件循环,asyncio.run() 又不能在已有循环的线程里随便用。我在视图里又调用 asyncio.run(main()),直接触发“事件循环已经运行”的连环报错。
正确做法:要么用支持异步的 Quart 或 FastAPI;如果必须用 Flask,就在应用启动时创建全局事件循环,用 loop.run_until_complete() 调度;或者更简单的,启动一个 asyncio 后台线程,通过队列与 Web 线程通信。
坑二:在协程里塞了同步阻塞调用——性能反降
我灵机一动,用 asyncio.gather(*[call_api_blocking(i) for i in range(20)]),结果发现总耗时还是接近 18 秒。打日志一看,每个 task 都是顺序执行完才跳到下一个。定位半天:call_api_blocking 里用的是 requests.get(),这东西是同步阻塞的,await 根本没用,因为事件循环等了第一个 requests.get 时,线程整个卡死了,别的协程根本没法调度。
asyncio 只认它自家的异步 IO 原语。遇到同步阻塞调用,必须用 loop.run_in_executor() 扔进线程池:
async def call_api_async(url):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, requests.get, url)
这样网络阻塞发生在独立线程,事件循环立刻切换到其他协程。后来我又把 requests 彻底换成了 aiohttp,性能才真正起飞。一句话记住:异步要全家桶,不能混搭流氓式阻塞。
坑三:Task 没被及时回收——内存慢慢涨
性能上来后我信心满满上线,结果运行两天后 Pod 被 OOMKilled。监控发现内存缓慢上涨,GC 不回收。最后发现是我为了“灵活控制并发”手写了这样的代码:
tasks = []
for url in urls:
task = asyncio.create_task(process(url))
tasks.append(task)
for t in tasks:
await t
乍看没问题,但 process(url) 内部有些分支会提前 return,还有些异常没处理好,导致 Task 处于 PENDING 或 CANCELLED 状态却仍被 tasks 列表引用,而 Task 内部又持有了大段请求数据,GC 链条断不掉。
修法:使用 asyncio.TaskGroup(Python 3.11+)自动管理生命周期,任何一个 task 挂掉都会通知其他 task 取消,结构清爽,无泄漏:
async def main():
async with asyncio.TaskGroup() as tg:
for url in urls:
tg.create_task(process(url))
如果用低版本 Python,就老老实实在 finally 里取消所有未完成的 task,并清空引用。
完整的正确姿势代码
下面这段代码是我重构后的核心骨架,直接可用,包含了信号量控并发、aiohttp 会话复用、异常隔离和超时控制:
import asyncio
import aiohttp
import time
from typing import List
class AsyncFetcher:
def __init__(self, concurrency: int = 10, timeout: int = 10):
self.sem = asyncio.Semaphore(concurrency) # 限制并发防止打爆下游
self.timeout = aiohttp.ClientTimeout(total=timeout)
async def fetch_one(self, session: aiohttp.ClientSession, url: str) -> dict:
async with self.sem:
try:
async with session.get(url, timeout=self.timeout) as resp:
data = await resp.json()
return {"url": url, "status": resp.status, "data": data}
except Exception as e:
return {"url": url, "error": str(e)}
async def fetch_all(self, urls: List[str]) -> List[dict]:
async with aiohttp.ClientSession() as session:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(self.fetch_one(session, url)) for url in urls]
# TaskGroup 退出时所有 task 已自动完成
return [t.result() for t in tasks]
if __name__ == "__main__":
urls = [f"https://httpbin.org/delay/1?id={i}" for i in range(20)]
fetcher = AsyncFetcher(concurrency=20)
start = time.time()
results = asyncio.run(fetcher.fetch_all(urls))
print(f"完成 {len(results)} 个请求,耗时 {time.time() - start:.2f}s")
这段代码直接把 18 秒压到了 1.8 秒,老板看到监控曲线后真的发了个大拇哥表情。
几个刻进肌肉记忆的准则
踩完这些坑之后我总结了三条铁律,现在写异步代码时就像惯性:
- 从入口到落地全部异步化:从 Web 框架、HTTP 客户端到数据库驱动,全部用 async 版本。async 函数里出现
requests或同步sleep就是定时炸弹。 - 并发控制必须上牙套:用
Semaphore限制并发数,用TaskGroup或asyncio.wait(return_when=...)管理生命周期,绝不手动裸管 Task 列表。 - 超时和异常隔离:每个协程内部独立捕获异常,default 情况返回降级对象,绝不直接让异常传播到事件循环,同时所有 IO 显式设置超时,防止长尾请求拖垮整个调度。
总结
asyncio 这把双刃剑,用对是神器,用错是事故发生器——关键就是彻底异步化,绝不妥协半个同步阻塞。
#Python #异步编程 #asyncio #性能优化 #踩坑实录