核心点:GIL 是什么、多线程/多进程/协程各适合什么场景(I/O 密集 vs CPU 密集)
为什么重要:面试高频题,也是判断 AI 写的并发代码对不对的依据
自检问题:为什么 Python 多线程跑 CPU 密集任务不会变快?FastAPI 里跑一个耗时计算应该怎么处理?
(AI整理生成用于复习)
这篇文章顺着一条因果链往下讲:从最底层的 GIL 这把锁出发,推出多线程/多进程/协程各自适合什么场景,最后落到 FastAPI 实战。读完你应该能自己回答开头那两个自检问题。
一、GIL 是什么
GIL(Global Interpreter Lock,全局解释器锁)是 CPython 解释器里的一把锁,规定同一个进程里、同一时刻只有一个线程能执行 Python 代码。
可以这样理解:进程里有很多线程,但只有一把"通行证",拿到 GIL 的线程才能跑,其他线程必须排队等。
它存在的原因是 CPython 的内存管理(尤其是对象引用计数)不是线程安全的。多个线程同时改同一个对象的引用计数会出错,GIL 用"同一时刻只让一个线程跑"这种简单粗暴的方式规避了这个问题。
有两个关键细节,记住它们后面全用得上:
- GIL 锁的是"执行 Python 代码"这件事。 线程在"等东西"(等网络、等读写文件)时会主动让出锁,让别的线程去执行;只有真正"算东西"时才需要一直攥着锁。
- GIL 是 CPython 的设计,不是 Python 语言的规定。 别的实现(如 Jython、IronPython)就没有 GIL。
顺带说清楚:CPython 是什么
- Python 是一门语言,是一套语法规则。
- CPython 是这门语言的一个具体实现------用 C 语言写的、真正能读懂并执行你代码的那个程序(解释器)。名字就是
C+Python。
你从官网下载、平时敲 python 跑起来的,几乎都是 CPython。所以平时一说"Python 有 GIL",说的其实都是它。其他实现对比:
| 实现 | 用什么写 | 特点 |
|---|---|---|
| CPython | C | 官方标准,最常用,有 GIL |
| PyPy | Python | 自带 JIT,跑得更快 |
| Jython | Java | 能和 Java 互通,无 GIL |
| IronPython | C#/.NET | 能和 .NET 互通,无 GIL |
二、由 GIL 推出的核心结论
判断一切的总开关只有一句话:你的程序是在"算东西"还是"等东西"?
- 算东西(CPU 密集) :大量计算、几乎不等外部资源。比如图像/视频处理、科学计算、加密解密、机器学习训练。线程会一直占着锁。
- 等东西(I/O 密集) :大部分时间在等网络、磁盘、数据库返回,CPU 其实闲着。线程在等待时会让出锁。
由此直接得到多线程的结论:
| 任务类型 | 线程在干嘛 | 多线程有用吗 |
|---|---|---|
| 算东西(CPU 密集) | 一直占着锁 | ❌ 没用,大家挤在一个核抢同一把锁 |
| 等东西(I/O 密集) | 等待时让出锁 | ✅ 有用,多个任务的等待时间能重叠 |
三、自检问题 1:为什么 Python 多线程跑 CPU 密集任务不会变快?
因为 GIL。同一时刻只有一个线程能执行 Python 代码,开再多线程跑计算,也只是一群线程在抢同一把锁、轮流用一个核心,没法让多核一起算。
把因果链拆开看:
CPU 密集任务的特点是线程一直在算、几乎不停下来等 。而线程只有在"等东西"时才会让出 GIL------现在它一直在算、根本不等,于是会死死攥着锁不放,别的线程只能排队干瞪眼。
结果:你以为开了 8 个线程在 8 核上一起干活,实际是 8 个线程抢 1 把锁、挤在 1 个核心上轮流跑。计算总量没变,还白白多了抢锁、线程切换的开销,所以不仅快不了,有时反而更慢。
ini
import threading
# CPU 密集型任务,多线程几乎没有加速效果
def count(n):
while n > 0:
n -= 1
# 两个线程并不会比单线程快一倍------它们在抢同一把 GIL
t1 = threading.Thread(target=count, args=(10**8,))
t2 = threading.Thread(target=count, args=(10**8,))
想真正加速 CPU 密集任务,得用多进程------每个进程有独立的解释器和 GIL,能真正吃满多核。
四、多线程 / 多进程 / 协程各适合什么场景
先分清两个词:
- 并发(concurrency) :多个任务交替执行,宏观上"同时进行",同一时刻不一定真有多个在跑。
- 并行(parallelism) :多个任务真的在同一时刻一起跑,必须依赖多核。
因为 GIL,CPython 的多线程和协程都只是"并发";只有多进程能做到真正的"并行"。
三者对比
| 维度 | 多线程 (threading) | 多进程 (multiprocessing) | 协程 (asyncio) |
|---|---|---|---|
| 调度方 | 操作系统(抢占式) | 操作系统(抢占式) | 程序自己(协作式) |
| 能否利用多核 | 否(受 GIL 限制) | 能 | 否 |
| 切换开销 | 中等 | 大(进程重) | 极小 |
| 内存 | 共享(同进程内) | 独立,需 IPC 通信 | 共享 |
| 数据安全 | 需加锁,易竞态 | 天然隔离,较安全 | 单线程内基本无竞态 |
| 适合并发量 | 几百到几千 | 受核心数限制 | 几万到几十万 |
CPU 密集 → 多进程
python
from multiprocessing import Pool
def heavy_compute(n):
return sum(i * i for i in range(n))
if __name__ == "__main__":
with Pool(4) as p: # 4 个进程跑在 4 个核心上
results = p.map(heavy_compute, [10**7] * 4)
每个进程独立、不受 GIL 限制,能真正并行。代价:进程启动慢、内存占用高、进程间传数据要序列化(pickle),不适合频繁通信或传大对象。
I/O 密集 → 协程(首选)或多线程
协程:单线程内靠事件循环高效切换,切换开销极小,能扛几万并发,是 I/O 密集的现代首选。
python
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.text()
async def main(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, u) for u in urls]
return await asyncio.gather(*tasks) # 上万请求也能轻松扛
多线程 :适合 I/O 密集但代码量小、不想重构成异步,或要用的库不支持异步的场景。写法简单,能直接用现成同步库(如 requests),缺点是并发上不去、要注意线程安全。
python
from concurrent.futures import ThreadPoolExecutor
import requests
with ThreadPoolExecutor(max_workers=20) as executor:
results = list(executor.map(requests.get, urls))
决策路径
-
先问:CPU 密集还是 I/O 密集?
-
CPU 密集(在算东西)→ 多进程
-
I/O 密集(在等东西)→ 再看并发量和生态:
- 并发量大、有成熟异步库 → 协程
- 并发量不大,或必须用同步第三方库 → 多线程
判断小技巧:拿不准时跑起来看任务管理器的 CPU 占用。接近 100% 一直很忙 = 算东西(多进程);CPU 很低 = 等东西(协程/多线程)。
也可以组合用:用多进程拆 CPU 密集部分到各核,每个进程内再用协程处理 I/O(典型爬虫架构)。
五、补充概念:"池"是什么
"池"(pool)就是提前备好一批资源、反复重用、用完归还、自动排队的仓库。
为什么需要它:创建/销毁线程或进程本身有开销。来一个任务造一个、用完就扔,任务一多光是"造和扔"就浪费大量资源。池子提前备好固定数量,要用就拿、用完还回去。
三个好处:
- 省去反复创建/销毁的开销
- 控制数量、不失控(来一万个任务也不会造一万个线程把系统压垮,多的会排队)
- 自动排队调度,不用自己操心
ini
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=4) # 备好 4 个线程
future = executor.submit(some_task, arg) # 丢任务,池子自动安排空闲线程
result = future.result() # 拿结果
ProcessPoolExecutor(进程池)道理一样,只是池里装的是进程:
- 线程池:装线程,受 GIL 限制 → 适合 I/O 密集
- 进程池:装进程,不受 GIL 限制 → 适合 CPU 密集
六、自检问题 2:FastAPI 里跑一个耗时计算应该怎么处理
FastAPI 是异步框架,靠一个主事件循环 单线程运转。如果在接口里直接跑耗时操作,会堵死整个事件循环 ------不只这一个请求慢,是所有用户的请求一起卡住。
csharp
# ❌ 千万别这么写
@app.get("/compute")
async def compute():
result = heavy_compute() # 这一卡,整个服务全瘫
return result
关键机制:async def vs def
FastAPI 看你接口的定义方式,用完全不同的方式跑:
async def→ 在主事件循环 上亲自跑,里面只能放异步操作 (await异步库)def→ 自动甩给线程池,不占用主循环
打个比方:事件循环是个超级勤快的服务员,靠"快速来回切换"同时招呼很多桌。但他绝不能在任何一桌停下来超过一瞬间。你在 async def 里跑 5 秒计算,等于他蹲在一桌埋头算 5 秒账,其他桌全部干等。改成 def,FastAPI 就把这活外包给线程池里的另一个人,主服务员继续招呼别人。
| 你接口里的耗时操作 | 接口怎么写 |
|---|---|
异步的(能 await,如 httpx 异步、异步数据库驱动) |
async def |
同步阻塞的(不能 await,含纯计算) |
def(交给线程池) |
最坑的写法 :在 async def 里塞同步阻塞代码,两头不讨好------
python
@app.get("/bad")
async def bad():
time.sleep(5) # ❌ 同步阻塞塞进 async,主循环卡死 5 秒
requests.get(url) # ❌ 同样的错
按计算耗时选方案
方案一:计算不太重 → 线程池(写成 def)
python
@app.get("/compute")
def compute(): # 注意是 def
return heavy_compute()
注意线程受 GIL 限制:纯 CPU 密集丢线程池只能保证"不卡别的请求",不能让这个计算本身变快。适合:计算较短,或用了会释放 GIL 的库(如 NumPy)。
方案二:真正 CPU 密集、要当场算完返回 → 进程池
python
from concurrent.futures import ProcessPoolExecutor
import asyncio
executor = ProcessPoolExecutor()
@app.get("/compute")
async def compute():
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, heavy_compute, arg)
return result
主循环不被堵,计算又在别的核心上真正并行。
方案三:计算特别久(几十秒以上)→ 异步任务队列
接口立刻返回一个任务 ID,计算丢后台慢慢算,用户之后拿 ID 查结果。常用 Celery(配 Redis/RabbitMQ),轻量的有 RQ、Dramatiq。
python
@app.post("/compute")
async def submit():
task = heavy_compute.delay(arg) # 丢给 Celery worker
return {"task_id": task.id}
@app.get("/result/{task_id}")
async def get_result(task_id: str):
...
注意:方案三不是让计算变快。 活该花多久还是花多久。它解决的是另一类问题:
- HTTP 连接挂几分钟会超时(网关、浏览器几十秒就掐断),结果都拿不到
- 连接是有限资源,一堆干等的请求会占满连接,新用户连不进来
- 用户对着转圈几分钟会重复提交,雪上加霜
方案三让接口一秒返回(连接立刻释放),用户拿 ID 去做别的,算完再来取。查结果可用轮询、WebSocket/SSE 主动推送、或邮件/消息通知。"导出 Excel 完成后通知你""视频上传后台处理中"背后都是这套。
选型总结
| 你的情况 | 用什么 | 用户怎么等 |
|---|---|---|
| 计算很轻,或用了 NumPy 这类放 GIL 的库 | def 接口(线程池) |
当场返回 |
| 纯 CPU 密集,要当场算完返回(秒级) | ProcessPoolExecutor |
当场返回 |
| 计算很久,用户等不了 | Celery / RQ 等任务队列 | 拿 ID 走人,回头取 |
判断标准:这个计算能不能在用户合理愿意等、且连接不会超时的时间内(一般十几秒)算完? 能 → 进程池当场返回;不能 → 任务队列立刻放人。
七、一张图串起整条逻辑
python
GIL(CPython 的锁,一时刻只一个线程跑 Python 代码)
│
├─ 算东西(CPU 密集)→ 一直占锁 → 多线程没用 → 用【多进程 / 进程池】
│
└─ 等东西(I/O 密集)→ 等时让锁 → 多线程有用 → 用【协程 / 多线程 / 线程池】
│
└─ 落到 FastAPI(别堵事件循环):
├─ 异步操作 → async def
├─ 同步 / 纯计算 → def(甩给线程池)
├─ 重计算、秒级 → 进程池 ProcessPoolExecutor
└─ 超久任务 → 任务队列,发个 ID 回头取
结语
整条线其实是闭合的:GIL 决定了"算东西"多线程没用、"等东西"多线程有用 ,从而决定了多进程/协程/线程的分工,最后在 FastAPI 里落地成"该用 async def 还是 def、该上进程池还是任务队列"。面试时顺着这条因果链讲,比单纯背结论扎实得多;写并发代码(或 review AI 写的并发代码)时,第一个该问的也永远是那句------这活儿是在算东西,还是在等东西?