【学习记录】FastAPI 入门:异步与同步端点的性能差异与并发测试解析
FastAPI 是当前最流行的 Python Web 框架之一,其核心卖点就是高性能 和原生异步支持。但很多初学者对"异步端点"和"同步端点"的实际区别模糊不清。本文通过一个完整的 FastAPI 示例,结合单请求测试和并发压测,直观展示异步 I/O 与同步阻塞的底层原理,并解释为什么 FastAPI 能轻松处理高并发。
📌 目录
一、FastAPI 入门代码
python
import asyncio
from fastapi import FastAPI
import time
app = FastAPI()
# 异步端点
@app.get("/async")
async def async_endpoint():
# 模拟异步 I/O 操作(如异步数据库查询、HTTP 请求)
await asyncio.sleep(1)
return {"message": "异步处理完成"}
# 同步端点(阻塞,但在线程池中运行)
@app.get("/sync")
def sync_endpoint():
# 模拟 CPU 密集型或同步 I/O 操作
time.sleep(1)
return {"message": "同步处理完成"}
# 另一个异步端点,演示并发
@app.get("/async-concurrent")
async def async_concurrent():
# 并发执行 5 个异步任务
tasks = [asyncio.sleep(0.5) for _ in range(5)]
await asyncio.gather(*tasks)
return {"message": "并发异步任务完成"}
代码要点:
async def:定义异步端点,FastAPI 会在事件循环中调度,不阻塞其他请求。await asyncio.sleep(1):模拟非阻塞 I/O 等待(如网络请求、数据库查询)。def(同步函数) :FastAPI 会将同步端点放入线程池执行,但每个请求会占用一个线程,并发能力受限。asyncio.gather:并发执行多个异步任务,总耗时 ≈ 最慢任务耗时,而非任务数之和。
二、单请求测试代码
python
import requests
import time
BASE_URL = "http://127.0.0.1:8000"
def measure_endpoint(url, name):
start = time.perf_counter()
try:
resp = requests.get(url, timeout=10)
elapsed = time.perf_counter() - start
print(f"{name} 响应时间: {elapsed:.4f} 秒 (状态码: {resp.status_code})")
if resp.status_code == 200:
print(f" 响应内容: {resp.json()}")
except Exception as e:
print(f"{name} 请求失败: {e}")
if __name__ == "__main__":
measure_endpoint(f"{BASE_URL}/async", "/async")
measure_endpoint(f"{BASE_URL}/sync", "/sync")
measure_endpoint(f"{BASE_URL}/async-concurrent", "/async-concurrent")
- 作用:依次测试三个端点的单次响应时间。
- 预期结果 :三个端点响应时间都约为 1 秒(
/async-concurrent内部并发 5 个 0.5 秒任务,gather 总耗时为 0.5 秒,但加上函数开销,也接近 0.5 秒)。由于asyncio.sleep只是挂起协程,不阻塞事件循环,所以/async-concurrent会比/async快一倍。
三、并发测试代码
python
import asyncio
import httpx
import time
async def fetch(client, url):
start = time.perf_counter()
await client.get(url)
return time.perf_counter() - start
async def benchmark(url, concurrency):
async with httpx.AsyncClient(timeout=10) as client:
tasks = [fetch(client, url) for _ in range(concurrency)]
start = time.perf_counter()
results = await asyncio.gather(*tasks)
total = time.perf_counter() - start
avg = sum(results) / len(results)
print(f"{url} 并发 {concurrency}: 总耗时 {total:.2f}s, 平均延迟 {avg*1000:.1f}ms")
if __name__ == "__main__":
asyncio.run(benchmark("http://127.0.0.1:8000/async", 50))
asyncio.run(benchmark("http://127.0.0.1:8000/sync", 50))
asyncio.run(benchmark("http://127.0.0.1:8000/async-concurrent", 50))
httpx.AsyncClient:异步 HTTP 客户端,支持并发请求。- 并发 50:同时发送 50 个请求,测量总耗时和平均延迟。
- 原理:异步端点可以在等待 I/O 时切换处理其他请求,所以 50 个请求的总耗时接近单次耗时;同步端点因为每个请求占用一个线程,当并发数超过线程池容量(默认 40)时,请求会排队,总耗时线性增加。
四、运行与结果分析
4.1 运行 FastAPI 服务
bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
4.2 单请求测试结果示例
/async 响应时间: 1.0023 秒 (状态码: 200)
响应内容: {'message': '异步处理完成'}
/sync 响应时间: 1.0015 秒 (状态码: 200)
响应内容: {'message': '同步处理完成'}
/async-concurrent 响应时间: 0.5021 秒 (状态码: 200)
响应内容: {'message': '并发异步任务完成'}
/async和/sync都约为 1 秒(模拟 1 秒延迟)。/async-concurrent约为 0.5 秒(内部 5 个 0.5 秒任务并发执行,总耗时 ≈ 0.5 秒)。
4.3 并发 50 测试结果示例
| 端点 | 并发 50 总耗时 | 平均延迟 | 说明 |
|---|---|---|---|
/async |
1.05 秒 | ~1050 ms | 异步 I/O 非阻塞,所有请求几乎同时完成 |
/sync |
2.3 秒 | ~2300 ms | 同步阻塞,请求排队,总耗时 > 单次耗时 |
/async-concurrent |
0.55 秒 | ~550 ms | 内部并发任务多,但外部请求仍高效处理 |
关键结论:
- 异步端点在高并发下性能稳定,总耗时 ≈ 单次耗时(因为 CPU 主要在等待 I/O)。
- 同步端点当并发数超过工作线程数时,请求会排队,总耗时线性增长。
- 即使
/async-concurrent内部有 5 个 0.5 秒任务,但因为是异步调度,外部并发请求仍能高效处理,不会相互阻塞。
五、原理解析:异步 vs 同步
5.1 传统同步 Web 服务器(如 Flask + Gunicorn)
请求1 → 线程1(sleep 1秒,线程挂起)→ 响应
请求2 → 线程2(sleep 1秒)→ 响应
...
每个请求独占一个线程,线程数量有限,高并发时排队。
5.2 FastAPI 异步端点(事件循环)
事件循环(单线程):
请求1 → 遇到 await asyncio.sleep(1) → 挂起协程,切换到请求2
请求2 → 遇到 await asyncio.sleep(1) → 挂起协程,切换到请求3
...
1 秒后,所有协程恢复,依次返回响应。
核心 :异步 I/O 在等待期间不占用线程,一个线程可以处理成千上万个并发连接。
5.3 FastAPI 同步端点的处理方式
- FastAPI 会自动将同步端点放入线程池(默认 40 个线程)。
- 每个请求占用一个线程,线程池满后新请求排队。
- 适合少量 CPU 密集型任务,但高并发 I/O 场景效率远低于异步。
六、总结与最佳实践
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 数据库查询、HTTP 调用、文件 I/O | 异步 async def |
非阻塞,高并发 |
| 简单计算、少量逻辑 | 同步 def |
无 I/O 等待,同步即可 |
| CPU 密集型计算(如图像处理、加密) | 同步 + 线程池 或 独立进程 | 避免阻塞事件循环 |
| 内部多个独立异步任务 | asyncio.gather |
并发执行,总耗时 ≈ 最慢任务 |
最佳实践建议
- 默认使用
async def,即使当前函数内没有await,也为未来扩展留出空间。 - 避免在异步函数中使用
time.sleep(),应使用await asyncio.sleep(),否则会阻塞整个事件循环。 - 对于 CPU 密集型任务 ,使用
asyncio.to_thread(func)或loop.run_in_executor()将其转移到线程池,避免阻塞事件循环。 - 并发测试 :使用
httpx.AsyncClient模拟真实高并发场景,不要只依赖单请求测试。
本文作为一份完整的学习记录,已在上述内容中详细解析了FastAPI异步与同步端点的原理、测试代码及结果分析。下面补充 "面试官如何问 & 我如何回答" 部分,帮助你在技术面试中更好地展示对异步I/O和FastAPI的理解。
8面试官如何问 & 我如何回答
Q1:FastAPI 中 async def 和 def 路由有什么区别?底层是如何处理的?
面试官期望考察点:对Python异步编程和FastAPI运行机制的理解。
回答思路:
- 区别 :
async def定义的路由会在主事件循环中直接执行,遇到await时会挂起协程,让出控制权处理其他请求,非阻塞 。def定义的路由会被FastAPI自动放到线程池 中执行,每个请求占用一个线程,不阻塞事件循环,但线程池大小(默认40)限制了并发能力。 - 底层 :FastAPI 基于 Starlette(ASGI 框架),利用
asyncio事件循环调度协程。对于同步函数,FastAPI 调用anyio.to_thread.run_sync将其丢入线程池,等待结果返回后通过事件循环发送响应。
示例话术:
"
async def路由适合I/O密集型操作,如数据库查询、网络请求,它不会阻塞事件循环,能在等待期间处理其他请求。而def路由适合CPU密集型或必须使用阻塞库的场景,FastAPI会在线程池中运行它,但并发数受线程池大小限制。底层上,FastAPI利用ASGI和anyio实现了异步与同步的透明切换。"
Q2:如果在 async def 路由中调用了 time.sleep(1),会有什么后果?为什么?
面试官期望考察点:是否清楚异步协程中不能使用阻塞同步代码。
回答思路:
- 后果:整个事件循环会被阻塞1秒,期间无法处理任何其他请求,所有并发连接都会排队等待,性能急剧下降。
- 原因 :
time.sleep(1)是同步阻塞函数,它会挂起当前线程,而异步协程运行在同一个线程的事件循环中。当协程调用time.sleep时,线程被阻塞,事件循环无法继续调度其他协程。
最佳实践 :应使用 await asyncio.sleep(1) 或 anyio.sleep(1) 来模拟非阻塞等待。
Q3:你如何测试一个FastAPI接口的并发性能?用什么工具或方法?
面试官期望考察点:实际性能测试经验。
回答思路:
- 工具 :可使用
httpx.AsyncClient编写自定义压测脚本(如上文提供的并发测试代码),或使用专业压测工具如locust、k6、wrk。 - 方法 :
- 确定测试端点(异步/同步)。
- 设置并发数(如 50、100),总请求数(如 500)。
- 记录总耗时、平均延迟、QPS、错误率等指标。
- 对比不同并发数下的性能表现,分析瓶颈。
示例 :在我提供的并发测试代码中,httpx.AsyncClient 配合 asyncio.gather 可以轻松模拟高并发,并观察到异步端点在并发升高时总耗时基本不变,而同步端点会明显增加。
Q4:FastAPI 的异步能力比 Flask 强在哪里?为什么 Flask 不适合高并发 I/O?
面试官期望考察点:对WSGI与ASGI差异的理解。
回答思路:
- Flask 基于 WSGI(同步网关接口),每个请求独占一个线程,即使使用
async def也无法真正异步(需要额外工具如gevent或Flask[async]实验特性)。高并发 I/O 时会消耗大量线程,线程切换开销大,且受 GIL 限制。 - FastAPI 基于 ASGI(异步网关接口),原生支持
asyncio,事件循环单线程即可处理成千上万并发连接,尤其适合 I/O 密集型任务。
本质:WSGI 是同步协议,ASGI 是异步协议,决定了框架的并发模型。
Q5:项目中你如何选择使用同步路由还是异步路由?给出实际例子。
面试官期望考察点:工程化决策能力。
回答思路:
- 使用异步 :
- 数据库驱动支持异步(如
asyncpg、databases)。 - 调用外部 HTTP API(使用
httpx.AsyncClient)。 - 文件 I/O 使用异步库(
aiofiles)。 - 需要高并发、低延迟的场景。
- 数据库驱动支持异步(如
- 使用同步 :
- 使用同步数据库驱动(如
psycopg2、PyMySQL)。 - 调用同步库(如
requests、boto3)。 - 任务主要是简单计算,无 I/O 等待。
- 使用同步数据库驱动(如
- 混合使用 :将同步阻塞操作放入
async def中时,使用asyncio.to_thread将其移出事件循环,避免阻塞。
实际例子 :某电商秒杀接口需要查询库存(异步Redis)、扣减库存(异步数据库)、记录日志(异步文件)。此时应全部使用异步路由。如果某个环节必须使用同步库(如旧版SDK),则用 asyncio.to_thread 包装。
Q6:asyncio.gather 和 asyncio.wait 有什么区别?在FastAPI中如何使用?
面试官期望考察点 :对 asyncio 并发工具的掌握。
回答思路:
asyncio.gather:并发执行多个可等待对象,返回结果列表(保持顺序)。如果任一任务异常,会向上抛出异常(除非return_exceptions=True)。asyncio.wait:更底层,返回(done, pending)集合,可自定义返回条件(FIRST_COMPLETED、ALL_COMPLETED等),适合更细粒度的任务控制。- 在FastAPI中使用 :通常使用
gather并发调用多个独立异步操作,例如同时查询多个数据源,缩短响应时间。
示例 :上文 /async-concurrent 中就使用了 asyncio.gather 并发执行多个 asyncio.sleep。
Q7:如何避免在异步端点中意外阻塞事件循环?有哪些检测手段?
面试官期望考察点:工程实践和调优能力。
回答思路:
- 避免阻塞 :
- 使用异步库替代同步库(
httpxvsrequests,aiofilesvsopen)。 - 如果必须使用同步阻塞代码,用
asyncio.to_thread或loop.run_in_executor隔离。 - 避免在异步函数中执行 CPU 密集型计算。
- 使用异步库替代同步库(
- 检测手段 :
- 日志记录请求处理前后时间,观察是否存在长时间阻塞。
- 使用
asyncio调试模式(PYTHONASYNCIODEBUG=1),检测未等待的协程。 - 使用性能分析工具(
py-spy、cProfile)定位热点。 - 编写压力测试,观察在并发下总耗时是否异常增加。
Q8:FastAPI 的 background_tasks 是什么?与直接 async def 中 await 有什么区别?
面试官期望考察点:对异步任务处理的理解。
回答思路:
BackgroundTasks:用于在返回响应之后执行轻量级任务(如发送邮件、记录日志),不会让客户端等待。任务在同一个事件循环中执行,但不会阻塞响应返回。- 区别 :直接在
async def中await的任务必须先完成 才能返回响应,客户端会等待。而BackgroundTasks会在发送响应后立即执行,适合非关键路径的后台操作。 - 注意 :
BackgroundTasks不支持长时间运行的任务(应使用任务队列如 Celery)。
🎯 最终思考
FastAPI 的异步能力并非"魔法",而是建立在 Python 的 asyncio 事件循环之上。理解 异步 I/O 的本质------在等待时释放控制权,是写出高性能 Web 服务的关键。本文通过对比异步/同步端点,以及单请求/并发压测,直观展示了异步架构在高并发 I/O 场景下的巨大优势。