进阶篇 · Advanced
lv.3 进阶
kp-029
GIL 与并发模型总览
1. 一句话定义
GIL(全局解释器锁)保证 CPython 中同一时刻只有一个线程在执行 Python 字节码——因此多线程加速不了 CPU 密集任务,但 IO 等待时会释放锁;四条并发路线(线程/进程/asyncio/无 GIL)各有适用面。
2. 为什么重要
"为什么我的多线程程序没有变快"是 Python 并发的第一问。GIL 决定了选型:IO 密集用线程或 asyncio,CPU 密集用多进程。2024 起 PEP 703(free-threading)开始改变游戏规则,但主战场认知仍是本篇。
3. 前置知识
kp-001(字节码与解释器)、kp-013(阻塞 vs 惰性)。
4. 核心概念
text
┌─ 线程 threading ── IO 密集;GIL 下 CPU 密集无收益
任务特性 ────────┼─ 进程 multiprocessing ── CPU 密集;开销大(进程启动+序列化)
├─ asyncio ── 海量 IO 并发(网络);单线程协作式
└─ free-threading(3.13 实验)── 去掉 GIL 的多线程- GIL 存在的原因:让 CPython 的内存管理(引用计数)线程安全且简单高效。
- 释放时机:线程阻塞在 IO/
time.sleep/C 扩展(如 NumPy 大矩阵运算)时主动让出。 - 并发 vs 并行:并发 = 交替推进(asyncio、单核多线程);并行 = 同时推进(多进程、多核)。
5. 原理与机制
python
# 验证:CPU 密集下线程 vs 进程
import time, threading, multiprocessing
def cpu_task(n):
while n > 0: n -= 1
t0 = time.perf_counter()
ts = [threading.Thread(target=cpu_task, args=(10**7,)) for _ in range(4)]
[t.start() for t in ts]; [t.join() for t in ts]
print("threads:", time.perf_counter() - t0) # ≈ 单线程的 4 倍耗时(GIL)
t0 = time.perf_counter()
ps = [multiprocessing.Process(target=cpu_task, args=(10**7,)) for _ in range(4)]
[p.start() for p in ps]; [p.join() for p in ps]
print("procs:", time.perf_counter() - t0) # ≈ 单线程(真并行)线程切换每 5ms(sys.getswitchinterval())强制轮转,即便纯计算也让四线程比单线程更慢(加上切换开销)。
6. 关键事实(模型/图示)
text
GIL 不是"锁住整个程序",是"锁住字节码执行":
IO 等待(socket/read/sleep)→ 放锁 → 其他线程跑 → CPython 多线程仍能并发 IO
C 扩展(NumPy/OpenCV)在 C 层运算时常自放 GIL → 真并行
free-threading(PEP 703):引用计数改为 biased reference counting,
3.13 起作为独立构建(--disable-gil)实验发行,3.14 持续演进7. 直观类比
GIL 像教室里唯一的麦克风:一次只有一个人(线程)能发言(执行字节码),其他人干等;但有人去走廊打电话(IO 阻塞)时会把麦克风放下。想让全员同时干活只能多开教室(多进程)——或者等学校换新音响系统(free-threading)。
8. 实例与案例
选型速查:
text
爬 1000 个 URL → asyncio(或线程池 20-100 并发)
压缩 100 个大文件 → 多进程(CPU 密集)
调用 GPU 训练 → 单线程 + NumPy/PyTorch(C 层并行)
每秒处理万级 socket → asyncio9. 常见误区
- "GIL 使 Python 不能并发" —— 不能的是字节码级并行;IO 并发、进程并行、C 层并行都成立。
- 线程池跑 CPU 密集求加速 —— 无收益甚至更慢(切换开销)。
- 以为 GIL 免除了锁 —— 数据竞争依然存在(
count += 1非原子,check-then-act 模式会丢更新);GIL 只保单条字节码,不保业务原子性。 - 以为 3.13 起 Python 就无 GIL —— free-threading 是独立实验构建,主流仍是带 GIL 的 CPython。
10. 自测题
- 为什么多线程下载 100 个文件能显著加速,多线程算 100 个矩阵乘却不能?
- GIL 释放的三个典型时机?
x = 0; 线程里执行 x += 1 十万次 × 10 线程,结果一定等于 100 万吗?
参考答案
- 下载是 IO 等待(线程放锁等网络);矩阵乘的 Python 层只是"发起调用",若算子未被 C 层并行化,每步都在抢 GIL 执行字节码。
- IO 阻塞、sleep/等待锁、进入释放 GIL 的 C 扩展代码。
- 不一定;
x += 1编译为 LOAD/ADD/STORE 多条字节码,线程切换可插在中间造成丢失更新——需要锁或原子操作。
11. 与其他知识点的关系
12. 延伸阅读
- PEP 703(Making the Global Interpreter Lock Optional)
- Python Wiki: GlobalInterpreterLock