一文搞懂 Python 并发:GIL、多线程/多进程/协程怎么选

核心点:GIL 是什么、多线程/多进程/协程各适合什么场景(I/O 密集 vs CPU 密集)

为什么重要:面试高频题,也是判断 AI 写的并发代码对不对的依据

自检问题:为什么 Python 多线程跑 CPU 密集任务不会变快?FastAPI 里跑一个耗时计算应该怎么处理?

(AI整理生成用于复习)

这篇文章顺着一条因果链往下讲:从最底层的 GIL 这把锁出发,推出多线程/多进程/协程各自适合什么场景,最后落到 FastAPI 实战。读完你应该能自己回答开头那两个自检问题。


一、GIL 是什么

GIL(Global Interpreter Lock,全局解释器锁)是 CPython 解释器里的一把锁,规定同一个进程里、同一时刻只有一个线程能执行 Python 代码。

可以这样理解:进程里有很多线程,但只有一把"通行证",拿到 GIL 的线程才能跑,其他线程必须排队等。

它存在的原因是 CPython 的内存管理(尤其是对象引用计数)不是线程安全的。多个线程同时改同一个对象的引用计数会出错,GIL 用"同一时刻只让一个线程跑"这种简单粗暴的方式规避了这个问题。

有两个关键细节,记住它们后面全用得上:

  1. GIL 锁的是"执行 Python 代码"这件事。 线程在"等东西"(等网络、等读写文件)时会主动让出锁,让别的线程去执行;只有真正"算东西"时才需要一直攥着锁。
  2. 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))

决策路径

  1. 先问:CPU 密集还是 I/O 密集?

  2. CPU 密集(在算东西)→ 多进程

  3. I/O 密集(在等东西)→ 再看并发量和生态:

    • 并发量大、有成熟异步库 → 协程
    • 并发量不大,或必须用同步第三方库 → 多线程

判断小技巧:拿不准时跑起来看任务管理器的 CPU 占用。接近 100% 一直很忙 = 算东西(多进程);CPU 很低 = 等东西(协程/多线程)。

也可以组合用:用多进程拆 CPU 密集部分到各核,每个进程内再用协程处理 I/O(典型爬虫架构)。


五、补充概念:"池"是什么

"池"(pool)就是提前备好一批资源、反复重用、用完归还、自动排队的仓库。

为什么需要它:创建/销毁线程或进程本身有开销。来一个任务造一个、用完就扔,任务一多光是"造和扔"就浪费大量资源。池子提前备好固定数量,要用就拿、用完还回去。

三个好处:

  1. 省去反复创建/销毁的开销
  2. 控制数量、不失控(来一万个任务也不会造一万个线程把系统压垮,多的会排队)
  3. 自动排队调度,不用自己操心
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 写的并发代码)时,第一个该问的也永远是那句------这活儿是在算东西,还是在等东西?

相关推荐
鱼人2 小时前
targets 包实战:R 语言数据分析流水线自动化管理方案
后端
Anson4322 小时前
Dubbo架构深度分析
后端
站大爷IP2 小时前
global和nonlocal到底有什么区别?
后端
二月龙2 小时前
从零开发 Shiny 交互式数据看板:本地运行到网页上线完整路径
后端
小强19882 小时前
词云 + 情感分析:爬取评论数据做舆情可视化实战
后端
小强19882 小时前
高颜值动态可视化:gganimate 制作时序动图与数据短视频
后端
鱼人2 小时前
Shiny 模块化开发:大型数据分析平台拆分与代码复用实战
后端
长大19882 小时前
R 语言空间地图实战:从城市热力图到地理分布图,一篇吃透
后端
二月龙2 小时前
Shiny 对接 Excel / 数据库:从文件上传到自动分析
后端