CPython 的 GIL
CPython's GIL
CPython 的全局解释器锁(GIL)是一把进程里的互斥锁:同一时刻只允许一个线程执行 Python 字节码。它不是语言标准的一部分。PyPy、Jython、IronPython 各自用过不同的策略。但它是默认解释器上那条最被抱怨、也最被依赖的边界。1990 年代它让引用计数保持简单;2023 年 PEP 703 被接受,目标是让这把锁变成可选;Python 3.13 给出实验性的自由线程构建。一把锁的一生,就是 C 扩展生态与多核硬件之间二十年错位的编年。
这篇文章要拆的是 GIL 究竟保护了什么、在哪些路径上它本来就会被放下,以及去掉它之后必须改写的不是一句 mutex.unlock,而是对象头、分配器和每一份假定“此时只有我”的 C 扩展。
破除误解
误解
**误解一:GIL 意味着 Python 不能并发。实情**
字节码被串行化,I/O 不是。执行到阻塞式读、锁等待、或显式释放 GIL 的 C 代码时,解释器会把锁放下,别的线程可以跑。网络服务、磁盘、等待子进程,这些路径上的重叠是真的。GIL 限制的是计算型 Python 循环在多核上的加速。把“不能并发”说成绝对句,会让人在 I/O 密集的服务里去做无用的进程拆分,也会让人在计算密集的循环里幻想 threading 会自动变快。
误解二:去掉 GIL 只是把一把互斥锁删掉。
引用计数必须变成线程安全的,否则两个线程同时增减同一对象的计数会得到损坏的生命期。容器的内部结构、分配器的空闲链表、异常状态、循环垃圾回收器,过去都默认“持有 GIL 等于持有全世界”。PEP 703 的方案包括偏向引用计数、每对象的临界区、以及 mimalloc 一类对并发更友好的分配器。Python 3.13 的自由线程构建把这些改动做成一套可切换的运行时,而不是一行配置。删锁是标题,工程是对象模型。
误解三:多进程已经把问题解决了,GIL 只是历史包袱。
multiprocessing 用进程换独立解释器,从而换独立的 GIL。代价是:对象不能便宜地共享,必须序列化;每个进程复制一份模块与内存;在 Windows 上还要面对启动方式的差异。这是好的工程逃逸舱,不是同一句话的两种写法。需要共享巨型矩阵或同一份内存映射模型时,进程边界会先把你挡住。GIL 之所以还值得改,是因为有一类负载既要共享内存,又要把核用满。逃逸舱覆盖的是另一类负载。
锁到底守着哪本账
CPython 的主垃圾回收是引用计数。每个对象头里有一个计数,指向它的名字出现或消失时加减。计数到零就立即析构。这套模型对单线程极其友好:确定性终结、内存回得快、实现可以用普通整数。循环引用由 2000 年前后 Neil Schemenauer 引入的分代循环回收器另行处理。GIL 让加减计数不必是原子操作,也让容器在 resize 时不必细粒度上锁。锁的经济学是:用所有线程的互相等待,换对象层协议的简单。
字节码循环里有一套“定期检查”。解释器执行若干条指令或经过一定时间后,查看是否有信号、是否该切换线程、是否该做 GC。GIL 的释放和再获取常常发生在这些检查点,以及已知会阻塞的 C 调用周围。David Beazley 在 2010 年前后的实验里展示过一种难看的现象:多核上,计算线程为了抢 GIL 与 I/O 线程互相折磨,总吞吐甚至差过单核。那不是锁的定义出了错,那是调度与锁粒度在多核缓存一致性下的真实价格。锁很粗,于是所有线程都在打同一面墙。
C 扩展是这把锁真正的政治基础。NumPy 的长时间计算可以在进入内核循环前释放 GIL,算完再拿回来,于是数值库能用上多核,而纯 Python 循环不能。扩展作者默认:拿到 GIL 时,没有别的线程会同时摸 Python 对象。字典的内部表、对象的引用计数、异常指针,都可以当单线程结构来写。这个默认值让数千个扩展成为可能,也让“去掉 GIL”变成一次生态迁移:每一个仍假定独占的扩展,都是一个潜在的数据竞争。自由线程构建必须提供一份清单,说明哪些模块已被审过、哪些仍应在 GIL 模式下加载。
PEP 684 把另一条缝拆出来:每解释器一把 GIL。3.12 起,子解释器可以有自己的锁,模块状态必须从过程全局挪到每解释器。这不是自由线程,这是把“一个进程一把锁”改成“一个解释器一把锁”。隔离比共享便宜,但对象仍然不能在解释器之间随便传递。它解决的是嵌入场景和某些并行任务,不是共享对象图上的数据并行。读这些 PEP 时要对准问题:703 管共享堆上的多线程,684 管隔离堆上的多解释器。两者都在削弱那把从 1990 年代传下来的全球锁,削弱的位置不同。
自由线程:从可选构建到对象头
Sam Gross 在 Meta 的 nogil 工作是 PEP 703 的直接前身。核心想法不是给每个对象一把大锁,那会把单线程性能打穿。偏向引用计数让“被创建它的线程拥有的对象”仍然走快路径,只有在对象被多个线程看见时才进入更重的原子路径。临界区保护的是容器突变这类必须互斥的区间,而不是每一条字节码。单线程程序应当仍然快;多线程程序应当不再在字节码边界上排队。这个“应当”要用基准来守,因为对象头一旦变大,所有程序都要付缓存税。
Python 3.13 把自由线程做成实验性构建选项,标准库和 C API 开始长出 Py_mod_gil 一类声明,让扩展表态自己是否能在无 GIL 下运行。3.14 继续收紧默认值、修补仍然依赖隐式独占的角落。这不会在某个点变成“GIL 从地球上消失”。发行版会在很长一段时间里同时提供带 GIL 的默认构建和自由线程构建,直到扩展生态的覆盖率让发行者敢于把默认值翻过来。语言用户看见的是 threading.Thread 终于能在计算循环里加速;发行者看见的是一份按模块着色的兼容矩阵。
还有一条必须说清的语义线。GIL 从未给你数据竞争自由。它只保证字节码评估不会在两个线程里同时发生。你自己的 Python 对象仍然需要 threading.Lock 或队列来保护不变量——两个线程交替执行 x += 1,即使每次加减都在 GIL 下是原子的,复合操作仍可能交错。去掉 GIL 之后,连“单个字节码操作在对象上是否原子”都要重新读文档。列表的 append、字典的赋值,这些过去碰巧像原子的操作,在自由线程里可能需要运行时内部的临界区来维持旧观感。兼容性工作很大一部分是在恢复这种观感,而不是在发明新的并发原语。
所以 GIL 的正确读法不是“Python 不懂多核”,而是:默认解释器用一把粗锁,把对象模型的并发协议推迟了三十年;现在要补课,学费由对象头和 C 扩展 API 一起付。 语言可以继续把并发写得很甜,运行时必须决定甜味是用锁买的,还是用原子计数和临界区买的。两种买法都诚实,账单落在不同的人身上。
为何这把锁活了三十年
扩展生态是锁的护城河。成千上万的 C 模块按“解释器会替我串行化”来写,构成一种稳定的均衡。谁先拆锁,谁先面对竞态;谁后拆,谁继续享受简单性。指导委员会接受实验构建,是在改支付矩阵:适配者得到未来的多核路径,不适配者在实验构建里被限制。默认构建仍带锁,是承认迁移需要发行周期,而不是一个开关。
语言层面的并发原语——队列、锁、异步——从来都能工作。锁挡住的不是并发叙事,是字节码级的数据并行。异步把等待重叠,不把计算重叠。把所有慢都怪到这把锁上,会忽略算法、数据布局和扩展是否释放锁。反过来,把自由线程当成“从此不必设计并发”,会忽略对象会计已经换了范畴。三十年的简单性是真的,账单也是真的;翻账本的人必须同时看见两页。
自由线程不会自动给出加速比
即使构建去掉了这把锁,纯 Python 循环仍受字节码调度、对象分配和缓存行争用限制。两个线程改邻近对象,会把引用计数所在的缓存行打脏。偏向计数降低单线程税,一旦对象真被共享,同步仍然发生。加速比是负载的函数:计算在扩展里且数据结构分得开,才接近线性;共享字典和模块状态,会把线程重新排队。
因此评估实验构建,要按负载分类量,而不是只跑一条基准再宣布胜利或失败。科学计算、数据解析、网络服务,三条曲线可以完全不同。把其中一条曲线当成语言的命运,会把一次实验读成终局。发行周期的意义正在于此:让三类负载的账单都被看见,再决定默认构建何时翻页。
数据科学把这套分层用到了极限。Pandas 大量操作仍在 Python 对象和 GIL 下走;真正快的路径是 NumPy 数组、Cython、近年的 Polars 与 Arrow——后两者从一开始就不把“每个格子一个 PyObject”当数据模型。Dask、Ray、Spark 用进程或跨机调度,绕过的不只是 GIL,还有单机内存。自由线程改变的是中间层:纯 Python 的 ETL、对象很多的变换,第一次有机会在单进程里用满多核。它不自动让旧版 Pandas 变快,也不替代 GPU 上的矩阵乘。pickle 的账单会少一些:进程模型为了躲 GIL 把巨型表复制出去;共享内存里的 Python 对象被多线程读,在自由线程下才更合法。只把 ThreadPoolExecutor 套在旧代码上,会先看见竞态,再看见加速。
异步并不是这把锁的替代品。它把等待重叠进单线程事件循环,计算仍然串行。在循环里塞进 CPU 密集工作,会拖死所有等待中的连接。正确拆法是:等待留在循环,计算交给进程池或会释放锁的扩展。自由线程打开后,事件循环与工作线程的组合会更诱人,也更容易把线程不安全的扩展拉进同一进程。默认策略仍应是:不明安全的扩展,不要和自由线程叠在同一条关键路径上。
标准库里不少模块在锁下是安全的,并不等于在自由线程里仍然安全。缓存、单例、模块级可变状态,要逐个重新看。官方文档用明确的声明来补信号:这个模块支持、这个模块尚未支持。应用作者应按声明来组合,而不是按“能导入”来组合。信号比口号更接近把均衡从旧锁里推开的办法。
标准库里不少模块在锁下是安全的,并不等于在自由线程里仍然安全。缓存、单例、模块级可变状态,要逐个重新看。官方文档用明确声明来补信号:这个模块支持,那个模块尚未支持。应用作者应按声明来组合,而不是按能够导入来组合。
异步把等待重叠进单线程循环,计算仍然串行。在循环里塞进密集计算,会拖死所有等待中的连接。等待留在循环,计算交给进程池或会释放锁的扩展。自由线程打开后,更不要把不明安全的扩展叠进同一条关键路径。
跨域连接
- 组合数学:GIL 把线程交错从“任意字节码边界上的全排列”收成“一次只有一个评估器”。这不是消灭交错,它只是把交错点移到锁的释放点——I/O、显式声明、定期检查。组合数仍然随释放点的密度上升。自由线程把组合空间重新打开:容器内部的指针摆弄现在必须对并发组合封闭,而不能靠“此时全世界只有我”来把非法组合整类切掉。PEP 703 的临界区是在这个重新打开的空间里人工划回一些互斥子集。测试很难穷尽那些子集之外的交错,这正是粗锁当年看起来如此划算的组合原因。
- 纳什均衡:所有计算线程都想要 GIL,锁的调度规则就是一场反复进行的博弈。公平性差一点,某类线程会学会占住锁;公平性过强,又会在多核上制造 Beazley 展示过的那种互相预占。没有人有单方面改规则的激励——扩展作者享受简单协议,语言内核享受单线程速度,应用作者用进程逃逸。纳什均衡停在“继续用粗锁”,直到硬件一侧的收益(多核计算)大到让其中一方愿意付对象模型的迁移成本。PEP 703 被接受,可以读成这次均衡被外生的硬件价格打破,而不是社区突然学会了并发。
- 信息不对称:C 扩展与解释器之间长期存在一份没有写全的合同。扩展作者以为“持有 GIL”等于“没有别人看见我正在改的对象”;解释器作者以为扩展会在阻塞前主动放锁。两边信息都不完全:大量扩展从未被在真正的多线程计算负载下审查,解释器也无法静态知道某段 C 代码会不会在持锁时做长时间循环。自由线程强迫这份不对称被声明出来——模块必须表态自己能不能在无 GIL 下跑。表态机制本身就是在减少隐藏信息,而不是在证明表态为真。
- 证明:GIL 给过一条极弱但好用的不变式:任意时刻至多一个线程位于字节码评估循环。许多“证明”对象结构正确的推理,其实把这条不变式当了公理。拿掉锁以后,那些推理必须换成每对象或每容器的局部不变式,并证明临界区确实覆盖了所有写路径。没有人会对整个 CPython 做 seL4 式的精化;能做的是把公理从全球锁改成局部断言,再靠测试和 sanitizer 去找反例。弱不变式被撤销之后,旧的正确性故事不会自动迁移。这是去掉 GIL 真正昂贵的地方,也是它拖了这么久的原因。
参考文献
- Gross, S. PEP 703: Making the Global Interpreter Lock Optional in CPython. Python Steering Council 接受, 2023.
- van Rossum, G. et al. PEP 684: A Per-Interpreter GIL. Python 3.12, 2022.
- Schemenauer, N., Peters, T., Hetland, M. L. PEP 442 / 循环垃圾回收相关历史 与 CPython
gc模块说明.(循环回收作为引用计数的补充) - Beazley, D. Understanding the Python GIL. PyCon 演讲, 2010.(多核上 GIL 争用的实测)
- Python Software Foundation. What's New in Python 3.13 中自由线程构建说明, 2024.
延伸阅读
- CPython 开发者文档 C API 与
Py_BEGIN_ALLOW_THREADS说明——读扩展何时应当放锁. - Okleshen, E. 等后续关于自由线程性能回归与对象头布局的核心开发者讨论(python-dev / Discourse).
- Breshears, C. The Art of Concurrency. O'Reilly, 2009.(对照:粗锁与数据竞争的一般语言,不绑定 Python)