asyncio 踩坑实录:这个问题坑了我3小时,差点让线上服务崩掉

事情是这样的:上周领导让我优化一个数据聚合服务,这个服务需要调用 20 个下游 API,串行跑一次要 18 秒,用户等得想砸键盘。我一看,这明显是 IO 密集型任务,果断上 asyncio,想着半天搞定,结果从下午两点踩坑踩到五点,线上还差点挂掉。这篇文章就复盘一下我踩过的三个大坑,以及到底怎么写出真正能用的异步代码。

核心概念先掰扯清楚

asyncio 的核心是单线程事件循环(Event Loop) ,它就像一个时间管理大师,把所有协程排好队,谁在等 IO 就让谁去旁边蹲着,先执行那些 ready 的任务。关键语法就两个:async def 定义协程函数,await 交出控制权,告诉事件循环"这里要等一会,你先去干别的"。

但很多教程只给你看这种理想代码:

python 复制代码
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() 扔进线程池:

python 复制代码
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 不回收。最后发现是我为了"灵活控制并发"手写了这样的代码:

python 复制代码
tasks = []
for url in urls:
    task = asyncio.create_task(process(url))
    tasks.append(task)
for t in tasks:
    await t

乍看没问题,但 process(url) 内部有些分支会提前 return,还有些异常没处理好,导致 Task 处于 PENDINGCANCELLED 状态却仍被 tasks 列表引用,而 Task 内部又持有了大段请求数据,GC 链条断不掉。

修法 :使用 asyncio.TaskGroup(Python 3.11+)自动管理生命周期,任何一个 task 挂掉都会通知其他 task 取消,结构清爽,无泄漏:

python 复制代码
async def main():
    async with asyncio.TaskGroup() as tg:
        for url in urls:
            tg.create_task(process(url))

如果用低版本 Python,就老老实实在 finally 里取消所有未完成的 task,并清空引用。

完整的正确姿势代码

下面这段代码是我重构后的核心骨架,直接可用,包含了信号量控并发、aiohttp 会话复用、异常隔离和超时控制:

python 复制代码
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 秒,老板看到监控曲线后真的发了个大拇哥表情。

几个刻进肌肉记忆的准则

踩完这些坑之后我总结了三条铁律,现在写异步代码时就像惯性:

  1. 从入口到落地全部异步化 :从 Web 框架、HTTP 客户端到数据库驱动,全部用 async 版本。async 函数里出现 requests 或同步 sleep 就是定时炸弹。
  2. 并发控制必须上牙套 :用 Semaphore 限制并发数,用 TaskGroupasyncio.wait(return_when=...) 管理生命周期,绝不手动裸管 Task 列表。
  3. 超时和异常隔离:每个协程内部独立捕获异常,default 情况返回降级对象,绝不直接让异常传播到事件循环,同时所有 IO 显式设置超时,防止长尾请求拖垮整个调度。

总结

asyncio 这把双刃剑,用对是神器,用错是事故发生器------关键就是彻底异步化,绝不妥协半个同步阻塞。

#Python #异步编程 #asyncio #性能优化 #踩坑实录

相关推荐
喂哟咦1 小时前
关于用codex两周写了一个epub阅读器这件事
前端·javascript
CDwenhuohuo2 小时前
前端文件预览
开发语言·前端·javascript
test_00012 小时前
JavaScript展开运算符的三个妙用
前端
前端尤雨西2 小时前
ElementPlus 源码之 packages 目录
前端·element
我要让全世界知道我很低调2 小时前
扔掉你的 Playwright MCP,拥抱 Playwright CLI
前端
Daybreak2 小时前
从 npm 到 pnpm:包管理器演进与 Monorepo 依赖冲突求生
前端
Restart-AHTCM2 小时前
AI 时代的大前端崛起,TypeScript 重塑前端开发
前端·人工智能·typescript·ai编程·a
008爬虫实战录2 小时前
【最新猿人学】 验证码 - 图文点选 文字验证码识别
前端·javascript
一叶飘零晋2 小时前
【(一)Electron 使用之如何用vite+vue3搭建初始框架】
前端·javascript·electron