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

文章来源声明: 原文作者:站大爷IP; 来源站点:掘金; 原文链接:https://juejin.cn/post/7688290600175534116; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

这篇把 FastAPI 异步路由的常见坑讲透了,适合正在用 FastAPI 且遇到高并发响应变慢的开发者,按库类型选 async def 或 def 即可避坑。

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

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

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

<span>@app</span>.<span>get</span>("/users/{user_id}")
async def get_user(user_id: <span>int</span>):
    <span>user</span> <span>=</span> db.query(<span>User</span>).<span>filter</span>(User.id <span>=</span><span>=</span> user_id).<span>first</span>()
    orders <span>=</span> db.query(<span>Order</span>).<span>filter</span>(Order.user_id <span>=</span><span>=</span> user_id).<span>all</span>()
    <span>return</span> {"user": <span>user</span>, "orders": orders}

我问了一句:“你这个 db 是同步的还是异步的?”

他说:“同步的,SQLAlchemy 的普通 Session。”

问题就在这里。async def 里跑了同步的数据库查询。

FastAPI 看到 async def,会直接在事件循环里调用这个函数。事件循环是单线程的。同步数据库查询会阻塞这个线程,整个事件循环停摆。所有其他请求——包括那个只返回 ok 的健康检查——都在排队等着。一个查询卡 200 毫秒,这一百个请求就要等 20 秒。

如果他把 async def 改成 def,FastAPI 会把函数丢到线程池里执行,事件循环不会被阻塞,其他请求照常处理。响应时间的问题立刻缓解。

这就是 async defdef 在 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 defdef 哪个“更好”,而在于函数内部用的是什么类型的库

阻塞和非阻塞,才是真正的分界线

async def 的设计初衷,是配合异步库使用的。异步库的特点是:发起 I/O 操作后不等待,把控制权交还给事件循环,等结果准备好了再回来继续。

比如 httpx 的异步客户端:

import httpx

@app.<span>get</span>(<span>"/proxy"</span>)
<span><span>async</span> def <span>proxy</span>():
    <span>async</span> <span>with</span> httpx.<span>AsyncClient</span>() <span>as</span> client:
        resp</span> = <span>await</span> client.<span>get</span>(<span>"https://example.com/api"</span>)
        <span>return</span> resp.json()

await client.get(...) 执行的时候,事件循环可以去处理别的请求。等 HTTP 响应回来了,再继续执行下一行。这段时间里,事件循环是自由的。

但如果你用的是同步的 requests

import requests

@app.<span>get</span>(<span>"/proxy"</span>)
<span><span>async</span> def <span>proxy</span>():
    resp</span> = requests.<span>get</span>(<span>"https://example.com/api"</span>)  <span># 阻塞!</span>
    <span>return</span> resp.json()

requests.get 会一直占着事件循环的线程,直到 HTTP 响应回来。这期间事件循环什么也做不了。所有其他请求都在排队。

同样的逻辑适用于数据库:asyncpgaiomysqlmotor 这些是异步驱动,配合 async def 没问题。psycopg2pymysqlpymongo 这些是同步驱动,放在 async def 里就是灾难。

文件操作也一样。普通的 open()read() 是阻塞的,aiofiles 是异步的。time.sleep() 是阻塞的,asyncio.sleep() 是异步的。

所以判断标准很简单:函数内部用的库是不是异步的? 是,就用 async def;不是,就用 def

用 def 的时候,FastAPI 帮你兜底

很多人不知道的是,FastAPI 对普通 def 函数的处理其实很聪明。

当你写:

<span>@app</span>.<span>get</span>("/users/{user_id}")
def get_user(user_id: <span>int</span>):
    <span>user</span> <span>=</span> db.query(<span>User</span>).<span>filter</span>(User.id <span>=</span><span>=</span> user_id).<span>first</span>()
    <span>return</span> {"user": <span>user</span>}

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

<span>import</span> asyncio

<span>@app</span>.<span>get</span>(<span>"/bad"</span>)
def bad():
    result = asyncio.run(some_async_func())
    <span>return</span> result

技术上能跑,但这是非常糟糕的做法。asyncio.run 会创建一个新的事件循环,在线程池的线程里执行。如果这个异步函数内部又依赖 FastAPI 主事件循环的资源,就会出问题。而且每个请求都创建一个新的事件循环,开销很大,完全失去了异步的意义。

所以规则是单向的:异步库只能配 async def,同步库只能配 def。不要混着来。

一个函数里既有同步又有异步怎么办

真实项目里经常遇到这种情况:大部分操作是异步的,但某一步只能用同步库。比如用了异步的 HTTP 客户端,但数据库驱动是同步的。

这时候有几个选择:

方案一:把整个函数改成 def,所有操作都用同步库。 简单直接,让 FastAPI 用线程池兜底。代价是失去了异步并发的优势,但至少不会阻塞事件循环。

方案二:把同步操作放到线程池里执行。 FastAPI 提供了 run_in_threadpool

<span>from</span> fastapi.concurrency <span>import</span> run_in_threadpool

<span>@app.get(<span><span>"/mixed"</span></span>)</span>
<span>async</span> <span>def</span> <span>mixed</span>():
    async_result = <span>await</span> some_async_call()
    sync_result = <span>await</span> run_in_threadpool(blocking_db_query, arg1, arg2)
    <span>return</span> {<span>"async"</span>: async_result, <span>"sync"</span>: sync_result}

run_in_threadpool 会把同步函数提交到线程池,事件循环继续处理其他事情。等线程池执行完,await 拿到结果,继续往下走。这样既保留了异步的并发能力,又不阻塞事件循环。

方案三:换成异步驱动。 如果条件允许,把数据库驱动换成异步版本,所有操作统一用 async def。这是最彻底的方案,但迁移成本可能比较高,尤其是当项目里已经有大量基于同步驱动的代码时。

选择哪个方案,取决于项目现状和迁移成本。但无论选哪个,都不能把同步操作直接放在 async def 里不管。

路由之外的 async def 和 def

async defdef 的区别不只在路由函数上。FastAPI 的依赖注入系统、中间件、后台任务,都会遇到同样的问题。

依赖函数如果是 async def,FastAPI 会 await 它;如果是 def,会在线程池里跑。规则和路由函数一致。

<span>def <span>get_db</span>():
    db</span> = SessionLocal()
    <span>try</span>:
        <span>yield</span> db
    <span>finally</span>:
        db.close()

这是一个常见的同步依赖,用 def 定义,FastAPI 会在线程池里执行它。如果把它改成 async def,但内部还是同步的数据库操作,同样会阻塞事件循环。

中间件如果是 async def,会在事件循环里执行。中间件里如果有阻塞操作,影响的是所有请求。所以中间件通常应该用 async def,并且只做轻量的异步操作。

后台任务如果是 async def,会被 await;如果是 def,会在事件循环的线程池里执行。BackgroundTasks 的行为和路由函数类似。

怎么判断该用哪个

有一个简单的判断流程:

第一,看你用的库。数据库驱动、HTTP 客户端、文件操作、缓存客户端——只要有一个是同步的,就优先考虑用 def

第二,如果所有库都是异步的,用 async def。这样能最大化并发能力,一个事件循环可以同时处理成百上千个请求。

第三,如果混合使用,用 async defrun_in_threadpool 把同步操作隔离出去。

第四,不确定的时候,先写 def。FastAPI 会帮你用线程池兜底,至少不会阻塞事件循环。等确认所有依赖都是异步的,再改成 async def

有一个常见的误解:async def 一定比 def 快。不是的。对于单个请求,async defdef 的执行速度差不多,甚至 def 因为要经过线程池调度,可能还稍微慢一点。async def 的优势在高并发场景下才体现出来——事件循环可以用极少的线程处理大量并发连接。

但如果 async def 里全是阻塞操作,这个优势不仅不存在,还会变成劣势。因为事件循环被阻塞,并发能力反而比线程池更差。

一个真实场景

我之前见过一个项目,所有路由都是 async def,但内部用的是 requestspsycopg2redis-py 这些同步库。开发者觉得“FastAPI 是异步框架,所以路由也应该写成 async”。

上线之后,QPS 一过 50 就开始排队。他们以为是数据库慢了,加了索引、加了缓存,效果都不明显。后来把路由改成 def,QPS 直接翻了三倍。

原因很简单:改成 def 之后,这些同步操作在线程池里执行,事件循环不再被阻塞。虽然线程池只有 40 个线程,但至少事件循环可以自由调度,请求不会互相卡死。

后来他们逐步把数据库驱动换成了 asyncpg,HTTP 客户端换成了 httpx,缓存客户端换成了 aioredis,再把路由改回 async def。QPS 又上了一个台阶,而且线程池的 40 个线程限制也不存在了——事件循环可以用极少的线程处理大量并发。

这才是 async def 正确的打开方式:配套的库全是异步的,函数里没有阻塞调用

最后

async defdef 的区别,本质上是在事件循环里执行在线程池里执行的区别。

FastAPI 对两者的调度方式完全不同:async def 直接在事件循环里跑,def 丢到线程池。async def 里如果有阻塞操作,整个事件循环停摆;def 里如果有阻塞操作,只影响一个线程。

选择哪个,不取决于个人偏好,而取决于函数内部用的库。异步库配 async def,同步库配 def。混用不是不行,但要知道后果,并且用 run_in_threadpool 把阻塞操作隔离出去。

写 FastAPI 的时候,每次敲下 async def,先问自己一句:这个函数里有没有同步的 I/O 操作?有的话,要么改成 def,要么把同步操作包起来。这一秒钟的检查,能省下后面排查性能问题时几个小时的困惑。