适用场景

FastAPI 服务平时响应正常,但只要某个文件处理、旧版 SDK 调用或报表计算接口并发升高,健康检查、登录和其他无关接口也一起变慢。CPU、内存和数据库连接数未必异常,扩容 Uvicorn worker 后只能暂时缓解。

这类问题常见于 async def 路由中直接调用同步阻塞函数。本文给出一个可复现的最小示例,说明如何区分事件循环阻塞、线程池排队和纯 CPU 饱和,并给出可测试的修复方式。

现象描述

典型现象不是单个下游接口超时,而是同一进程内看似无关的异步接口一起出现延迟尖峰:

  • /report 执行同步文件读取或第三方 SDK 请求时,/health 的 P99 同步升高;
  • 应用进程 CPU 并未跑满,数据库和下游 HTTP 服务也没有对应的慢查询;
  • 增加 worker 数量后故障阈值提高,但单个 worker 仍会周期性“冻结”;
  • 日志时间戳之间出现空档,异步定时任务也延后执行。

下面的代码虽然语法合法,却会让当前 worker 的事件循环停住两秒:

import time

from fastapi import FastAPI

app = FastAPI()


@app.get("/report")
async def create_report() -> dict[str, str]:
    # 错误示例:同步阻塞函数在事件循环线程中直接执行。
    time.sleep(2)
    return {"status": "ready"}


@app.get("/health")
async def health() -> dict[str, str]:
    return {"status": "ok"}

async def 不会自动把函数体中的同步代码变成异步代码。协程只有运行到能够让出控制权的 await 时,事件循环才有机会调度其他请求;time.sleep()、同步数据库驱动、同步 HTTP SDK 和普通文件 I/O 都可能在这段时间独占事件循环线程。

可能原因

排查时重点搜索以下调用:

  • async def 中使用 time.sleep(),而不是 await asyncio.sleep()
  • 调用 requests、同步云厂商 SDK、同步数据库客户端或阻塞式消息客户端;
  • 直接执行大文件读写、压缩、图片处理或模板渲染;
  • 在事件循环线程中做大 JSON 编解码、加密、正则回溯或批量数据转换;
  • 误以为普通工具函数会被 FastAPI 自动放入线程池。

FastAPI 只会把它直接调用的普通 def 路由和普通 def 依赖放入线程池。async def 路由内部直接调用的普通工具函数仍在当前线程执行,这是定位时最容易忽略的边界。

排查思路

1. 先证明事件循环发生延迟

在应用中增加一个低开销的事件循环延迟探针。它比较计划唤醒时间和实际唤醒时间,只记录超过阈值的异常,避免在热点循环中刷日志:

import asyncio
import logging
import time

logger = logging.getLogger(__name__)


async def monitor_event_loop(
    interval_seconds: float = 0.5,
    warning_seconds: float = 0.2,
) -> None:
    while True:
        started = time.monotonic()
        await asyncio.sleep(interval_seconds)
        lag_seconds = time.monotonic() - started - interval_seconds
        if lag_seconds >= warning_seconds:
            logger.warning(
                "事件循环延迟超过阈值",
                extra={"event_loop_lag_ms": round(lag_seconds * 1000)},
            )

event_loop_lag_ms 持续升高说明协程没有按时获得执行机会。它不能单独指出是哪段代码,但可以把“下游慢”与“进程内部调度被阻塞”分开。

2. 用对照请求确认影响范围

压测可疑接口时,同时以低并发持续请求只返回固定 JSON 的 /health。若可疑接口启动后 /health 也出现接近相同长度的停顿,阻塞事件循环的可能性很高。

# 终端一:持续观察健康检查耗时
while ($true) {
    curl.exe -s -o NUL -w "%{time_total}`n" http://127.0.0.1:8000/health
    Start-Sleep -Milliseconds 200
}

# 终端二:触发 10 个并发请求,需要预先安装 hey
hey.exe -n 20 -c 10 http://127.0.0.1:8000/report

测试应在隔离环境执行,不要直接对生产接口施压。观察总吞吐的同时,还要看健康检查延迟、事件循环延迟和线程数,避免只凭平均响应时间判断。

3. 区分 I/O 阻塞与 CPU 密集计算

同步 I/O 的特点是 CPU 使用率不高,但线程长期等待文件、网络或驱动返回;这类调用适合异步客户端或受控线程池。CPU 密集任务的特点是某个核心持续繁忙,把它简单移到线程中不一定提高吞吐,还可能受 GIL 和线程竞争影响,应考虑进程池、任务队列或独立计算服务。

修复方案

方案一:优先换成真正的异步接口

如果库提供异步方法,直接等待它,让事件循环在 I/O 等待期间继续服务其他请求:

import asyncio

from fastapi import FastAPI

app = FastAPI()


@app.get("/report")
async def create_report() -> dict[str, str]:
    await asyncio.sleep(2)
    return {"status": "ready"}

真实项目中应对应替换为异步数据库驱动、异步 HTTP 客户端或库提供的异步 SDK。不要用 await asyncio.sleep(0) 包裹阻塞函数,它只能在调用之前让出一次控制权,不能改变后续阻塞行为。

方案二:把暂时无法替换的同步 I/O 移出事件循环

Python 3.9 及以上可使用 asyncio.to_thread()。同时用信号量限制并发,防止请求洪峰把线程池和下游服务一起压垮:

import asyncio
import time

from fastapi import FastAPI, HTTPException

app = FastAPI()
blocking_slots = asyncio.Semaphore(8)


def generate_report() -> str:
    time.sleep(2)
    return "ready"


@app.get("/report")
async def create_report() -> dict[str, str]:
    try:
        async with asyncio.timeout(5):
            async with blocking_slots:
                status = await asyncio.to_thread(generate_report)
    except TimeoutError as exc:
        raise HTTPException(status_code=503, detail="报表生成超时") from exc
    return {"status": status}

to_thread() 解决的是事件循环被同步 I/O 卡住的问题,不代表底层工作已经可取消。外层超时后,正在执行的线程通常仍会继续运行,所以同步函数本身也应配置网络、数据库或文件操作超时。信号量容量应根据下游承载能力和压测结果设置,不能简单等于最大请求并发。

若整个路由都是同步 I/O,也可以把路径函数声明为普通 def,由 FastAPI 在线程池中执行:

@app.get("/legacy-report")
def create_legacy_report() -> dict[str, str]:
    return {"status": generate_report()}

方案三:把 CPU 密集任务移出 Web 请求

图片批处理、压缩、模型推理或大规模报表计算不宜长期占用 Web worker。请求只负责校验参数、创建任务并返回任务 ID,由独立任务队列或进程池执行;客户端轮询状态或通过回调接收结果。这样可以单独设置并发、超时、重试和资源限额,也不会让计算峰值拖慢健康检查。

验证示例

修复应包含回归测试,验证阻塞工作运行期间其他协程仍能获得调度:

import asyncio
import time

import pytest


def blocking_operation() -> str:
    time.sleep(0.2)
    return "done"


@pytest.mark.asyncio
async def test_blocking_operation_does_not_block_event_loop() -> None:
    started = time.monotonic()
    task = asyncio.create_task(asyncio.to_thread(blocking_operation))

    await asyncio.sleep(0.02)
    scheduling_delay = time.monotonic() - started

    assert scheduling_delay < 0.1
    assert await task == "done"

时间断言容易受共享 CI 主机抖动影响,因此阈值要明显大于正常调度耗时。更完整的集成测试应同时请求慢接口和健康检查,并断言健康检查不会等待慢任务结束。

预防措施

  • 代码审查中检查 async def 内的同步 SDK、文件操作、time.sleep() 和重计算;
  • 为外部 I/O 设置连接、读取和总耗时边界,不只依赖 Web 层超时;
  • 对线程池任务、后台队列和下游调用分别设置并发上限与拒绝策略;
  • 监控事件循环延迟、接口 P95/P99、线程池排队时间、活动线程数和任务队列长度;
  • 将 CPU 密集工作从 Web 进程拆出,并为任务设计幂等键、失败状态和有限重试;
  • 压测时同时观察业务接口与轻量健康检查,验证故障是否会跨接口扩散。

FastAPI 官方并发文档明确区分了普通 def 路由、async def 路由和直接调用的工具函数;Python 官方文档则说明了 asyncio.to_thread() 适合把会阻塞事件循环的 I/O 函数放到独立线程。实施前应结合当前 Python、FastAPI 和底层客户端版本核对接口行为。

参考资料:

总结

FastAPI 全站延迟不一定来自数据库或网络,也可能是某个 async def 路由直接执行同步阻塞代码。先用事件循环延迟和健康检查对照请求证明问题,再按工作类型选择异步客户端、受控线程池或独立任务系统。修复的关键不是机械地增加 worker 或线程数,而是让阻塞工作离开事件循环,并为超时、并发和失败恢复建立清晰边界。