首页
/ FastAPI 并发与 async/await:路径操作函数的并发模型、线程池执行机制与协程原理详解

FastAPI 并发与 async/await:路径操作函数的并发模型、线程池执行机制与协程原理详解

2026-09-04 11:05:16作者:裴麒琰

本文基于 FastAPI 官方文档《Nebenläufigkeit und async / await》(并发与 async/await 章节)展开,系统讲解在 FastAPI 路径操作函数(path operation functions)中何时该用 async def、何时该用普通 def 的完整决策准则;并深入 FastAPI 源码,展示框架如何用 run_in_threadpool 将同步函数卸载到外部线程池、如何区分协程函数、以及依赖(Dependencies)与带 yield 的生成器依赖在线程池中的执行细节,帮助读者既掌握日常写法,又理解底层并发原理。

并发汉堡比喻:一对情侣在快餐店柜台点两个汉堡,背景是汉堡与薯条海报

一、快速决策指南(TL;DR)

如果你使用的第三方库必须用 await 调用,例如:

results = await some_library()

那么请将你的路径操作函数声明为 async def

@app.get('/')
async def read_results():
    results = await some_library()
    return results

注意await 只能用在用 async def 声明的函数内部。

如果你使用的第三方库与外部系统(数据库、API、文件系统、网络等)交互,但不支持 await——目前绝大多数数据库库都是如此(同步阻塞库),那么就用普通的 def 声明路径操作函数:

@app.get('/')
def results():
    results = some_library()
    return results

如果你的应用(无论如何)不需要与其他东西通信并等待其响应,那么即使内部没有 await,也请使用 async def

如果你不确定,就用普通的 def

要点:你可以在路径操作函数中自由混用 defasync def,每个函数各自采用最合适的声明方式,FastAPI 都会正确处理。无论哪种情况,FastAPI 整体上始终是异步运行的、性能都极高;但遵循上述准则后,还能进一步榨取性能优化空间。

这四条准则构成了全文的“操作骨架”,后文所有技术细节都是为了说明这些准则为何成立。

二、异步代码:程序为什么需要“等待”

现代 Python 支持使用所谓的协程(Coroutines)来编写异步代码,语法即 asyncawait。这句话包含三个关键词,逐一拆解:

2.1 什么是“异步代码”

“异步”意味着语言提供了一种机制,让程序可以在代码的某个点上“声明”:它需要等待别的事情在别处完成。假设等待的对象是一个“慢文件”操作:在等待期间,系统可以去处理其他任务;等空闲时再回来检查那些等待中的任务是否已完成,并继续处理第一个完成的任务。

“等待别的事情”通常指相对“慢”的 I/O 操作(相对于 CPU 和内存的速度),例如等待:

  • 客户端数据通过网络接收完毕;
  • 客户端接收你程序发出的数据;
  • 系统从磁盘读取文件内容并交给你的程序;
  • 系统将你程序写入的内容落盘;
  • 远程 API 调用完成;
  • 数据库操作完成;
  • 数据库查询返回结果;
  • 等等。

由于执行时间主要消耗在等待 I/O 上,这类操作被称为**“I/O 密集型”(I/O bound)**。之所以叫“异步”,是因为程序不需要与慢任务“同步”——不必傻等到那一精确时刻任务完成才能接手结果。在异步系统里,任务完成后可以短暂(几微秒)排队,等程序忙完手头的事再回来接收结果。相对地,“同步”也常被称为“顺序(sequentially)”:程序按顺序执行所有步骤(哪怕中间在等待),才切换到下一个任务。

2.2 并发与并行:汉堡比喻

**并发(concurrency)并行(parallelism)**都指“不同的事情大致同时发生”,但细节差别很大。FastAPI 文档用一个汉堡故事来区分二者。

并发的汉堡:你和对象去快餐店,排队等候前面的人点单。轮到你时点了两个汉堡。收银员转头吩咐后厨开始制作(他还在处理前面顾客的单子)。你付钱,拿到一个取餐号。然后你和对象找位子坐下,边聊边等,偶尔看一眼柜台上方显示的取餐号。等号到了,你去柜台取餐,回到桌上吃汉堡——全程大部分时间你在做别的事(和对象聊天),等待被“外包”给了厨房和取餐号系统。

并行汉堡比喻:一排多位收银员/厨师同时工作,顾客们必须留在各自柜台前等待

把这个场景映射到计算机:你是程序。排队时你“闲置”(但队列处理很快,因为收银员只接单不做饭);点单时你做“生产性工作”;随后交互“暂停”(⏸),因为你必须等汉堡做好。但离开柜台后你可以把注意力切换到对象身上,做另一项“生产性工作”。当显示屏跳到你的号码时,你不必立刻跳起来——你有取餐号,别人抢不走你的汉堡。你等当前对话说完(完成当前任务),再去柜台取餐,这一步(取汉堡)完成,同时产生新任务(吃汉堡)。

并行的汉堡:想象 8 位“收银员兼厨师”的快餐店。每位收银员接单后立刻亲自下厨,做完才接下一单。所有顾客都必须留在柜台前等自己的汉堡做完——因为没有取餐号机制。轮到你时,你点单、付钱,然后站在柜台前一直等,同时还得看住别让任何人误拿了你的汉堡。你的对象全程无法获得你的注意力。最后收银员/厨师端着汉堡回来,你取餐、吃、离开。整个过程大量时间都耗在柜台前干等。

映射到计算机:这是一个双处理器系统(你和对象),两个处理器都在“长时间站在柜台前等待”。餐厅有 8 个处理器(收银员/厨师),而并发版餐厅只有 2 个(一个收银员 + 一个厨师)。但用户体验反而更差——因为等待没有被利用起来。

一个更“现实”的并行例子是银行:过去大多数银行有多个柜员和长队,每个柜员顺序地为每个客户做完所有事情,客户必须长时间排队,你的同伴大概率不想陪你去银行办业务。

结论:在“快餐店 + 同伴”场景下,并发系统明显更合理,因为等待时间占比很高——大多数 Web 应用正是如此:大量用户,但服务器大部分时间在等(🕙)用户那不太好的网络把请求发过来,然后又在等响应被接收回去。这些等待单次以微秒计,累积起来却非常可观。因此用异步代码写 Web API 极其划算。这类异步模型让 NodeJS 走红(尽管 NodeJS 并非并行),也是 Go 语言作为编程语言的强项所在。FastAPI 正提供了同一水平的并发能力;而由于 Python 可以同时拥有并发与并行,官方文档引用第三方基准测试(TechEmpower)称其性能可与 Go 这类更接近 C 的编译型语言相当——这一表述出自原文档,属于对特定测试场景的引用,适用前提以原文档为准(“得益于 Starlette”)。

2.3 并发是否比并行更好?

不!这不是故事的寓意。并发不同于并行,它只在需要大量等待的特定场景下更优,因此通常(而非绝对)比并行更适合 Web 应用开发。

文档用一个简短故事做对照:

你有一个又大又脏的房子要打扫。

——对,这就是整个故事。*

这里没有等待,只有多处大量工作。你可以像汉堡例子那样在客厅和厨房之间来回切换(并发),但因为没有任何等待,切换没有任何收益——不管切不切换,完成时间都一样。但如果你把那 8 位前收银员/厨师/现清洁工带来,每人负责一个区域(加上你自己),就能并行地做完所有工作,速度大幅提升。这个场景里每个清洁工就是一个处理器,各自完成自己那部分。由于大部分执行时间花在“干活”(而非等待)上,且计算机的工作由 CPU 执行,这类问题被称为**“CPU 密集型”(CPU bound)**。

典型的 CPU 密集型工作包括需要复杂数学运算的任务:

  • 音频图像处理
  • 计算机视觉:一张图有数百万像素,每个像素含 3 个颜色值,处理通常要对这些像素做大量并行计算;
  • 机器学习:通常是大量“矩阵”与“向量”乘法——想象一张巨大的数字表格,要同时把所有数字相乘;
  • 深度学习:机器学习的子领域,原理相同,只是表格数量巨大,且训练/推理常用专用处理器(GPU 等)。

2.4 并发 + 并行:Web + 机器学习

FastAPI 让你利用 Web 开发中广泛受益的并发优势(这正是 NodeJS 的主要卖点);同时也能利用并行与多进程(multiprocessing,多个进程并行执行)来处理机器学习系统中的 CPU 密集型负载。再加上 Python 是数据科学、机器学习尤其是深度学习的主力语言这一事实,使 FastAPI 成为构建数据科学/机器学习 Web API 与应用的合适工具(当然不限于此)。生产环境如何做到这一点,可参见官方文档的 Deployment 章节

三、asyncawait 语法实战

现代 Python 提供了一种非常直观的异步代码写法:它看起来像普通的“顺序”代码,并在正确的时机替你完成“等待”。

如果一个操作需要“等结果返回”,且该操作支持这套新语法,你可以这样写:

burgers = await get_burgers(2)

关键在于 await:它告诉 Python 必须等待 get_burgers(2) 完成,才能把结果存入 burgers。同时 Python 也知道这段时间可以做别的事(比如接收另一个请求)。

为了让 await 生效,它必须位于一个支持异步的函数内部——用 async def 声明:

async def get_burgers(number: int):
    # 这里做点异步操作来“制作”汉堡
    return burgers

而不是:

# 这不是异步的
def get_sequential_burgers(number: int):
    # 这里做点顺序操作来“制作”汉堡
    return burgers

用了 async def,Python 就知道该函数体内要留意 await 表达式,可以随时把该函数的执行“暂停”⏸ 转去干别的事,之后再回来继续。

调用 async def 函数时必须“等待”(await)它。因此下面这样写不会工作:

# 不工作,因为 get_burgers 是用 async def 定义的
burgers = get_burgers(2)

所以,只要你使用的库告诉你“可以 await 它”,使用它的路径操作函数就必须用 async def

@app.get('/burgers')
async def read_burgers():
    burgers = await get_burgers(2)
    return burgers

3.1 更多技术细节与“鸡蛋问题”

await 只能出现在 async def 函数中;而 async def 函数又必须被 await 才能执行。于是只有 async def 函数内部才能调用 async def 函数——这导致“鸡蛋问题”:第一个 async 函数由谁来调用?

使用 FastAPI 时无需担心:这个“第一个”函数就是你的路径操作函数,FastAPI 知道该怎么处理它(后文源码部分会展示具体机制)。即使不用 FastAPI,也可以直接写自己的异步应用。

Starlette(以及 FastAPI)基于 [AnyIO](此处不附外链),这意味着它同时兼容 Python 标准库的 asyncioTrio 两个异步后端。你甚至可以直接使用 AnyIO 处理更高级的并发用例;官方还基于 AnyIO 提供了 Asyncer 库,作为一层薄封装改善类型标注与自动补全,并配有教程帮助你理解“自己的”异步代码——尤其在必须把异步代码与常规(阻塞/同步)代码组合时特别有用。

3.2 其他异步代码形式

这种 async/await 用法在 Python 中相对较新,但它大幅降低了异步代码的难度。同样的(或几乎相同的)语法近期也被现代 JavaScript(浏览器与 NodeJS)采纳。在此之前,异步代码的处理复杂得多:早期 Python 中人们使用线程或 Gevent,代码更难理解、调试和追踪;早期 NodeJS/浏览器 JavaScript 使用回调,导致“回调地狱”(callback hell)。

四、协程(Coroutines)

协程不过是 async def 函数返回物的一个时髦称呼。Python 知道它是一个“可启动、终会结束、并且可以在每个 await 处随时暂停⏸”的东西。整套用 async/await 编写异步代码的能力常被统称为“使用协程”,类比 Go 语言的核心特性“Goroutines”。

把开头那句话再看一遍:

现代版本的 Python 使用所谓的协程、借助 asyncawait 语法来支持 “异步代码”

到这里,这句话应该完全通顺了。而这一切正是 FastAPI(经由 Starlette)获得卓越性能的动力来源。

五、源码级印证:FastAPI 如何执行 async defdef

以上准则并非只是建议——FastAPI 的源码中对二者的执行路径做了显式区分。以下均以当前仓库源码为准。

5.1 路径操作函数:协程直接 await,同步函数进线程池

文档的“非常技术细节”指出:若用普通 def(而非 async def)声明路径操作函数,它不会被直接调用(那会阻塞服务器),而是在一个外部线程池中执行,然后被 await

这一点在 路由实现 中一目了然:

async def run_endpoint_function(
    *, dependant: Dependant, values: dict[str, Any], is_coroutine: bool
) -> Any:
    assert dependant.call is not None, "dependant.call must be set"

    if is_coroutine:
        return await dependant.call(**values)
    else:
        return await run_in_threadpool(dependant.call, **values)

即:协程函数直接 await,普通函数走 run_in_threadpool(来自 Starlette/AnyIO 的线程池调度)。is_coroutine 的判定发生在请求处理器构建阶段,见 get_request_handler

is_coroutine = _is_coroutine_callable(dependant.call)

判定函数 _is_coroutine_callable 内部使用 inspect.iscoroutinefunction 检查(并对 functools.partial、解包后的对象、类实例的 __call__ 等情形做了处理),且结果经 @lru_cache 缓存——从源码结构看,这是针对每个可调用对象只做一次判定、之后零开销的性能优化。

更底层的入口在 request_response(FastAPI 对 Starlette 同名函数的复制改造版):

f: Callable[[Request], Awaitable[Response]] = (
    func
    if is_async_callable(func)
    else functools.partial(run_in_threadpool, func)
)

这里再次确认:异步可调用对象原样执行,同步可调用对象被包进线程池

文档还特别提醒:如果你从其他异步框架来,习惯给“纯计算、无阻塞”的路径操作函数用普通 def 以换取约 100 纳秒的微小提速,那么在 FastAPI 中效果恰好相反——因为 def 会引入线程池调度开销,这类纯计算场景反而应该用 async def;只有当函数会执行阻塞 I/O 时才用 def。当然即便如此,文档也指出 FastAPI 很可能(依然)比你的旧框架快(或至少相当)。

5.2 依赖(Dependencies):同样的规则

文档说明:依赖若为普通 def 函数(而非 async def),同样在外部线程池执行。这在 依赖求解器 中可以直接验证:

elif _is_coroutine_callable(use_sub_dependant.call):
    solved = await call(**solved_result.values)
else:
    solved = await run_in_threadpool(call, **solved_result.values)

子依赖可以相互嵌套,一部分用 async def、一部分用普通 def,依然工作正常——def 的那些从线程池取线程执行,而不是被 await。上面的分支逻辑对整棵依赖树中的每一个依赖都适用。

5.3 带 yield 的同步依赖:线程池中的上下文管理器

仓库中还有一个与文档主题强相关的实现细节:contextmanager_in_threadpool。当依赖是普通 def + yield 的生成器时,依赖求解器 会走这条路:

if _is_async_gen_callable(dependant.call):
    cm = asynccontextmanager(dependant.call)(**sub_values)
elif _is_gen_callable(dependant.call):
    cm = contextmanager_in_threadpool(contextmanager(dependant.call)(**sub_values))

也就是说,同步的 yield 依赖在进入和退出时都会被丢进线程池__enter__run_in_threadpool__exit__anyio.to_thread.run_sync)。源码注释还解释了为什么 __exit__ 使用一个容量为 1 的独立 CapacityLimiter 而不受默认线程池容量限制:若 __exit__(阻塞操作)因为等不到空闲线程而被卡住,而线程池本身又被依赖内部(例如数据库连接池)占用,就可能产生竞态条件甚至死锁。这是“同步代码必须隔离在事件循环之外”这一原则在边缘场景下的具体体现。

另外,fastapi/concurrency.py 直接转发了 starlette.concurrencyrun_in_threadpooliterate_in_threadpoolrun_until_first_complete,并导入 anyio.to_thread——印证了文档中“Starlette/FastAPI 基于 AnyIO、兼容 asyncio 与 Trio”的说法。

5.4 普通辅助函数:FastAPI 不插手

文档特别强调:你自己直接调用的其他辅助函数,无论用 def 还是 async def 定义,FastAPI 都不影响你调用它们的方式——这与 FastAPI 替你调用的路径操作函数依赖不同。若辅助函数是普通 def,它就在你的代码里被直接调用(不进线程池);若是 async def,你需要在代码里自己 await 它。换句话说:“进不进线程池”只针对框架替你调用的那一层

六、小结:一张决策表

综合 TL;DR 准则与源码机制,可归纳为:

场景 声明方式 FastAPI 执行方式(源码依据)
需要 await 异步第三方库 async def 直接 await(事件循环内,run_endpoint_function
使用阻塞库(多数数据库驱动等) def 外部线程池执行后再 awaitrun_endpoint_function
完全不等待任何东西 async def 直接 await
不确定 def 线程池执行
纯计算的“简单”函数(性能敏感) async def(FastAPI 下与直觉相反) 避免线程池调度开销
同步 yield 依赖 def __enter__/__exit__ 均走线程池,__exit__ 用独立容量限制防死锁(contextmanager_in_threadpool
自己调用的辅助函数 任选 FastAPI 不介入,按需 await

核心心智模型:FastAPI 的事件循环永不被阻塞——同步代码不是被“忽略”,而是被显式卸载到 AnyIO 线程池中执行;async defdef 的选择本质上是决定“这段工作”运行在事件循环协程里还是线程池线程里。遵循文档给出的决策准则,你就能在 I/O 密集型 Web 场景拿到并发红利,在 CPU 密集型负载上再叠加多进程并行——这正是 FastAPI 兼顾 NodeJS 式并发能力与 Python 数据科学生态的原因。

登录后查看全文
热门项目推荐
相关项目推荐