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

上个月帮朋友看一个 FastAPI 项目。接口本身不复杂,一个查询接口,接收几个参数,去数据库里捞数据,返回 JSON。单机压测的时候 QPS 能到两千多,他觉得没问题。

上线之后,流量稍微大了一点,问题来了:所有接口的响应时间集体飙升。不只是这个查询接口,连一个只返回 {"status": "ok"} 的健康检查接口,响应时间都从几毫秒涨到了几百毫秒。整个服务像是被什么东西卡住了,但 CPU 和内存都很正常。

他把代码发给我,路由函数写的是 async def:

sql 复制代码
@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 的异步客户端:

csharp 复制代码
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:

csharp 复制代码
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 函数的处理其实很聪明。

当你写:

sql 复制代码
@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 里手动跑一个事件循环来执行异步代码?比如:

kotlin 复制代码
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:

python 复制代码
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,会在线程池里跑。规则和路由函数一致。

csharp 复制代码
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,要么把同步操作包起来。这一秒钟的检查,能省下后面排查性能问题时几个小时的困惑。

相关推荐
沙蒿同学41 分钟前
Wails v2 实战:用 Go + Vue3 做一个真正能用的 AI 桌面应用
前端·javascript·后端
货拉拉技术41 分钟前
大模型在货拉拉营销广告的应用实践
后端
怕浪猫41 分钟前
AI 知识库 WeKnora(腾讯微信团队出品)
后端·面试·github
Gopher_HBo42 分钟前
zap日志 整体架构与数据流
后端
颜进强42 分钟前
09 · NestJS Middleware 中间件:链路最外层那个"最像 Express"的家伙
前端·后端·ai编程
创新技术阁42 分钟前
FastapiAdmin 实战:演示模式开关失效的排查记录
前端·后端·fastapi
归鹭42 分钟前
spring外部化配置
后端
拖孩42 分钟前
一个人 + AI 做的小程序,一个半月把服务器钱赚回来一半了
前端·后端·微信小程序
京东云开发者43 分钟前
从零构建一个生产级记忆型 AI Agent —— AgentScope 项目全景技术与学习指南
后端·架构·ai编程