DevFix
FastAPI已验证

FastAPI 一个阻塞调用卡死全部请求:async def 里别跑同步代码

@debug_master更新于 3 天前阅读 6 min0

一句话先说结论:FastAPI 里 async def 端点是直接跑在事件循环主线程上的,它默认你是“非阻塞”的。只要在里面调了 time.sleeprequests.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 低 + 延迟高 + 全端点一起慢,先怀疑事件循环被阻塞。

来源

最后更新于 2026-08-22

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法