「Python 进阶之路」系列 Day16
写在前面
模块三讲完面向对象进阶,从今天开始进入 Python 面试里问法最刁钻的部分------GIL 与并发编程。第一篇先把最基础也最容易被问到的问题讲透:GIL 到底是什么,为什么大家都说 Python 的多线程是"假并发"。这一篇会用实测数据说话,而不是空谈概念。
一、是什么:CPython 的全局解释器锁
GIL(Global Interpreter Lock,全局解释器锁):CPython 解释器内部的一把全局锁,保证同一时刻只有一个线程在执行 Python 字节码。
关键限定:GIL 是 CPython 这个具体实现的产物,不是 Python 语言规范的一部分。 其他 Python 实现不一定有这个限制------比如 Jython(跑在 JVM 上)、IronPython(跑在 .NET 上)都没有 GIL;PyPy 也长期在尝试优化/去掉 GIL。平时说"Python 因为 GIL 所以多线程是假并发",严格来说指的是"CPython"。
二、为什么会有 GIL:引用计数的历史包袱
CPython 用引用计数做垃圾回收的主要机制(Day22 会细讲):每个对象内部维护一个计数器,记录有多少地方引用着它,计数归零就立刻释放内存。
问题在于:如果多个线程同时对同一个对象做增减引用计数的操作,会出现竞态条件(race condition)------两个线程"读到旧值、各自加一、再写回",可能导致计数算错,进而引发对象被提前释放(其他地方还在用)或者内存泄漏。
要解决这个问题,一种办法是给每个对象的引用计数单独加锁,但这样一来,几乎所有的对象操作都要经历"加锁-解锁",性能损耗和实现复杂度都会大幅上升。CPython 的设计者选择了一个简单粗暴但有效的方案:用一把全局大锁,把整个解释器的字节码执行串行化------同一时刻只有一个线程在跑,自然不会有并发修改引用计数的问题。这是"能正确工作"优先于"完美并行"的历史选择,简单可靠,代价是多线程没法利用多核 CPU 做真正的并行计算。
为什么现在还没移除 :历史上不止一次尝试过移除 GIL(比如著名的 Gilectomy 项目),但都没能真正合并进主线,主要因为去掉 GIL 后单线程性能会下降(需要在很多更细粒度的地方单独加锁),而且会破坏大量长期隐式依赖 GIL 提供"线程安全性"的 C 扩展库。最新进展 是 PEP 703 提出的可选"自由线程"构建模式,从 Python 3.13 起可以用 --disable-gil 编译出不带 GIL 的实验性 CPython 版本,但目前还处于实验阶段,不是默认行为。
三、怎么用:实测验证 GIL 的真实影响
1. CPU 密集型任务:多线程没有加速效果
python
import time, threading
def cpu_task(n):
count = 0
for _ in range(n):
count += 1
return count
N = 20_000_000
start = time.perf_counter()
cpu_task(N)
cpu_task(N)
t_single = time.perf_counter() - start # 单线程顺序执行两个任务
start = time.perf_counter()
t1 = threading.Thread(target=cpu_task, args=(N,))
t2 = threading.Thread(target=cpu_task, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
t_multi = time.perf_counter() - start # 两个线程"并发"执行同样两个任务
print(t_single, t_multi, t_multi / t_single)
实测跑了三轮,多线程耗时 / 单线程耗时 的比值分别是 0.87、0.98、0.97------两个线程并发执行,总耗时和单线程顺序执行几乎没有差别,远没有达到两个 CPU 核心真正并行处理时该有的、接近减半的加速效果。这正是 GIL 的直接体现:同一时刻只有一个线程真正在跑字节码,另一个线程只能干等着,多开的线程并没有换来真正的并行计算能力。
2. I/O 密集型任务:多线程确实有效果
python
def io_task(n):
time.sleep(n)
start = time.perf_counter()
io_task(0.5)
io_task(0.5)
t_single = time.perf_counter() - start # 约 1.0s
start = time.perf_counter()
t3 = threading.Thread(target=io_task, args=(0.5,))
t4 = threading.Thread(target=io_task, args=(0.5,))
t3.start(); t4.start()
t3.join(); t4.join()
t_multi = time.perf_counter() - start # 约 0.5s
print(t_single, t_multi)
这次两个线程并发跑的总耗时(约 0.5s)明显比顺序执行(约 1.0s)快了将近一倍。原因是 time.sleep()(以及网络请求、磁盘读写这类 I/O 操作)在等待期间会主动释放 GIL,让其他线程有机会拿到 GIL 去执行------I/O 密集型任务大部分时间都在"等"而不是在"算",GIL 释放出来的这段空档正好可以被别的线程利用,这也是为什么"I/O 密集型任务适合用多线程、CPU 密集型任务不适合"这条经验法则背后的真正原因。
3. GIL 保证的是字节码级别的原子性,不是复合操作的原子性
用 dis 模块看一下 counter += 1 实际对应几条字节码:
python
import dis
def incr():
global counter
counter += 1
dis.dis(incr)
# LOAD_GLOBAL (counter)
# LOAD_CONST (1)
# BINARY_OP (+=)
# STORE_GLOBAL (counter)
counter += 1 至少拆成了 4 条字节码指令------GIL 只保证单条字节码指令 执行时不会被打断,并不保证这一整串"读取、计算、写回"的过程不会被切走。理论上,如果线程恰好在 LOAD_GLOBAL 之后、STORE_GLOBAL 之前被切换走,另一个线程插进来完成了自己的读写,回来的线程会拿着一个过时的值覆盖回去,导致计数丢失。
有意思的是,这个"理论上存在"的竞态条件,在实际测试里相当难触发:
python
counter = 0
def worker():
global counter
for _ in range(2_000_000):
counter += 1
threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # 实测:20个线程 x 200万次,结果依然精确等于 40000000,没有丢失
即使开了 20 个线程、每个跑 200 万次、把 GIL 切换间隔调到很小,这个纯粹的 += 循环依然没有暴露出竞态------因为这几条字节码执行得实在太快,GIL 切换的时机很难恰好卡在"读"和"写"中间那个极窄的窗口。
但只要人为在读和写之间制造一个明确的间隙,竞态立刻就能稳定复现:
python
counter2 = 0
def worker_with_gap():
global counter2
for _ in range(1000):
current = counter2 # 读取
time.sleep(0) # 主动让出,模拟"读和写之间被切走"
counter2 = current + 1 # 写回
threads2 = [threading.Thread(target=worker_with_gap) for _ in range(10)]
for t in threads2: t.start()
for t in threads2: t.join()
print(counter2) # 实测:期望10000,实际只有1057,丢了8943次!
这说明一个关键点:GIL 从来没有承诺过"任意一段 Python 代码整体是线程安全的",它只保证单条字节码指令不可分割。 一旦读和写之间出现任何间隙(哪怕只是一次函数调用、一次属性访问,或者纯粹运气不好被调度器切走),复合操作的原子性假设就会破产。不能因为"实测很难复现"就认为 += 在多线程下是安全的,该加锁的地方还是要加锁。
四、面试追问
Q1:什么是 GIL?
CPython 解释器内部的一把全局锁,保证同一时刻只有一个线程执行 Python 字节码。它是 CPython 这个具体实现的产物,不是 Python 语言规范要求的,其他实现(Jython、IronPython)没有这个限制。
Q2:为什么 CPython 需要 GIL?
CPython 用引用计数做垃圾回收,多线程并发修改同一对象的引用计数会产生竞态条件,可能导致对象被提前释放或内存泄漏。给每个对象单独加锁代价太高,所以 CPython 选择用一把全局大锁把字节码执行串行化,简单地规避了这个问题。
Q3:GIL 会导致什么问题?
CPU 密集型任务开多线程得不到多核并行的加速效果------实测中两个线程并发执行两个计算任务,总耗时和单线程顺序执行几乎一样(比值在 0.87~0.98 之间),远达不到真正并行应有的、接近减半的效果,因为同一时刻永远只有一个线程真正在执行字节码。
Q4:I/O 密集型任务用多线程为什么还有意义?
I/O 操作(如 sleep、网络请求、磁盘读写)在等待期间会主动释放 GIL,让其他线程有机会执行。实测中两个各睡眠 0.5 秒的任务,多线程并发执行总耗时约 0.5 秒,比顺序执行的约 1.0 秒快了接近一倍,说明多线程能有效利用 I/O 等待期间释放出来的 GIL 空档。
Q5:怎么绕开 GIL?
常见方式有三种:用 multiprocessing 开多进程,每个进程有自己独立的解释器和 GIL,互不干扰,能真正利用多核;用 C 扩展在纯计算、不涉及 Python 对象操作的部分主动释放 GIL;或者使用 Python 3.13 起实验性支持的 --disable-gil 自由线程构建,这是官方正在探索但还未成为默认行为的方向。
下一篇预告
Day17 讲 threading 模块------多线程的适用场景到底该怎么判断,以及今天最后提到的竞态条件问题,在实际编码里应该怎么用锁正确解决。