2.1 选择同步路由还是异步路由
选择同步路由还是异步路由
“所有路由都写成 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 适合快速验收,不适合替代网络压测。
代码审查时的四个问题
- 这个函数直接调用的客户端是否提供异步方法?不要只看项目里是否安装了异步框架。
async def内有没有time.sleep()、同步网络请求、同步文件大读写或同步数据库查询?若有,确认它们是否应被隔离。- 普通 helper 是纯计算、快速内存操作,还是会等待外部资源?普通函数“看起来很小”不能证明它不会阻塞。
- 同步路由的并发量是否可能超过线程资源?同步写法能保护事件循环,但不能消除线程排队、连接数和外部服务限流。
最后,把路由声明当成执行契约,而不是风格标签:异步路由承诺内部会在可等待点交还控制权,同步路由则把阻塞边界交给框架的同步执行机制。后续接入实际 SDK 时,沿着调用链重新检查一次。
来源与相邻小节
- 主要参考:fastapi-best-practices 中文 README 的“异步路由 / I/O 密集型任务”主题。本节重写了三种执行方式和选择规则,未直接复制原文代码。
- 官方说明:FastAPI 并发与 async/await、Starlette 线程池。
- 相邻小节:2.2 复现并定位事件循环阻塞、2.3 安全接入只能同步调用的 SDK、2.4 区分 CPU 任务与后台任务。
阅读相邻小节时,请在教程目录中选择对应标题。

免费 AI IDE


更多建议: