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 def 和 def 的真实区别
理解了上面那个前提,后面的机制就顺了。
| 写法 | 实际跑在哪 | 一次能扛多少 |
|---|---|---|
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 explicitlimiterargument will cause a maximum of 40 threads to be spawned.
一般不用动——40 对大多数 I/O 密集的服务够用。高并发场景再调。
3. await 内部到底发生了什么
await 后面分 4 层——微观、物理基础、调度机制、状态机:
- 微观:
await到底干了什么(让出 + 等结果) - 物理基础:为什么能扛高并发(OS 的 epoll 机制)
- 调度机制:单线程事件循环怎么赢 40 线程(OS + GIL)
- 状态机: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 结束
3 个 1 秒的 sleep,总共耗时: 1.00s
3 个 1 秒的 sleep,总共只花了 1 秒。这就是 await 在干的事——"等"的时候让出,让别人先跑。
await 微观:让出 + 等结果
只有两件事:
- 让出——当前协程暂停,事件循环去跑别的协程
- 等结果——结果到了,协程唤醒继续
对于 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) |
|---|---|---|
| 1 | await sock.recv() 触发 | — |
| 2 | 把 socket 设为非阻塞 | — |
| 3 | 把 socket 注册到 epoll | epoll 收到注册 |
| 4 | 协程让出 | — |
| 5 | 事件循环调 epoll_wait() | epoll_wait 阻塞,同时等所有 socket |
| 6 | — | socket X 有数据了,通知事件循环 |
| 7 | 找到对应协程,唤醒 | — |
| 8 | 协程从 await 那行继续执行 | — |
"epoll_wait 阻塞 + 同时等一堆 socket" 反直觉——展开看 3 种做法对比:
| 做法 | 线程状态 | CPU 占用 | 服务能力 |
|---|---|---|---|
| 朴素阻塞(一个 socket 一个线程) | 卡死等 socket 1 | 0% | 一次只等 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 类型),第二个不会(只创建未执行的协程)。两个都能用来判断类型,但行为不同。
| 同步 | 异步 |
|---|---|
requests | httpx.AsyncClient / aiohttp |
psycopg2 | asyncpg / psycopg 3 async |
redis-py 默认 | redis.asyncio |
pymysql | aiomysql / asyncmy |
sqlite3 | aiosqlite |
open() | aiofiles |
time.sleep | asyncio.sleep |
项目里已经有同步库——用 def 路由。FastAPI 会自动扔线程池,至少不会卡事件循环。
从 def 迁到 async def——渐进式:先找出最慢的接口,确认依赖库都有 async 版本,迁一个测一个,不要全盘重写。
8. 什么时候别用 async
前面几节都在讲 async 的好处——但 async 不是免费的。
用 async 不是"async def 一写就完事",是一整套技术栈要换:
| 维度 | sync | async |
|---|---|---|
| HTTP 客户端 | requests | httpx.AsyncClient |
| 数据库 | psycopg2 / SQLAlchemy | asyncpg / SQLAlchemy async |
| Redis | redis-py | redis.asyncio |
| 文件 I/O | open() | aiofiles |
| 测试 | TestClient | AsyncClient + 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 多进程。