学习Python GIL:解决的问题&带来的限制
由于 GIL 的存在,Python 的多线程并不能有效利用 CPU 多核。但如果只把 GIL 看成性能包袱,就很难理解为什么 CPython 长期保留它,也很难理解为什么“去掉 GIL”会牵动整个解释器与扩展生态。
这篇文章面向已经会写 Python、也知道线程和锁的人。重点介绍:GIL 想解决什么问题,为什么“给每个对象单独加锁”听起来合理却常常更差,以及它为什么让 C 扩展更容易集成。
GIL 是什么
GIL(Global Interpreter Lock) 可以直接翻译成“全局解释器锁”。在任意时刻,只有一个线程可以持有 GIL;持有 GIL 的线程才能执行 Python 字节码[1] & 直接操作解释器状态[2]。
为什么会有 GIL
简化内存管理
CPython 的内存管理长期依赖 引用计数(Reference Counting)。每个对象头里都有一个计数器 ob_refcnt,记录当前有多少地方持有这个对象。
- 当有新的变量指向它时:调用
Py_INCREF令计数 +1 - 当引用消失(变量销毁、重新赋值等):调用
Py_DECREF令计数 -1 - 当计数变为 0 时:对象立即被回收(释放内存)
import sysa = []print(sys.getrefcount(a)) # 通常会比直觉多 1,因为 getrefcount 调用本身也持有引用这个方案在实现起来相对简单。但标准 CPython 的 Py_INCREF 和 Py_DECREF 只是简单的 C 宏,直接执行 op->ob_refcnt++ 或 --,C 语言中的自增、自减均非原子操作,因此在多线程环境下,如果并发修改计数,可能会出现偏差,导致两种后果:
- 计数偏大:对象无法被释放,造成内存泄漏。
- 计数偏小,对象可能在仍被使用时提前释放,最终触发悬垂指针甚至段错误。
既然引用计数并发修改不安全,那就给每个对象加一把锁,或者把 Py_INCREF 改成原子的,不就好了吗?
直觉上,这似乎比全局一把大锁更先进,因为锁更细,看起来并发度也更高。但放到 CPython 这种“几乎一切都是对象”的运行时里,事情没有这么简单。
先看一个关键事实:引用计数操作非常频繁,而且临界区极短。
很多普通代码,底层都会触发 INCREF 或 DECREF:
- 变量赋值
- 函数传参与返回值
- 容器读写
- 临时对象创建与销毁
- 异常传播过程中的对象保存与清理
也就是说,如果采用对象级锁(或更细粒度的锁机制),至少需要考虑下面这些因素。
引用计数更新本质上只是改对象头上的一个整数,但对象级锁获取和释放需要:
Atomicity:被锁保护的操作必须是原子操作,即操作不可被线程中断
Memory Visibility:要保证一个线程对对象的修改能被其他线程及时看到
Cache Coherence:多核 CPU 中,每个核心可能有自己的缓存,对象级锁需要保证不同核心看到的对象状态一致
死锁避免与可重入性
每个对象带锁会膨胀对象头,影响小整数、字符串、元组等大量小对象。
结果:
内存占用增加
CPU cache 容纳热点对象减少
热点路径性能下降
Python 操作通常涉及多个对象,例如赋值、
list.append(x)。问题:
- 多对象锁必须严格顺序,否则易死锁
- 许多代码路径需重写
- C 扩展也必须遵守锁协议
对象级锁不仅慢,还显著增加实现复杂度。
CPython 选择了直接加一把全局锁,优势有:
- 引用计数更新天然串行化,不需要对象级锁
- 很多解释器内部结构可以假设“当前只有一个线程在改”
- 锁的获取与释放被摊薄到更大的执行片段上,而不是每个对象操作都来一次
为什么 GIL 让 C 扩展更容易集成
这部分经常只被一句话带过,但它其实是 GIL 能长期存在的关键原因之一。
CPython 的 C API 长期建立在一个重要约束上:
只要扩展代码持有 GIL,它就可以把大部分解释器内部状态当成“当前线程独占”的。
这意味着一个 C 扩展在操作
PyObject*时,通常可以直接做这些事,而不必给每个对象再上一层锁:- 读写引用计数
- 访问列表、字典、元组等对象的内部结构
- 创建新对象、抛出异常、调用 Python API
- 使用大量历史遗留下来的 C API 宏与快速路径
这极大降低了扩展作者的心智负担。对很多扩展来说,规则几乎可以简化成一句话:
碰 Python 对象之前,先确保自己持有 GIL。
这个默认前提非常重要,因为大量 C 扩展作者并不是并发运行时专家。他们可能擅长数值计算、图像处理、数据库驱动或系统接口,但并不想在每次调用 Python C API 时都重新推导对象生命周期与锁顺序。
如果没有 GIL,而是改成真正的细粒度并发模型,那么 C 扩展就不能再假设“只要进入 Python API,我就在一个近似单线程的世界里”。
这会立刻引出一串额外要求:
- 每次操作 Python 对象前,都要考虑对象是否会被其他线程同时修改
- borrowed reference(借用引用)、容器迭代器、缓存指针等历史接口会变得更脆弱
- 扩展内部如果保存了指向 Python 对象内部结构的裸指针,就必须重新审视其生命周期
- 许多旧扩展需要补锁、改数据结构、调整 API 使用方式
也就是说,去掉 GIL 不只是“解释器自己改一改”这么简单,而是 整个 CPython C API 合同都需要收紧或重写。
C 扩展的并发性
GIL 并不是要求 C 扩展全程串行执行。它真正提供的是一个简单边界:
- 操作 Python 对象时,持有 GIL
- 执行纯 C 计算或阻塞 I/O 时,可以主动释放 GIL
这也是为什么很多扩展既能安全集成,又能在关键路径上跑出并行性。典型做法类似这样:
Py_BEGIN_ALLOW_THREADSdo_blocking_io_or_native_compute();Py_END_ALLOW_THREADS这套模型的好处在于默认安全,按需放开。
扩展作者不必从第一天起就把所有代码写成细粒度线程安全,只需要在“不碰 Python 对象”的阶段显式释放 GIL。
GIL 给 Python 多线程带来的影响
CPU 密集型 Python 线程无法真正并行
如果两个线程都在执行纯 Python 的 CPU 密集型代码,那么它们会轮流持有 GIL,而不是同时在两个核心上并行执行字节码。
这就是为什么很多人会觉得“Python 开了多线程还是没变快”。
I/O 密集型场景仍然适合线程
如果线程大部分时间都在等待网络、磁盘或其他阻塞 I/O,那么线程依然很有价值,因为等待阶段通常会让出 GIL。此时多线程提升的不是单核算力,而是 等待时间的重叠。
原生扩展可以绕开这一限制
如果耗时部分在 C、C++、Rust 或 Fortran 实现的原生扩展里,而且这些代码在计算阶段释放了 GIL,那么多个线程就可以在底层真正并行。
通常一些 CPU Bound 的代码会有 C 扩展实现,因此通常不需要关心 GIL 带来的影响
WarningGIL 的存在并不意味着“写 Python 多线程代码时就自动线程安全”。GIL 保护解释器内部状态,并不保护业务变量
remain_count -= 1这种 read-modify-write(读、改、写)逻辑,依然可能在多个线程之间交错执行,导致结果错误。如何绕过 GIL
常见思路有三种:
- 多进程:
multiprocessing让每个进程拥有自己的解释器实例与 GIL,适合 CPU 密集型任务。 - 原生扩展:把热点路径挪到 C、C++、Rust、Cython 或其他可以释放 GIL 的实现里。
- 换实现或关注 free-threaded 路线:PEP 703 推动的是一条 no-GIL / free-threaded 的 CPython 路线,但这不是“删掉一把锁”那么简单,而是对对象模型、引用计数策略与 C 扩展兼容性的系统性改造。
总结
总体来看,全局锁是性能、内存、复杂度的工程折中方案,牺牲 CPU 密集型多线程并行能力,换解释器实现复杂度、单线程路径成本与生态兼容性。
Read More
Inside The Python Virtual Machine | 深入理解 Python 虚拟机
Python 字节码(Bytecode)是 Python 源代码(.py)经编译后生成的一种低级、与平台无关的中间代码,存在于
.pyc文件或内存中。它由 CPython解释器在虚拟机上执行,用于提升程序加载速度。开发者可通过内置的dis模块反编译字节码以深入理解程序运行机制。 ↩︎Python 解释器(通常是 CPython)在运行过程中维护的所有运行时数据结构和执行环境的集合。包括对象系统(如整数、字符串等)、内存管理(引用计数与 GC)、调用栈与栈帧、线程与解释器状态、字节码执行状态(指令与操作数栈)、全局解释器锁(GIL)、模块与命名空间以及异常处理等。可参考 Python 3.7 源码中的 PyInterpreterState结构体 ↩︎
学习Python GIL:解决的问题&带来的限制

