一句话先说结论:FastAPI 里
async def端点是直接跑在事件循环主线程上的,它默认你是“非阻塞”的。只要在里面调了time.sleep、requests.get、同步数据库驱动这些阻塞代码,整个事件循环就被卡住——不是这一个接口慢,而是所有接口(包括返回常量的/health)一起卡。解法是换异步库,或者用run_in_threadpool/asyncio.to_thread把同步调用丢到线程池。
背景
FastAPI 跑在 Uvicorn 之上,Uvicorn 用 Python 的 asyncio 事件循环来调度所有请求。事件循环是单线程的协作式调度:一个协程只有在碰到 await 时才让出控制权,轮不到其他协程跑。这套模型在“等 I/O”时几乎零开销,所以能支撑很高并发——前提是每个人都按时让路。
现象
一个很反直觉的线上事故特征:CPU 明明很低(个位数),数据库也正常,但所有接口的 p99 一起涨,连返回一个常量、本应微秒级响应的 /health 都要等几百毫秒。
追根溯源通常是这样的:有人把一堆 def 端点“清理”成了 async def(以为 async 就是快),里面却还调着 requests.get 或同步 ORM。代码照跑、测试照过、staging 延迟不变,上线后流量一上来就崩。而回滚那个“只是加了 async 关键字”的 PR 就好了——这恰恰是最迷惑的地方。
根因分析
关键是理解 FastAPI 怎么分发端点:一个 async def 端点在事件循环主线程上执行;一个普通 def 端点则被 Starlette 丢到工作线程池(默认 40 线程),在 loop 里 await 它。
所以同一个阻塞调用,放在三种写法里结果天差地别:
| 端点写法 | 内部调用的代码 | 高负载下的表现 |
|---|---|---|
def |
requests.get(...) |
占用 40 个线程池线程里的 1 个,loop 不受影响 |
async def |
await client.get(...) (httpx) |
loop 正确挂起协程,干净扩展 |
async def |
requests.get(...) |
整个进程停滞,直到调用返回 |
第三种是三者里最糟的。因为在 async def 里写阻塞调用,等于把执行从“受保护的线程池”挪到了“最不能阻塞的主线程”——async def 这个关键字本身,就是把你从安全的线程池里踢出来,再塞进那个不能停的线程上。
事件循环没有抢占:一个协程在 await 之间如果调了 time.sleep 或同步网络调用,它不会让出控制权,而是把唯一的线程扣住不还。这 800ms 里,进程里没有别的请求能推进、定时器不触发,/health 也得排在后面等。
解决方案
方案 A(正确姿势):换真正的异步库
让 promise 端到端成立——async 端点就该用异步库:
import httpx # 替代 requests
import asyncio
@app.get("/data")
async def get_data():
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com/data")
return r.json()
数据库用 asyncpg(或 async SQLAlchemy)、文件用 aiofiles、sleep 用 await asyncio.sleep()。这才是 async 收益真正的来源。
方案 B(兜底):run_in_threadpool / asyncio.to_thread
必须用同步库(老 SDK、同步 ORM、time.sleep、读大文件)时,把它丢进线程池:
from fastapi.concurrency import run_in_threadpool
import time
def slow_sync_task():
time.sleep(3) # 阻塞操作,但会在别的线程跑
return "done"
@app.get("/work")
async def work():
result = await run_in_threadpool(slow_sync_task)
return {"result": result}
Python 3.9+ 也可以直接:
import asyncio
result = await asyncio.to_thread(slow_sync_task)
效果立竿见影:原来两个请求串行要 6 秒,改成线程池后两个请求并行、总耗时压回 3 秒,loop 全程不卡。
需注意:线程池是有限的(默认 40),别把长事务、超长循环、海量并发同步调用一股脑塞进去,否则池子被占满、后面的请求排队。
方案 C:CPU 密集改用进程池
如果是矩阵运算、模型推理这类 CPU 密集(不是 I/O 等),线程池也不解决问题(GIL 限制),要用 ProcessPoolExecutor:
from concurrent.futures import ProcessPoolExecutor
executor = ProcessPoolExecutor(max_workers=2)
loop = asyncio.get_running_loop()
prediction = await loop.run_in_executor(executor, blocking_model_predict, text)
排查小抄
从面板就能认出 loop 阻塞:所有路由一起退化、退化幅度跟着某个具体路由的流量走、CPU 全程很低、延迟的“地板”整体抬升而不是只有长尾变长。要坐实的话:
# 抓当前线程栈,阻塞时能看到 socket.recv / requests.get 卡在端点函数里
py-spy dump --pid <uvicorn worker pid>
也可以 PYTHONASYNCIODEBUG=1 启动,asyncio 会打印每个卡住 loop 超过 100ms 的回调(Executing took 0.874 seconds),把无形的停顿变成日志里一个具名函数。
小结
async def是“我不会阻塞”的承诺,不是装饰;做不到就老老实实用def或线程池。- 记住三档:
def+ 同步库「能跑但不高并发」,async def+ 异步库「最优」,async def+ 同步库「最差」。 - I/O 阻塞用
run_in_threadpool/asyncio.to_thread,CPU 密集用ProcessPoolExecutor。 - 判断口诀:CPU 低 + 延迟高 + 全端点一起慢,先怀疑事件循环被阻塞。