上线之后,流量稍微大了一点,问题来了:所有接口的响应时间集体飙升。不只是这个查询接口,连一个只返回 {"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 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.<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 响应回来。这期间事件循环什么也做不了。所有其他请求都在排队。
同样的逻辑适用于数据库:asyncpg、aiomysql、motor 这些是异步驱动,配合 async def 没问题。psycopg2、pymysql、pymongo 这些是同步驱动,放在 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 def 和 def 的区别不只在路由函数上。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 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,要么把同步操作包起来。这一秒钟的检查,能省下后面排查性能问题时几个小时的困惑。
这篇把 FastAPI 异步路由的常见坑讲透了,适合正在用 FastAPI 且遇到高并发响应变慢的开发者,按库类型选 async def 或 def 即可避坑。