FastAPI 里 `async def` 的真正含义

2 阅读17分钟

FastAPI 里 async def 的真正含义

Web 是 I/O 密集的世界——这是 async def 是 FastAPI 默认的真正原因。


写给用过 FastAPI、async 写得动但没完全搞明白机制的人。稍微有点长,整瓶可乐配着看。

前置:Python 基础 + 写过几个接口。OS 调度、GIL 文中都讲——遇到不懂的术语随手查一下别被一个点卡住


用 FastAPI 写接口时,async def 还是 def 是绕不开的选择。

网上搜出来的答案大多是"async def 性能好,能异步就异步"——但没说为什么。这事儿其实跟"性能好不好"关系不大,跟Web 服务到底在干嘛有关系。


1. 为什么要默认 async def

Web 服务里绝大部分时间都在"等"

一个 HTTP 请求的生命周期,真正在 CPU 上跑业务逻辑的时间往往几毫秒,剩下的全在等

  • 等用户从网络把请求体发过来
  • 等数据库返回查询结果
  • 等外部 API 响应
  • 等 Redis 拿到缓存

把这些"等"加起来,单个请求从进入到返回的几百毫秒里,典型 Web 服务 80-95% 都在等(具体比例看业务:纯 CRUD 后端偏 95%,复杂业务逻辑可能 70%)。这种"大部分时间在等"的工作负载,叫 I/O 密集型

async def 的全部价值,就是让程序在等的这段时间里去做别的事。单个 worker 进程能同时处理的请求数从几十(线程池上限)跃升到几万——差别是数量级,不是百分比。

所以默认推荐 async def 没什么好犹豫的:它针对的就是 Web 这个场景


2. async defdef 的真实区别

理解了上面那个前提,后面的机制就顺了。

写法实际跑在哪一次能扛多少
async def事件循环(单线程)受内存 / fd 上限约束,1 万到几万
def外部线程池(默认 40 个并发)40 个并发

单 worker 的内部结构:

FastAPI 单 Worker
├── 主线程
│   └── 事件循环
│       ├── 协程 1 (async def 请求 1)
│       ├── 协程 2 (async def 请求 2)
│       └── 协程 N (async def 请求 N)
└── 外部线程池(默认 40 个并发)
    ├── 线程 1 (def 请求 A)
    └── 线程 2 (def 请求 B)

几个数字

  • 协程很轻量(几 KB 内存),开 10000 个没问题
  • 线程很重(每个 ~1MB 栈),开 10000 个能吃光机器内存
  • 线程池有上限(默认 40),超过的要排队

所以同一个进程,async def 路由能并发处理的数量是 def 路由的几十倍到几千倍——不是"快一点",是"数量级"的差别。

关于"40"这个数字的来源——网上很多博客(包括很多年前的 FastAPI 教程)会直接说"线程池大小 40",但严格说不太准确

  • FastAPI 不直接管线程池,是 Starlette 通过 run_in_threadpool 实现的
  • run_in_threadpool 走 anyio 的 to_thread.run_sync
  • anyio 有个默认线程限制器(default thread limiter),默认值为 40
  • 这个 40 控制的是"同时能有几个线程任务在跑",不是线程池本身大小

anyio 官方文档(anyio.readthedocs.io/en/stable/t…)原话:

The default AnyIO worker thread limiter has a value of 40, meaning that any calls to to_thread.run_sync() without an explicit limiter argument will cause a maximum of 40 threads to be spawned.

一般不用动——40 对大多数 I/O 密集的服务够用。高并发场景再调。


3. await 内部到底发生了什么

await 后面分 4 层——微观、物理基础、调度机制、状态机:

  1. 微观await 到底干了什么(让出 + 等结果)
  2. 物理基础:为什么能扛高并发(OS 的 epoll 机制)
  3. 调度机制:单线程事件循环怎么赢 40 线程(OS + GIL)
  4. 状态机:BLOCKED 线程根本不占 CPU(线程三态)

跑这段代码:

import asyncio
import time

async def task(name: str, delay: float):
    print(f"{name} 开始")
    await asyncio.sleep(delay)
    print(f"{name} 结束")

async def main():
    start = time.time()
    await asyncio.gather(
        task("A", 1),
        task("B", 1),
        task("C", 1),
    )
    print(f"3 个 1 秒的 sleep,总共耗时: {time.time() - start:.2f}s")

asyncio.run(main())

输出:

A 开始
B 开始
C 开始
A 结束
B 结束
C 结束
31 秒的 sleep,总共耗时: 1.00s

3 个 1 秒的 sleep,总共只花了 1 秒。这就是 await 在干的事——"等"的时候让出,让别人先跑

await 微观:让出 + 等结果

只有两件事:

  1. 让出——当前协程暂停,事件循环去跑别的协程
  2. 等结果——结果到了,协程唤醒继续

对于 asyncio.sleep(1):等 1 秒后被事件循环叫醒 对于 await sock.recv():等 socket 数据回来被叫醒

事件循环不在乎你"等什么"——它只管一件事:有没有 callback 该跑了。可能是定时器到点了(asyncio.sleep),可能是 socket 有数据了(await sock.recv),可能是你刚 create_task() 排的任务——所有来源汇到同一个 ready 队列,循环里挨个跑

asyncio.sleep(1)await sock.recv() 在事件循环眼里结构上一模一样——

  • 都是"注册一个事件" + "让出" + "事件触发唤醒"
  • 区别只是注册到哪个事件源:asyncio.sleep 注册到定时器队列,sock.recv 注册到 epoll

物理基础:epoll 怎么扛高并发

类比:你跟快递员说"5 个包裹,哪个先到你给我打电话",然后你在家睡觉。线程睡了= 你在睡觉,OS 同时等所有 socket= 快递员替你盯 5 个。这就是"阻塞 + 同时等一堆"的真正含义——你睡了,OS 替你高效地等。

asyncio 在 Linux 上用的是 OS 提供的 epoll 机制(一组系统调用:epoll_create 建实例、epoll_ctl 注册 socket、epoll_wait 阻塞等数据)——macOS 是 kqueue,Windows 是 IOCP。

一个 await sock.recv() 触发的 8 步过程

步骤Python 层(事件循环 + 协程)OS 层(epoll)
1await sock.recv() 触发
2把 socket 设为非阻塞
3把 socket 注册到 epollepoll 收到注册
4协程让出
5事件循环调 epoll_wait()epoll_wait 阻塞,同时等所有 socket
6socket X 有数据了,通知事件循环
7找到对应协程,唤醒
8协程从 await 那行继续执行

"epoll_wait 阻塞 + 同时等一堆 socket" 反直觉——展开看 3 种做法对比:

做法线程状态CPU 占用服务能力
朴素阻塞(一个 socket 一个线程)卡死等 socket 10%一次只等 1 个
忙轮询(不停遍历所有 socket)满负荷跑100%等 N 个但烧 CPU
epoll(OS 替你等)睡死0%等 N 个且省 CPU

epoll_wait第三种。线程睡着了,但 OS 在底层用网卡中断 + 内核缓冲区这两套硬件级机制替你盯着所有 socket:

  • 网卡中断:数据包到了,网卡硬件直接发信号通知 CPU——不需要进程轮询
  • 内核缓冲区:数据先暂存在 OS 内核的内存里,进程之后从 recv()

哪个先到就唤醒你——你(线程)从头到尾不用动,OS 在背后同时跑着

epoll_wait 虽然阻塞,但 CPU 不烧——进程睡了,OS 在底层用硬件级机制(网卡中断 + 内核缓冲区)高效监听。这就是 1 个事件循环能扛 N 个并发的机制

OS + GIL:1 个事件循环怎么赢 40 个线程

OS 调度器 + Python GIL 两层叠加:

OS 调度器看 41 个普通线程(1 个事件循环 + 40 个线程池),公平轮转:

时刻 1: 线程 7  10ms
时刻 2: 线程 23  10ms
时刻 3: 线程 1  10ms
...

Python GIL 在 OS 调度之上又加了一层"抢锁"——同一时刻只有 1 个线程能跑 Python 代码:

时刻 1: 线程 7 抢到 GIL   Python
时刻 2: 线程 7 释放  线程 23 抢到   Python
时刻 3: 线程 23 释放  线程 1 抢到   Python
...

OS 给 41 个线程公平分 CPU但 GIL 让 Python 代码串行执行。结果:

维度实际效果
CPU 时间总和41 线程平分(OS 公平调度)
真正跑 Python 的时间1 线程的水平(GIL 串行)
多出来的开销GIL 抢锁 / 释放 / OS 切换

CPU 密集场景:41 线程抢 CPU → GIL 强制串行 → 41 线程 ≈ 1 线程 + 一堆切换开销。这就是为什么 CPU 任务必须用多进程——只有进程能绕过 GIL。

I/O 场景:N 线程等 I/O → OS 标 BLOCKED → 事件循环线程独享 CPU所以单线程 + 协程反而赢了——OS 跳过等 I/O 的线程。

41 线程在抢同一把 GIL 锁——抢到了就跑几行 Python,没抢到就 sleep,周而复始。OS 还在轮转这 41 个线程(其他线程并不是完全不跑——它们偶尔抢到 GIL 也能跑几行),但事件循环常驻 RUNNABLE 状态、抢 GIL 抢得最勤——绝大部分 Python 执行时间归事件循环示意数字,实际比例取决于 GIL 切换频率)。

两层叠加的效果

状态OS 行为GIL 行为实际效果
多数线程等 I/O跳过等 I/O 的线程事件循环独享 CPU
多数线程跑 CPU公平轮转(41 个)只让 1 个跑 Python多线程 ≈ 单线程

所以 I/O 密集选 async,不是 async 比线程强——是 OS + GIL 这两层限制"单线程事件循环 + 协程"成为最优解。async 这个机制,本来就是为利用这两层特性而设计的——Guido 设计 asyncio 时就是冲着这个场景去的。CPU 密集选多进程,也不是进程本身神奇——是只有进程能绕过 GIL(每个进程有独立 GIL)。两者都是"绕开限制"——async 绕的是"线程等 I/O 占资源",多进程绕的是"GIL 锁死 CPU"。

线程状态机:BLOCKED 线程根本不占 CPU

前提:这一节讨论的是 I/O 密集场景——线程池里的线程在跑阻塞 I/O 任务(SQLAlchemy 同步查询、requests 调用、文件 I/O 等),这是 FastAPI def 路由的典型场景。如果是 CPU 任务(图像处理、加密、压缩),情况完全不同——线程会一直 RUNNABLE 抢 CPU,参见第 6 节。

OS 调度器眼里,线程有三种状态

状态含义占 CPU 吗
RUNNING正在 CPU 上跑
RUNNABLE准备好跑,等 CPU 时间片不占(等轮到自己)
BLOCKED在等 I/O、锁、信号等不参与调度

线程做阻塞 I/O(比如 socket.recv()requests.get()time.sleep())的瞬间,OS 把它标成 BLOCKED,从运行队列里挪走CPU 根本不会调度它

完整的等待过程

时刻 1:  线程池线程 1  socket.recv()   BLOCKED  不占 CPU
时刻 2:  事件循环线程在跑(CPU 闲着没人抢)
时刻 3:  数据到了  线程 1  RUNNABLE  但还要等 CPU 时间片
时刻 4:  线程 1 抢到 CPU  解析响应   ms
时刻 5:  线程 1 又调别的 I/O   BLOCKED  不占 CPU

注意第 4 步:线程被唤醒后确实需要 CPU 来处理数据——但只是几毫秒(解析响应、回调),然后又阻塞了

线程池的 40 线程大部分时间是 BLOCKED 状态(典型 I/O 密集请求 50-200ms 等待,1-10ms CPU 处理;下面 5%/95% 是示意数字,实际比例随业务变化):

状态占比占不占 CPU
RUNNABLE(在跑或等 CPU)~5%
BLOCKED(等 I/O)~95%不占

真实画面是 1 个事件循环线程在干活 + 40 个"等 I/O 的线程"

[事件循环线程]  ← 在跑 CPU,处理 1000 个协程的业务逻辑
[线程 1]        ← BLOCKED,等数据库
[线程 2]        ← BLOCKED,等数据库
[线程 3]        ← BLOCKED,等数据库
...
[线程 40]       ← BLOCKED,等数据库

真正跑 Python 代码的线程始终只有 1 个(GIL 决定)——这条对所有情况都一样。

区别不在"几个真线程在跑 Python"(那是 1:1),在"挂起中的协程数" vs "等 I/O 的真线程数"

方案真线程挂起任务
事件循环 + 协程1 个1 万个挂起协程
线程池40 个40 个挂起请求

真线程数比 1:40,挂起任务数比 1 万:40——async 赢的根本原因:用 1 个真线程承载 1 万个挂起任务

异步能赢,不是因为 CPU 强——1 个事件循环线程能同时管 1 万个挂起中的协程,没有"线程数 = 上限"的硬限制;而 40 个线程池线程只能管 40 个请求(线程数限制)。"等 = 占 CPU"是直觉上的误解——等 I/O 的线程 OS 跳过,根本不参与调度。


4. 致命陷阱:async def 配同步库

await 的协程让出机制只在调用链里所有函数都"协作"时才有意义。一旦中间夹了一个同步阻塞调用:

import asyncio
import time  # time.sleep 是同步阻塞的

async def bad():
    print("开始")
    time.sleep(1)  # 同步阻塞 1 秒 → 整个事件循环卡 1 秒
    print("结束")

同步阻塞调用不会让出控制权——time.sleep(1) / socket.recv() 之类都是。这 1 秒内整个事件循环被卡住——其他所有协程(哪怕是正在等的)都跑不了。

如果中间是 requests.get 调外部 API(同样同步阻塞),效果完全一样。

测试一下:

async def main():
    start = time.time()
    await asyncio.gather(bad(), bad(), bad())
    print(f"耗时: {time.time() - start:.2f}s")

asyncio.run(main())

输出:

开始
结束
开始
结束
开始
结束
耗时: 3.0s   # 串行,不是并发的 1.0s

3 个请求串行执行。异步的优势完全消失,甚至比纯同步还慢——多了协程调度的开销。

所以文档那两句话不是矛盾,是两条独立的安全护栏

  • "用 async def" → 默认推荐,享受异步红利
  • "拿不准用 def" → 安全网,防止你把同步库塞进 async 路由

async def 配同步库是异步代码里最糟糕的组合——不如直接用 def 路由,至少不会卡事件循环。


5. 一个常被忽略的坑:工具函数

async def / def 的规则有个隐藏点很多人没注意——工具函数不受 FastAPI 调度

类型async def 跑在哪def 跑在哪
路径操作函数(路由)事件循环外部线程池
依赖项(Depends)事件循环外部线程池
工具函数(自己写的小函数)你自己 await你自己直接调(不进线程池

第三个最容易翻车。例:

# 工具函数 - def 不会自动扔线程池
def read_file():
    with open("big.txt") as f:  # 同步阻塞
        return f.read()

如果 async def 路由里调它:

from fastapi import FastAPI

app = FastAPI()

@app.get("/data")
async def get_data():
    content = read_file()  # 同步调用,卡当前事件循环
    return {"content": content}

FastAPI 不会帮你把 read_file 扔到线程池——它是普通函数调用,卡在事件循环里,所有协程一起卡

解法只有两个:

# 方法 1:用异步库
import aiofiles
async def read_file():
    async with aiofiles.open("big.txt") as f:
        return await f.read()

# 方法 2:手动扔线程池
import asyncio
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor()
async def get_data():
    content = await asyncio.get_running_loop().run_in_executor(
        executor, read_file
    )
    return {"content": content}

工具函数是 async def 路由里最隐蔽的坑——它不报错、代码能跑,但事件循环会卡。


6. CPU 密集型怎么办

async 解决的是 I/O 等待,对 CPU 密集任务无效——单线程串行跑,协程帮不上忙。

CPU 密集任务的例子:

  • 图像处理(每像素计算)
  • 视频转码
  • ML 推理
  • 加密解密、压缩
  • 大规模数据分析

这类任务的特点:没"等"的动作,CPU 一直在算。处理方式只有一种——多进程

from concurrent.futures import ProcessPoolExecutor
import asyncio
from fastapi import FastAPI

app = FastAPI()
process_pool = ProcessPoolExecutor(max_workers=4)


def cpu_heavy(n: int) -> int:
    return sum(i * i for i in range(n))


@app.get("/cpu")
async def cpu_route(n: int = 10_000_000):
    loop = asyncio.get_running_loop()
    result = await loop.run_in_executor(process_pool, cpu_heavy, n)
    return {"result": result}

必须用进程——Python 有 GIL(全局解释器锁),同一时刻只有一个线程能跑 CPU 代码。线程再多也只用一个核。只有多进程能真正利用多核——每个进程独立 Python 解释器、独立 GIL。

社区说的"违背祖宗的决定"——Python 核心团队已经在做了。3.13+ 推的 free-threaded 模式(PEP 703)就是——编译时关 GIL,线程就能真正跑 CPU 任务了。

不过别急现在(2026)这个特性还是实验性的,NumPy、Pandas、SQLAlchemy 这些老大哥还在适配,多进程仍然是稳妥选择

线程能重新用上?再等 1-2 年——这是 Python 历史上最大的语言级改动,落地没那么快。先把多进程玩明白,真到那天你自然会切过去


7. 怎么选你的库

async def 路由里能用的库,得是支持异步的版本

怎么判断一个库是同步还是异步——直接调用一下,看返回类型:

import requests
import httpx

# 同步 - 调用直接返回 Response 对象
r1 = requests.get("https://www.baidu.com")
print(type(r1))
# 输出: <class 'requests.Response'> → 同步

# 异步 - 调用返回 coroutine 对象
r2 = httpx.AsyncClient().get("https://www.baidu.com")
print(type(r2))
# 输出: <class 'coroutine'> → 异步,必须 await

或者直接 await 试一下——报错就是同步

import requests

r = requests.get("https://www.baidu.com")
await r
# ❌ TypeError: object Response can't be used in 'await' expression

requests.get(url) 立刻发请求,httpx.AsyncClient().get(url) 返回 coroutine(不 await 不发请求)。所以第一个例子会真发请求(看 Response 类型),第二个不会(只创建未执行的协程)。两个都能用来判断类型,但行为不同。

同步异步
requestshttpx.AsyncClient / aiohttp
psycopg2asyncpg / psycopg 3 async
redis-py 默认redis.asyncio
pymysqlaiomysql / asyncmy
sqlite3aiosqlite
open()aiofiles
time.sleepasyncio.sleep

项目里已经有同步库——用 def 路由。FastAPI 会自动扔线程池,至少不会卡事件循环。

def 迁到 async def——渐进式:先找出最慢的接口,确认依赖库都有 async 版本,迁一个测一个,不要全盘重写。


8. 什么时候别用 async

前面几节都在讲 async 的好处——但 async 不是免费的

用 async 不是"async def 一写就完事",是一整套技术栈要换

维度syncasync
HTTP 客户端requestshttpx.AsyncClient
数据库psycopg2 / SQLAlchemyasyncpg / SQLAlchemy async
Redisredis-pyredis.asyncio
文件 I/Oopen()aiofiles
测试TestClientAsyncClient + pytest-asyncio
排错普通 stack trace栈断在 await
学习曲线大部分人会只有一部分人会

任何一个环节不 async,整条链路就破。"async def 配同步库 = 灾难"——这种坑在中等项目里能 debug 半天。

sync 不是"过时",是"够用"。多数业务系统一辈子到不了 async 的必要线:

  • 内部系统、CRUD 后台 → sync 完全够
  • 中小型 API → sync + 多 worker 扛得住
  • 大流量网关 → 这才轮到 async

新项目怎么选?看两件事:预估日活团队 async 熟练度。两个都低,老老实实 sync——把索引、压测、连接池做扎实,比追 async 收益高 10 倍。

老项目呢?别为了"用上 async"而重构。等 sync 真的成了瓶颈(CPU 100%、队列堆积、连接打满)再说。async 不会让你飞,只是让你能扛的并发高一个数量级——你要是根本没那个量,追了也是白追

async 是优化手段,不是默认姿态。用 sync 写出干净的代码,比用 async 写出一堆坑强。


总结

一个 FastAPI worker 怎么处理请求:

请求进来
  ↓
进程的事件循环拿到
  ↓
路由匹配
  ├── async def → 创建协程 → 事件循环调度
  └── def → 扔外部线程池
  ↓
协程/线程内部:
  ├── 调异步库 → await 让出 → 事件循环跑别的 → 唤醒继续
  └── 调同步库 → 阻塞 → 整个事件循环卡死(async)/ 占一个线程(def)
  ↓
返回 HTTP 响应

Web 是 I/O 密集的世界——async def 是 FastAPI 的默认姿态。协程让出机制要全链协作——async def 配同步库是最糟组合。CPU 密集用不上 async——上多进程。

面试被问 "FastAPI 怎么选 async def 还是 def" 怎么答:Web 服务是 I/O 密集,所以 async def 是默认。async def 让单个 worker 能并发处理上万个请求,而 def 路由最多 40 个(线程池上限)。但前提是路由里调用的库是异步的——async def 配同步库(requests、psycopg2)会让整个事件循环卡死,比纯同步还慢。项目里没异步库就用 def 路由,让 FastAPI 扔到线程池;CPU 密集型任务用 ProcessPoolExecutor 多进程。