2.1 选择同步路由还是异步路由

2026-09-05 14:54 更新

选择同步路由还是异步路由

“所有路由都写成 async def”听起来像统一规范,实际上很容易把同步阻塞代码藏进事件循环。判断路由形式的依据应当是它直接调用的库:调用方提供 await 的异步客户端,通常放在异步路由中;只有同步接口的客户端,可以保留普通 def,或在异步流程中显式隔离。

先分清三个概念

事件循环负责在一个线程里轮流推进协程。当协程执行到 await,被等待的操作确实支持异步调度时,它可以暂时交出执行权,事件循环就能处理别的请求。网络读写、异步数据库驱动和 asyncio.sleep() 都是这种等待的典型例子。await 不是“把任意函数变快”的关键字;它必须等待一个可等待对象,而且被调用的操作本身也要按异步方式实现。

同步阻塞调用则会让当前线程停在原地,例如 time.sleep()、同步 HTTP 客户端的请求或某些同步数据库驱动。若该调用发生在异步路由中,它就停在事件循环线程里;同一 worker 里的其他协程要等它返回后才有机会继续。相同调用若发生在普通同步路由中,FastAPI 会把整个同步路由交给 Starlette 使用的线程池执行,事件循环线程可以继续接收和调度请求,但线程资源仍然有限。

这里还有一个容易漏掉的边界:普通辅助函数不会因为“被异步路由调用”就自动进入线程池。下面的调用链仍然在事件循环线程执行:

@app.get("/bad")
async def bad_route():
    value = blocking_helper()  # 直接调用,仍会阻塞事件循环
    return {"value": value}

如果必须在 async def 中调用同步函数,应该在边界处显式使用线程池,2.3 节会专门处理同步 SDK。若没有异步依赖,也没有必要为了函数签名好看而改成 async def

按被调用库选择形式

被调用操作 推荐路由形式 原因
异步 HTTP 客户端、异步数据库驱动 async def 在 await 等待 I/O 时释放事件循环
同步 HTTP 客户端、同步数据库驱动 普通 def FastAPI 会把整个路由放到同步线程执行
异步路由中的轻量纯 Python 逻辑 async def 内直接调用 没有等待,也没有明显阻塞,保持简单
异步路由中的同步阻塞 SDK async def + run_in_threadpool 只隔离明确的阻塞边界
纯 Python 大量计算 不靠 await 解决 需要评估进程、任务系统或算法优化,见 2.4

“异步”也不等于“永远更快”。异步 I/O 的优势是等待期间可以推进其他工作;如果请求主要是计算,或者客户端库内部仍是同步实现,换一个函数声明不会改变瓶颈。线程池也不是无限资源,线程数用尽后新的同步任务仍会排队。

一个可运行的最小对照

下面的程序用短暂的本地等待模拟 I/O。/async-io 调用异步辅助函数,/sync-io 保留同步路由,/bad-helper 特意保留一个错误示例,帮助在代码审查时识别“异步路由直接调用同步阻塞 helper”。自检只检查路由能返回预期结果,不把一次本地请求误当成性能基准。

<!-- file: ch02_choice/choice_demo.py -->

from __future__ import annotations

import asyncio
import time

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI(title="同步与异步路由选择")

def blocking_lookup() -> str:
    """模拟只有同步接口的 I/O 客户端。"""
    time.sleep(0.01)
    return "sync-client"

async def async_lookup() -> str:
    """模拟提供 await 接口的异步客户端。"""
    await asyncio.sleep(0.01)
    return "async-client"

@app.get("/async-io")
async def async_io() -> dict[str, str]:
    return {"mode": "async", "client": await async_lookup()}

@app.get("/sync-io")
def sync_io() -> dict[str, str]:
    # FastAPI 会把普通同步路由放入同步线程执行。
    return {"mode": "sync", "client": blocking_lookup()}

@app.get("/bad-helper")
async def bad_helper() -> dict[str, str]:
    # 这里没有自动调度:blocking_lookup 仍在事件循环线程直接运行。
    return {
        "mode": "async-with-blocking-helper",
        "client": blocking_lookup(),
    }


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

def self_check() -> None:
    with TestClient(app) as client:
        async_response = client.get("/async-io")
        sync_response = client.get("/sync-io")
        bad_response = client.get("/bad-helper")
        health_response = client.get("/health")

    assert async_response.status_code == 200
    assert async_response.json() == {"mode": "async", "client": "async-client"}
    assert sync_response.status_code == 200
    assert sync_response.json() == {"mode": "sync", "client": "sync-client"}
    assert bad_response.status_code == 200
    assert bad_response.json()["client"] == "sync-client"
    assert health_response.json() == {"ok": True}
    print("choice_demo self-check passed")


if __name__ == "__main__":
    self_check()

在教程项目的虚拟环境中运行这个文件:

python ch02_choice/choice_demo.py

输出 choice_demo self-check passed 只能说明导入、路由和返回值成立。要观察不同路由的并发行为,应使用下一节的真实 HTTP 进程实验;TestClient 适合快速验收,不适合替代网络压测。

代码审查时的四个问题

  1. 这个函数直接调用的客户端是否提供异步方法?不要只看项目里是否安装了异步框架。
  2. async def 内有没有 time.sleep()、同步网络请求、同步文件大读写或同步数据库查询?若有,确认它们是否应被隔离。
  3. 普通 helper 是纯计算、快速内存操作,还是会等待外部资源?普通函数“看起来很小”不能证明它不会阻塞。
  4. 同步路由的并发量是否可能超过线程资源?同步写法能保护事件循环,但不能消除线程排队、连接数和外部服务限流。

最后,把路由声明当成执行契约,而不是风格标签:异步路由承诺内部会在可等待点交还控制权,同步路由则把阻塞边界交给框架的同步执行机制。后续接入实际 SDK 时,沿着调用链重新检查一次。

来源与相邻小节

阅读相邻小节时,请在教程目录中选择对应标题。

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号