深入理解Python Web框架:从Flask到FastAPI的并发之路
前言
这篇是 Python 基础合集的学习笔记,这次整理的是 Python Web 框架的并发模型。
说实话,刚开始学 Python Web 开发的时候,我也是一头雾水。Flask 是同步的,FastAPI 是异步的------这话听了无数遍,但到底啥意思?GIL 不是说 Python 多线程是假的吗?那 Flask 的多线程又是怎么回事?async 函数里为啥必须用异步库?
这些问题困扰了我挺长一段时间。后来花了不少功夫,总算把这些概念串起来了。今天就把我的理解分享出来,希望能帮你少走点弯路。
🏠个人主页:山沐与山
文章目录
- 一、从一个餐厅的故事说起
- 二、GIL------理解Python并发的钥匙
- 三、Flask的工作方式------多线程模型
- 四、FastAPI的工作方式------协程与事件循环
- 五、为什么async函数里必须用异步库
- 六、FastAPI的线程池是干嘛的
- 七、Flask为什么不能直接加async
- 八、性能对比与选型建议
- 九、总结
一、从一个餐厅的故事说起
在深入技术细节之前,先用一个生活中的例子来理解同步和异步。别急着跳过,这个例子后面会反复用到。
1.1 同步模式:一个"死脑筋"的服务员
想象一家餐厅,服务员小张的工作方式是这样的:
客人A点餐 → 小张去厨房等菜做好 → 端菜给A → 才能服务下一桌
时间线:
客人A: |点餐|----等待20分钟----|上菜|
客人B: |点餐|----等待20分钟----|上菜|
客人C: |点餐|...
三桌客人总耗时:60分钟
小张在厨房干等着,什么都不干。你想想,这效率得多低?但他就是这么死脑筋------一件事没做完,绝不开始下一件。这就是同步阻塞。
1.2 异步模式:一个"会来事"的服务员
现在小张开窍了:
客人A点餐 → 小张把单子给厨房 → 不等!去服务客人B
客人B点餐 → 小张把单子给厨房 → 不等!去服务客人C
客人C点餐 → 小张把单子给厨房
厨房喊"A的菜好了" → 小张端菜给A
厨房喊"B的菜好了" → 小张端菜给B
...
时间线:
客人A: |点餐|----等待----|上菜|
客人B: |点餐|----等待----|上菜|
客人C: |点餐|----等待----|上菜|
三桌客人总耗时:约25分钟
看到没?小张在等待的时候去做别的事了,效率一下子就上来了。这就是异步非阻塞。
1.3 映射到Web服务器
把这个餐厅的例子对应到 Web 服务器:
| 餐厅 | Web服务器 |
|---|---|
| 服务员 | 线程/协程 |
| 客人点餐 | 收到HTTP请求 |
| 厨房做菜 | 数据库查询/调用外部API |
| 等待上菜 | 等待I/O响应 |
| 服务完成 | 返回HTTP响应 |
Web 应用大部分时间都在干嘛?等!等数据库返回,等 Redis 响应,等第三方 API 回复。如果你的服务员(线程)傻等着,那资源就白白浪费了。
二、GIL------理解Python并发的钥匙
要理解 Python 的并发,必须先搞懂一个东西:GIL(全局解释器锁)。这玩意儿是很多 Python 并发问题的根源。
2.1 GIL是什么
GIL = Global Interpreter Lock = 全局解释器锁
规则很简单:同一时刻,只有一个线程能执行 Python 字节码
就这么一句话,但理解它需要点背景知识。
2.2 为什么Python要设计这个东西
这要从 1991 年说起。那时候:
- CPU 都是单核的,多核处理器要到 2005 年才开始普及
- Python 使用引用计数来管理内存
python
# Python 的内存管理:引用计数
a = [] # 创建列表,引用计数 = 1
b = a # b也指向这个列表,引用计数 = 2
del b # 删除b,引用计数 = 1
del a # 删除a,引用计数 = 0 → 内存被释放
如果没有 GIL,两个线程同时修改引用计数会出问题:
线程1: 读取引用计数(1) → 准备+1
线程2: 读取引用计数(1) → 准备+1
线程1: 写入引用计数(2)
线程2: 写入引用计数(2)
结果:引用计数 = 2,但实际应该是 3!
内存管理直接乱套
所以 Python 加了 GIL:同一时刻只有一个线程能跑 Python 代码,引用计数就不会乱。简单粗暴,但有效。
2.3 关键秘密:I/O时会释放GIL
这是很多人不知道的:当线程进行 I/O 操作时,会释放 GIL。
python
import requests
def fetch_data():
response = requests.get("http://api.com") # 发起网络请求
# 这里!当请求发出去后,线程在等待网络响应
# 此时 GIL 会被释放,其他线程可以执行
return response
让我画个图:
时间线 ────────────────────────────────────────────────────>
线程1: |执行Python代码| |.....等待网络.....| |执行Python代码|
↓ 持有GIL ↓ 释放GIL ↓ 持有GIL
线程2: (等待GIL) |执行代码| |..等待DB..| (等待GIL)
↓ ↓ 释放GIL
持有GIL
线程3: (等待GIL) (等待GIL) |执行代码| |..等待文件..|
↓
持有GIL
关键观察:
- "执行Python代码" → 永远只有1个线程(GIL限制)
- "等待I/O" → 可以多个线程同时等(不需要GIL)
2.4 一句话总结GIL
┌─────────────────────────────────────────────────────────────┐
│ │
│ Python多线程的真相: │
│ │
│ 线程是真的 → 操作系统级别的真线程 │
│ GIL是真的 → 同一时刻只有1个线程能执行Python代码 │
│ I/O等待是真并发 → 多个线程可以同时等待I/O │
│ │
│ 所以:"执行代码"是假并行,"等待I/O"是真并发 │
│ │
└─────────────────────────────────────────────────────────────┘
这一点理解了,后面的内容就好懂多了。
三、Flask的工作方式------多线程模型
理解了 GIL,我们来看 Flask 是怎么处理并发的。
3.1 Flask的基本模型
python
from flask import Flask
app = Flask(__name__)
@app.route('/user/<int:id>')
def get_user(id):
user = db.query(f"SELECT * FROM users WHERE id = {id}") # 同步查询
return {"user": user}
Flask 使用 WSGI 协议 (Web Server Gateway Interface),这是一个同步协议 。2003 年设计的时候,Python 还没有 async/await 这些东西。
3.2 Flask处理并发的方式
Flask 自己不做并发,它把这事儿交给 WSGI 服务器来处理:
Flask 并发模型
┌─────────────────────────────────┐
│ WSGI 服务器 │
│ (gunicorn / uwsgi) │
└───────────────┬─────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 线程1 │ │ 线程2 │ │ 线程3 │
│ │ │ │ │ │
│ 处理请求A│ │ 处理请求B│ │ 处理请求C│
└─────────┘ └─────────┘ └─────────┘
每个请求分配一个线程,线程内部同步执行
你用 gunicorn -w 4 --threads 4 启动 Flask 应用,就是 4 个进程,每个进程 4 个线程,一共 16 个并发处理单元。
3.3 多线程的实际执行过程
结合前面讲的 GIL:
请求A → 线程1: |执行代码|----等待数据库----|执行代码|返回|
请求B → 线程2: |执行代码|---等待---|执行代码|返回|
请求C → 线程3: |执行代码|---等待---|返回|
由于GIL:
- "执行代码"部分:线程轮流执行(假并行)
- "等待数据库"部分:真的可以同时等(真并发)
所以 Flask 的多线程不是完全没用------在等待 I/O 的时候,多个线程确实是"同时"在等的。
3.4 Flask多线程的代价
但这种方式有问题:
| 问题 | 说明 |
|---|---|
| 内存开销大 | 每个线程约 8MB 内存(线程栈),1000 并发 = 8GB 内存 |
| 切换开销 | 线程切换是操作系统级别的,有上下文切换成本 |
| 线程本身占资源 | 线程等待时虽然释放了 GIL,但线程本身还占着内存 |
你可能会想:Flask 这样用多线程处理 I/O 等待,和协程的效果差不多啊?
没错!某种意义上,Flask 是在用"重量级工具"(线程)做"轻量级的事"(等 I/O)。这就引出了 FastAPI 的方案。
四、FastAPI的工作方式------协程与事件循环
4.1 FastAPI的基本模型
python
from fastapi import FastAPI
app = FastAPI()
@app.get('/user/{id}')
async def get_user(id: int):
user = await db.fetch_one(f"SELECT * FROM users WHERE id = {id}")
return {"user": user}
FastAPI 使用 ASGI 协议(Asynchronous Server Gateway Interface),专门为异步设计。
4.2 事件循环是什么
事件循环 (Event Loop)是 asyncio 的核心,可以理解为一个"超级调度员",在单线程里管理所有任务:
python
# 事件循环的本质(伪代码)
def 事件循环():
任务队列 = []
while True:
for 任务 in 任务队列:
if 任务.准备好了():
执行任务一小步()
if 任务.遇到了await():
# 任务说"我要等东西,先跳过我"
把任务标记为等待中()
if 任务.完成了():
从队列移除(任务)
# 检查等待中的任务有没有等到想要的东西
检查IO完成情况()
4.3 用一个真实例子理解事件循环
python
import asyncio
async def 任务A():
print("[A] 开始")
await asyncio.sleep(2) # A说:我要等2秒,先忙别的去
print("[A] 结束")
async def 任务B():
print("[B] 开始")
await asyncio.sleep(1) # B说:我要等1秒,先忙别的去
print("[B] 结束")
async def main():
await asyncio.gather(任务A(), 任务B())
asyncio.run(main())
事件循环的执行过程:
[0.0秒] 执行任务A,打印"[A] 开始"
[0.0秒] 任务A遇到 await sleep(2),暂停A,设置2秒后唤醒
[0.0秒] 切换到任务B,打印"[B] 开始"
[0.0秒] 任务B遇到 await sleep(1),暂停B,设置1秒后唤醒
[0.0秒] 没有可执行的任务了,事件循环等待...
[1.0秒] 任务B的sleep到期,唤醒B
[1.0秒] 任务B打印"[B] 结束",任务B完成
[2.0秒] 任务A的sleep到期,唤醒A
[2.0秒] 任务A打印"[A] 结束",任务A完成
输出顺序:
[A] 开始
[B] 开始
[B] 结束 ← 1秒后
[A] 结束 ← 2秒后
总耗时:2秒(不是3秒!两个任务"同时"在等)
看到没?关键就在 await 这个关键字------它告诉事件循环"我要等东西,你先忙别的去"。
4.4 FastAPI架构图
FastAPI 请求处理架构
┌─────────────────────────┐
│ 客户端请求 │
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ ASGI服务器(uvicorn) │
│ 事件循环驱动 │
│ (单线程) │
└───────────┬─────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ async 函数 │ │ async 函数 │ │ 普通 def 函数 │
│ │ │ │ │ │
│ 事件循环直接 │ │ 事件循环直接 │ │ ↓ │
│ 调度执行 │ │ 调度执行 │ │ 自动放入 │
└───────────────┘ └───────────────┘ │ 线程池执行 │
└───────────────┘
五、为什么async函数里必须用异步库
这是一个很多人踩过的坑,而且踩了之后还不一定知道问题在哪。
5.1 错误示范
python
import requests # 同步库
@app.get('/bad')
async def bad_example():
# 灾难!requests 是同步库,会阻塞整个事件循环!
response = requests.get("http://api.com")
return response.json()
5.2 发生了什么
事件循环(只有1个线程):
时间线 ──────────────────────────────────────────────────────>
请求A: |--执行--|=========同步请求阻塞========|--返回--|
请求B: 被卡住,动不了...
请求C: 被卡住,动不了...
请求D: 被卡住,动不了...
问题:同步库 requests 把唯一的事件循环线程卡死了!
所有其他请求都得排队等!
为什么会这样?因为 requests.get() 是同步的,它不会说"我要等东西,你先忙别的去",它就是死等,把整个线程卡住。
而 FastAPI 的事件循环就跑在这一个线程上,线程被卡住了,事件循环也就停了。
5.3 正确示范
python
import httpx # 异步库
@app.get('/good')
async def good_example():
# 正确!httpx 是异步库,等待时会让出控制权
async with httpx.AsyncClient() as client:
response = await client.get("http://api.com")
return response.json()
5.4 发生了什么
事件循环(还是只有1个线程):
请求A: |执行| ------await等待------ |拿到结果|返回|
请求B: |执行| ---await等待--- |结果|返回|
请求C: |执行| ---await等待--- |结果|返回|
关键:await 的时候,控制权交还给事件循环
事件循环就可以去处理其他请求了!
5.5 核心理解
┌──────────────────────────────────────────────────────────────┐
│ │
│ 事件循环只有 1 个线程在跑 │
│ │
│ 同步库 = 你霸占着这个线程傻等,别人都得等你 │
│ 异步库 = 你说"我要等东西,但我先让出来,好了叫我" │
│ │
│ 所以:async 函数里用同步库 = 伪异步 = 性能灾难! │
│ │
└──────────────────────────────────────────────────────────────┘
5.6 常见同步库与异步库对照
| 同步库 | 异步库 | 用途 |
|---|---|---|
requests |
httpx / aiohttp |
HTTP请求 |
psycopg2 |
asyncpg |
PostgreSQL |
PyMySQL |
aiomysql |
MySQL |
redis-py |
aioredis / redis.asyncio |
Redis |
time.sleep() |
asyncio.sleep() |
延时 |
open() |
aiofiles |
文件操作 |
六、FastAPI的线程池是干嘛的
你可能注意到了,FastAPI 里可以写普通的 def 函数,不一定要 async def:
python
@app.get('/sync')
def sync_endpoint(): # 没有 async
time.sleep(1) # 同步阻塞操作
return {"type": "sync"}
咦?这样不会阻塞事件循环吗?
6.1 FastAPI的聪明之处
FastAPI 会检测你的函数类型:
- 如果是
async def→ 直接在事件循环里执行 - 如果是普通
def→ 自动扔到线程池里执行
这样普通 def 函数就不会阻塞事件循环了。
内部实现大概是这样:
python
from concurrent.futures import ThreadPoolExecutor
线程池 = ThreadPoolExecutor(max_workers=40)
async def 处理请求(你的函数, 参数):
if 是异步函数(你的函数):
return await 你的函数(参数)
else:
# 把同步函数扔到线程池,不阻塞事件循环
loop = asyncio.get_event_loop()
return await loop.run_in_executor(线程池, 你的函数, 参数)
6.2 什么时候用async def vs def
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| 函数里有异步I/O操作 | async def |
能充分利用异步的优势 |
| CPU密集型计算 | 普通 def |
会自动放到线程池,不阻塞事件循环 |
| 必须用同步库 | 普通 def |
同上 |
| 没有任何I/O,纯内存操作 | 都行 | async def 稍微有点开销,但影响不大 |
python
# 用 async def:当你的函数里有异步I/O操作
@app.get('/async-io')
async def async_io():
async with httpx.AsyncClient() as client:
data = await client.get("http://api.com")
return data.json()
# 用普通 def:当你的函数是CPU密集型
@app.get('/cpu-heavy')
def cpu_heavy():
result = 复杂计算() # CPU密集,没有I/O
return {"result": result}
# 用普通 def:当你必须使用同步库
@app.get('/legacy')
def use_legacy_lib():
data = 某个只有同步版本的老库.查询()
return {"data": data}
七、Flask为什么不能直接加async
有人可能会问:Python 的 async 是语言自带的关键字,为什么 Flask 不能用?
7.1 技术上可以写,但没用
python
from flask import Flask
app = Flask(__name__)
@app.route('/test')
async def test(): # 语法上可以加 async
await asyncio.sleep(1)
return "hello"
7.2 但Flask底层会这样调用你的函数
python
# Flask 内部(简化)
result = asyncio.run(test()) # 同步地等待异步函数完成
等于说:Flask 把你的异步函数又变成同步的了,完全没享受到异步的好处。
7.3 根本原因:协议不同
| 框架 | 协议 | 设计时间 | 特点 |
|---|---|---|---|
Flask |
WSGI |
2003年 | 同步协议,那时Python还没有async |
FastAPI |
ASGI |
2018年 | 异步协议,原生支持async/await |
【WSGI - Flask 底层】
请求进来
↓
分配一个线程给这个请求
↓
调用你的视图函数(同步等待返回)
↓
返回响应
↓
释放线程
就算你写 async,框架底层也是同步调用的
【ASGI - FastAPI 底层】
请求进来
↓
事件循环接收请求,创建协程
↓
执行你的 async 视图函数
↓
遇到 await → 暂停当前协程,去处理其他请求
↓
await 的东西好了 → 回来继续执行
↓
返回响应
真正的异步,一个线程处理成千上万请求
八、性能对比与选型建议
8.1 资源消耗对比
【处理 10000 个并发连接】
Flask(多线程/多进程):
├── 假设每个连接一个线程
├── 每个线程约 8MB 内存(栈空间)
├── 10000 连接 → 需要大量线程
├── 线程切换有操作系统开销
└── 内存消耗:可能几个GB
FastAPI(协程):
├── 单线程 + 事件循环
├── 每个协程约几KB
├── 10000 连接 → 10000 个协程
├── 协程切换是用户态,几乎无开销
└── 内存消耗:几十MB
差距:可达 100 倍!
当然,实际场景中不会这么极端,但协程的资源优势是实实在在的。
8.2 什么时候选Flask
| 场景 | 说明 |
|---|---|
| 项目简单,不需要高并发 | Flask 上手简单,生态成熟 |
团队熟悉 Flask 生态 |
换框架有学习成本 |
| 需要用很多只有同步版本的库 | 异步库生态还没那么全 |
| 主要是 CPU 密集型任务 | 这种情况异步优势不大 |
| 维护老项目 | 重构成本太高 |
8.3 什么时候选FastAPI
| 场景 | 说明 |
|---|---|
| 需要高并发、高性能 | 协程的优势场景 |
| I/O 密集型应用 | 大量数据库查询、外部 API 调用 |
| 构建 API 服务 | 自动生成文档(Swagger/OpenAPI) |
| 想用现代 Python 特性 | 类型提示、async/await |
| 新项目,可以选择异步库 | 生态在逐渐完善 |
8.4 性能排序(I/O密集型场景)
高 ─────────────────────────────────────────────────> 低
FastAPI Flask+gevent Flask多线程 Flask单线程
(原生异步) (协程补丁) (受GIL限制) (一次一个)
九、总结
9.1 Python并发的完整图景
┌─────────────────────────────────────────────────────────────────┐
│ Python 并发模型全景图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ GIL 的存在,决定了 Python 的并发特点: │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 执行 Python 代码时: │ │
│ │ └── 同一时刻只有 1 个线程能执行(假并行) │ │
│ │ │ │
│ │ 等待 I/O 时: │ │
│ │ └── 多个线程可以同时等待(真并发) │ │
│ │ └── 因为等待不需要执行代码,GIL 可以释放 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 基于这个现实,有两种并发策略: │
│ │
│ 【策略一:多线程】Flask 的方式 │
│ ├── 用多个线程,利用"I/O 等待时可以真并发"的特点 │
│ ├── 代价:线程重,内存占用大,切换开销大 │
│ └── 本质:用重量级工具做轻量级的事 │
│ │
│ 【策略二:协程】FastAPI 的方式 │
│ ├── 单线程 + 事件循环 + 协程 │
│ ├── 代价:极小,协程只有几 KB │
│ └── 本质:既然反正不能真并行,那就用最轻的方式做并发 │
│ │
└─────────────────────────────────────────────────────────────────┘
9.2 核心要点速记
| 要点 | 说明 |
|---|---|
GIL 让多线程不是"真并行" |
但 I/O 等待时可以真并发 |
Flask 用多线程处理并发 |
每个请求占一个线程,代价大 |
FastAPI 用协程处理并发 |
单线程处理万级连接,代价小 |
async 函数里必须用异步库 |
否则会阻塞事件循环 |
FastAPI 的普通 def 函数 |
会自动放到线程池,避免阻塞 |
Flask 基于 WSGI(同步) |
FastAPI 基于 ASGI(异步) |
9.3 一个最终的类比
Flask 多线程 ≈ 雇佣 100 个服务员,每人服务 1 桌客人
服务员等菜时在傻站着,但人多所以能应付
FastAPI 协程 ≈ 雇佣 1 个超级服务员,同时服务 100 桌
他不会傻等,等菜时去服务别桌
效率高,成本低
两种方式都能服务 100 桌客人
但后者只需要 1 个人的工资
9.4 常见问题
问题1:既然 FastAPI 这么好,Flask 还有存在的必要吗?
有。Flask 简单、成熟、生态丰富。很多场景不需要那么高的并发,用 Flask 反而更省事。技术选型要看具体需求,不是越新越好。
问题2:我用 FastAPI,但必须用一个只有同步版本的库,怎么办?
用普通 def 定义路由函数就行,FastAPI 会自动把它放到线程池里执行,不会阻塞事件循环。
问题3:为什么不直接去掉 GIL?
这个问题 Python 社区讨论了二十多年。去掉 GIL 会让单线程代码变慢,而且很多 C 扩展库依赖 GIL。Python 3.13 开始实验性地支持无 GIL 模式,但还需要时间让生态适应。
热门专栏推荐
- Agent小册
- 服务器部署
- Java基础合集
- Python基础合集
- Go基础合集
- 大数据合集
- 前端小册
- 数据库合集
- Redis 合集
- Spring 全家桶
- 微服务全家桶
- 数据结构与算法合集
- 设计模式小册
- 消息队列合集
等等等还有许多优秀的合集在主页等着大家的光顾,感谢大家的支持
文章到这里就结束了,如果有什么疑问的地方请指出,诸佬们一起来评论区一起讨论😊
希望能和诸佬们一起努力,今后我们一起观看感谢您的阅读🙏
如果帮助到您不妨3连支持一下,创造不易您们的支持是我的动力🌟