首页 文章 精选 留言 我的

精选列表

搜索[Python],共10000篇文章
优秀的个人博客,低调大师

Python的FastAPI把我坑惨了,原来async def和def的区别这么大

上个月帮朋友看一个 FastAPI 项目。接口本身不复杂,一个查询接口,接收几个参数,去数据库里捞数据,返回 JSON。单机压测的时候 QPS 能到两千多,他觉得没问题。 上线之后,流量稍微大了一点,问题来了:所有接口的响应时间集体飙升。不只是这个查询接口,连一个只返回 `{"status": "ok"}` 的健康检查接口,响应时间都从几毫秒涨到了几百毫秒。整个服务像是被什么东西卡住了,但 CPU 和内存都很正常。 他把代码发给我,路由函数写的是 `async def`: ``` @app.get("/users/{user_id}") async def get_user(user_id: int): user = db.query(User).filter(User.id == user_id).first() orders = db.query(Order).filter(Order.user_id == user_id).all() return {"user": user, "orders": orders} ``` 我问了一句:“你这个 `db` 是同步的还是异步的?” 他说:“同步的,SQLAlchemy 的普通 Session。” 问题就在这里。`async def` 里跑了同步的数据库查询。 FastAPI 看到 `async def`,会直接在事件循环里调用这个函数。事件循环是单线程的。同步数据库查询会阻塞这个线程,整个事件循环停摆。所有其他请求——包括那个只返回 ok 的健康检查——都在排队等着。一个查询卡 200 毫秒,这一百个请求就要等 20 秒。 如果他把 `async def` 改成 `def`,FastAPI 会把函数丢到线程池里执行,事件循环不会被阻塞,其他请求照常处理。响应时间的问题立刻缓解。 这就是 `async def` 和 `def` 在 FastAPI 里最核心的区别:**FastAPI 对两者的调度方式完全不同**。选错了,不是慢一点的问题,是整个服务被拖垮。 ## **FastAPI 怎么处理这两种函数** 要理解这个区别,得先知道 FastAPI 底层是怎么跑起来的。 FastAPI 是一个 ASGI 框架,跑在 uvicorn 这类 ASGI 服务器上。uvicorn 启动时会创建一个事件循环,所有请求都由这个事件循环来调度。 当一个请求进来,FastAPI 会看你的路由函数是 `async def` 还是普通 `def`: **如果是 `async def`**,FastAPI 直接 `await` 它。也就是说,这个函数在事件循环的线程里执行。它里面每一行代码,都跑在事件循环上。 **如果是普通 `def`**,FastAPI 不会直接调用它,而是用 `run_in_threadpool` 把它丢到一个线程池里执行。事件循环只负责等待线程池返回结果,自己不被占用。 这个差异带来的后果是: - `async def` 里如果有阻塞操作,事件循环被卡住,所有请求都受影响。 - `def` 里如果有阻塞操作,只卡住线程池里的一个线程,事件循环继续处理其他请求。 所以问题不在于 `async def` 和 `def` 哪个“更好”,而在于**函数内部用的是什么类型的库**。 ## **阻塞和非阻塞,才是真正的分界线** `async def` 的设计初衷,是配合异步库使用的。异步库的特点是:发起 I/O 操作后不等待,把控制权交还给事件循环,等结果准备好了再回来继续。 比如 `httpx` 的异步客户端: ``` import httpx @app.get("/proxy") async def proxy(): async with httpx.AsyncClient() as client: resp = await client.get("https://example.com/api") return resp.json() ``` `await client.get(...)` 执行的时候,事件循环可以去处理别的请求。等 HTTP 响应回来了,再继续执行下一行。这段时间里,事件循环是自由的。 但如果你用的是同步的 `requests`: ``` import requests @app.get("/proxy") async def proxy(): resp = requests.get("https://example.com/api") # 阻塞! return resp.json() ``` `requests.get` 会一直占着事件循环的线程,直到 HTTP 响应回来。这期间事件循环什么也做不了。所有其他请求都在排队。 同样的逻辑适用于数据库:`asyncpg`、`aiomysql`、`motor` 这些是异步驱动,配合 `async def` 没问题。`psycopg2`、`pymysql`、`pymongo` 这些是同步驱动,放在 `async def` 里就是灾难。 文件操作也一样。普通的 `open()` 和 `read()` 是阻塞的,`aiofiles` 是异步的。`time.sleep()` 是阻塞的,`asyncio.sleep()` 是异步的。 所以判断标准很简单:**函数内部用的库是不是异步的?** 是,就用 `async def`;不是,就用 `def`。 ## **用 def 的时候,FastAPI 帮你兜底** 很多人不知道的是,FastAPI 对普通 `def` 函数的处理其实很聪明。 当你写: ``` @app.get("/users/{user_id}") def get_user(user_id: int): user = db.query(User).filter(User.id == user_id).first() return {"user": user} ``` FastAPI 内部会把这个函数包装一下,通过 `run_in_threadpool` 提交到线程池。线程池里的线程执行这个同步函数,事件循环在外面等着。函数执行期间,事件循环可以自由处理其他请求。 这就是为什么把 `async def` 改成 `def` 之后,健康检查接口的响应时间立刻恢复了——事件循环不再被数据库查询卡住了。 线程池的默认大小是 40(具体取决于 anyio 的版本和配置)。也就是说,FastAPI 默认可以同时处理 40 个同步的 `def` 请求。超过 40 个,新的请求会排队等待空闲线程。 对于大多数中小规模的应用,40 个并发足够了。如果不够,可以通过环境变量或者 anyio 的配置调大线程池。但线程池不是越大越好,线程切换有开销,而且数据库连接池通常也有上限。线程池太大,反而会把压力转移到数据库连接上。 ## **async def 里做同步操作,为什么后果这么严重** 事件循环是单线程的。这意味着,同一时刻只有一个任务在事件循环上运行。 当你在 `async def` 里执行一个阻塞操作,比如 `db.query(...)`,事件循环的线程被这个操作占住。它没法去执行其他已经就绪的任务,也没法去接收新的请求。 假设这个阻塞操作耗时 100 毫秒。在这 100 毫秒里: - 所有新进来的请求都卡在 ASGI 服务器的接收队列里,没有被处理。 - 所有已经 `await` 了某个异步操作、等着被唤醒的任务,也得不到执行。 - 定时器、后台任务、WebSocket 心跳,全部暂停。 一个请求阻塞 100 毫秒,看起来不多。但如果 QPS 是 100,每个请求都阻塞 100 毫秒,事件循环就完全被占满了,每秒只能处理 10 个请求。其余 90 个请求全部排队,响应时间线性增长。 这就是我朋友遇到的情况。他的查询接口本身不慢,数据库有索引,单次查询 20 毫秒左右。但 QPS 一上来,事件循环被这些同步查询轮流占住,所有请求都在互相等待,整体响应时间就崩了。 更隐蔽的是,这种问题在低流量下完全看不出来。单机测试、开发环境、小规模内测,请求量小,事件循环被短暂占用一下也无所谓。一旦上线,流量涨起来,问题才暴露。而且排查的时候,CPU 不高、内存不高、数据库也不慢,很难想到是事件循环被阻塞了。 ## **反过来:def 里能不能用异步库** 有人会想,那我在 `def` 里用异步库行不行? 不行。异步函数必须被 `await`,而 `await` 只能在 `async def` 里使用。你在普通 `def` 里写 `await`,语法直接报错。 那能不能在 `def` 里手动跑一个事件循环来执行异步代码?比如: ``` import asyncio @app.get("/bad") def bad(): result = asyncio.run(some_async_func()) return result ``` 技术上能跑,但这是非常糟糕的做法。`asyncio.run` 会创建一个新的事件循环,在线程池的线程里执行。如果这个异步函数内部又依赖 FastAPI 主事件循环的资源,就会出问题。而且每个请求都创建一个新的事件循环,开销很大,完全失去了异步的意义。 所以规则是单向的:异步库只能配 `async def`,同步库只能配 `def`。不要混着来。 ## **一个函数里既有同步又有异步怎么办** 真实项目里经常遇到这种情况:大部分操作是异步的,但某一步只能用同步库。比如用了异步的 HTTP 客户端,但数据库驱动是同步的。 这时候有几个选择: **方案一:把整个函数改成 `def`,所有操作都用同步库。** 简单直接,让 FastAPI 用线程池兜底。代价是失去了异步并发的优势,但至少不会阻塞事件循环。 **方案二:把同步操作放到线程池里执行。** FastAPI 提供了 `run_in_threadpool`: ``` from fastapi.concurrency import run_in_threadpool @app.get("/mixed") async def mixed(): async_result = await some_async_call() sync_result = await run_in_threadpool(blocking_db_query, arg1, arg2) return {"async": async_result, "sync": sync_result} ``` `run_in_threadpool` 会把同步函数提交到线程池,事件循环继续处理其他事情。等线程池执行完,`await` 拿到结果,继续往下走。这样既保留了异步的并发能力,又不阻塞事件循环。 **方案三:换成异步驱动。** 如果条件允许,把数据库驱动换成异步版本,所有操作统一用 `async def`。这是最彻底的方案,但迁移成本可能比较高,尤其是当项目里已经有大量基于同步驱动的代码时。 选择哪个方案,取决于项目现状和迁移成本。但无论选哪个,都不能把同步操作直接放在 `async def` 里不管。 ## **路由之外的 async def 和 def** `async def` 和 `def` 的区别不只在路由函数上。FastAPI 的依赖注入系统、中间件、后台任务,都会遇到同样的问题。 **依赖函数**如果是 `async def`,FastAPI 会 `await` 它;如果是 `def`,会在线程池里跑。规则和路由函数一致。 ``` def get_db(): db = SessionLocal() try: yield db finally: db.close() ``` 这是一个常见的同步依赖,用 `def` 定义,FastAPI 会在线程池里执行它。如果把它改成 `async def`,但内部还是同步的数据库操作,同样会阻塞事件循环。 **中间件**如果是 `async def`,会在事件循环里执行。中间件里如果有阻塞操作,影响的是所有请求。所以中间件通常应该用 `async def`,并且只做轻量的异步操作。 **后台任务**如果是 `async def`,会被 `await`;如果是 `def`,会在事件循环的线程池里执行。`BackgroundTasks` 的行为和路由函数类似。 ## **怎么判断该用哪个** 有一个简单的判断流程: 第一,看你用的库。数据库驱动、HTTP 客户端、文件操作、缓存客户端——只要有一个是同步的,就优先考虑用 `def`。 第二,如果所有库都是异步的,用 `async def`。这样能最大化并发能力,一个事件循环可以同时处理成百上千个请求。 第三,如果混合使用,用 `async def` 加 `run_in_threadpool` 把同步操作隔离出去。 第四,不确定的时候,先写 `def`。FastAPI 会帮你用线程池兜底,至少不会阻塞事件循环。等确认所有依赖都是异步的,再改成 `async def`。 有一个常见的误解:`async def` 一定比 `def` 快。不是的。对于单个请求,`async def` 和 `def` 的执行速度差不多,甚至 `def` 因为要经过线程池调度,可能还稍微慢一点。`async def` 的优势在高并发场景下才体现出来——事件循环可以用极少的线程处理大量并发连接。 但如果 `async def` 里全是阻塞操作,这个优势不仅不存在,还会变成劣势。因为事件循环被阻塞,并发能力反而比线程池更差。 ## **一个真实场景** 我之前见过一个项目,所有路由都是 `async def`,但内部用的是 `requests`、`psycopg2`、`redis-py` 这些同步库。开发者觉得“FastAPI 是异步框架,所以路由也应该写成 async”。 上线之后,QPS 一过 50 就开始排队。他们以为是数据库慢了,加了索引、加了缓存,效果都不明显。后来把路由改成 `def`,QPS 直接翻了三倍。 原因很简单:改成 `def` 之后,这些同步操作在线程池里执行,事件循环不再被阻塞。虽然线程池只有 40 个线程,但至少事件循环可以自由调度,请求不会互相卡死。 后来他们逐步把数据库驱动换成了 `asyncpg`,HTTP 客户端换成了 `httpx`,缓存客户端换成了 `aioredis`,再把路由改回 `async def`。QPS 又上了一个台阶,而且线程池的 40 个线程限制也不存在了——事件循环可以用极少的线程处理大量并发。 这才是 `async def` 正确的打开方式:**配套的库全是异步的,函数里没有阻塞调用**。 ## **最后** `async def` 和 `def` 的区别,本质上是**在事件循环里执行**和**在线程池里执行**的区别。 FastAPI 对两者的调度方式完全不同:`async def` 直接在事件循环里跑,`def` 丢到线程池。`async def` 里如果有阻塞操作,整个事件循环停摆;`def` 里如果有阻塞操作,只影响一个线程。 选择哪个,不取决于个人偏好,而取决于函数内部用的库。异步库配 `async def`,同步库配 `def`。混用不是不行,但要知道后果,并且用 `run_in_threadpool` 把阻塞操作隔离出去。 写 FastAPI 的时候,每次敲下 `async def`,先问自己一句:这个函数里有没有同步的 I/O 操作?有的话,要么改成 `def`,要么把同步操作包起来。这一秒钟的检查,能省下后面排查性能问题时几个小时的困惑。

资源下载

更多资源
Mario

Mario

马里奥是站在游戏界顶峰的超人气多面角色。马里奥靠吃蘑菇成长,特征是大鼻子、头戴帽子、身穿背带裤,还留着胡子。与他的双胞胎兄弟路易基一起,长年担任任天堂的招牌角色。

腾讯云软件源

腾讯云软件源

为解决软件依赖安装时官方源访问速度慢的问题,腾讯云为一些软件搭建了缓存服务。您可以通过使用腾讯云软件源站来提升依赖包的安装速度。为了方便用户自由搭建服务架构,目前腾讯云软件源站支持公网访问和内网访问。

Nacos

Nacos

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service 的首字母简称,一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台。Nacos 致力于帮助您发现、配置和管理微服务及AI智能体应用。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据、流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。

WebStorm

WebStorm

WebStorm 是jetbrains公司旗下一款JavaScript 开发工具。目前已经被广大中国JS开发者誉为“Web前端开发神器”、“最强大的HTML5编辑器”、“最智能的JavaScript IDE”等。与IntelliJ IDEA同源,继承了IntelliJ IDEA强大的JS部分的功能。

用户登录
用户注册