码农高天
GIL通过全局互斥执行消除了引用计数的并发冲突并降低了C扩展门槛,但也导致解释器无法在多核时代实现原生多线程并行计算,迫使密集任务依赖多进程规避性能瓶颈。
AI-assisted summary of 【python】天使还是魔鬼?GIL的前世今生。一期视频全面了解GIL! — original source. Check the original for full context.
Source published:
Key ideas
- GIL 的诞生本质上是为了解决 CPython 内存管理中“引用计数”在多线程环境下的非原子性并发竞争问题。 (2:30)
- GIL 方案长期得以保留的核心原因在于其架构实现简单、天然避免了死锁,并且最大程度保障了单线程性能与 C 扩展生态的开发便利性。 (7:15)
- 诞生于单核时代的 GIL 在如今多核 CPU 普及的背景下成为瓶颈,导致 Python 无法利用原生多线程有效加速 CPU 密集型任务。 (9:30)
- 目前开发者规避 GIL 限制的主要方案包括使用 multiprocessing 模块进行多进程并发、在底层 C 扩展中实现多线程,或选用 Jython 等无 GIL 替代解释器。 (10:45)
Detailed notes
## Background 该视频由技术博主“码农高天”于 2023 年 10 月 16 日发布在 YouTube 平台上。视频旨在系统梳理 Python 社区中讨论度极高且常被误解的 GIL(全局解释器锁),从其诞生历史、技术现状到优缺点进行全方位解析,帮助开发者彻底解开对 GIL 的迷茫。
## TL;DR 内容从操作系统底层的线程、进程及竞争冒险等基础概念切入,深入剖析了 CPython 采用引用计数进行内存管理时面临的并发危机,进而解释了引入全局解释器锁(GIL)的根本原因。随后,作者从“天使”的一面论证了 GIL 具备架构极简、杜绝死锁、保障单线程高效运行以及大幅降低 C 扩展开发门槛等巨大红利,说明了其长期无法被轻易移除的技术原因。最后,作者直面 GIL 在现代多核硬件下导致原生线程无法实现 CPU 密集型任务并行的“恶魔”一面,并为开发者梳理了多进程(multiprocessing)、底层 C 扩展多线程以及选用 Jython 或 IronPython 等无 GIL 解释器的应对破局方案。
## Atmosphere & style 整期视频呈现出严谨扎实却又通俗易懂的硬核技术分享风格,高天的讲解条理清晰、抽丝剥茧。他善于运用“上厕所锁门排队”等生动的日常比喻,将晦涩的底层锁机制与复杂的操作系统原理讲得趣味盎然。观看体验既像是跟随经验丰富的前辈拆解 CPython 底层源码的幕后设计,又充满客观理性的工程权衡思考,让观众在理解技术历史局限性的同时收获开阔的技术视野。
## Bulletin - [02:30](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=150s) GIL 的诞生本质上是为了解决 CPython 内存管理中“引用计数”在多线程环境下的非原子性并发竞争问题。 - [07:15](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=435s) GIL 方案长期得以保留的核心原因在于其架构实现简单、天然避免了死锁,并且最大程度保障了单线程性能与 C 扩展生态的开发便利性。 - [09:30](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=570s) 诞生于单核时代的 GIL 在如今多核 CPU 普及的背景下成为瓶颈,导致 Python 无法利用原生多线程有效加速 CPU 密集型任务。 - [10:45](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=645s) 目前开发者规避 GIL 限制的主要方案包括使用 multiprocessing 模块进行多进程并发、在底层 C 扩展中实现多线程,或选用 Jython 等无 GIL 替代解释器。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=0s) 同一进程内的多个线程共享全部内存资源,且线程间的调度顺序不可控,这导致多线程在并发读写共享变量时极易触发竞争冒险(Race Condition)。 - [02:30](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=150s) CPython 依赖引用计数进行自动内存管理,当计数归零时立即回收内存。 - [03:05](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=185s) 引用计数的底层修改在 CPU 指令集层面非原子操作,多线程并发交错修改会导致计数错误,从而引发内存泄漏或悬空指针崩溃。 - [05:00](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=300s) 为每一个底层 PyObject 对象单独加细粒度锁会导致设计复杂度爆炸,CPython 最终选择在解释器核心引入唯一的全局解释器锁(GIL)。 - [06:15](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=375s) 在 CPython 的核心执行循环中,线程在运行字节码之前必须先持有 GIL,从而确保单条字节码运行期间底层的 C 语言操作天然具备线程安全。 - [07:15](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=435s) GIL 的架构极简性大幅降低了代码维护成本,并且单全局锁的设计从根本上消除了多锁交叉引起的死锁隐患。 - [08:00](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=480s) 细粒度锁会在单线程中产生频繁的加锁与解锁开销,而 GIL 确保单字节码最多加锁一次,保障了单线程运行性能。 - [08:30](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=510s) GIL 为早期 C 扩展开发者免去了繁重的线程同步负担,是促成 Python 生态繁荣的重要基石。 - [08:55](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=535s) 历史上移除 GIL 的尝试屡屡受挫,核心障碍在于无法避免单线程性能倒退,且会严重破坏现有海量第三方 C 扩展的向后兼容性。 - [09:30](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=570s) Python 诞生于 20 世纪 90 年代初期,单核时代的全局锁能够很好满足“时间片切片”并发需求,但与现代多核 CPU 的并行架构产生了严重冲突。 - [10:10](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=610s) 由于单解释器进程在任意时刻仅允许单个线程执行字节码,Python 原生多线程在 CPU 密集型任务中无法利用多核提升性能。 - [11:10](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=670s) 使用 multiprocessing 模块开启多进程是规避 GIL 束缚最常见且最 Pythonic 的方案,因为每个进程拥有独立的解释器和内存空间。 - [11:40](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=700s) 将计算密集型逻辑与多线程交给底层 C 扩展执行可突破 GIL,但开发者必须自行承担底层线程安全的维护工作。 - [12:05](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=725s) Jython 和 IronPython 等替代解释器没有 GIL,而 PyPy 解释器内部依然实现了 GIL。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=0s) 线程、进程与竞争冒险(Race Condition)
在探讨 GIL(Global Interpreter Lock,全局解释器锁)之前,首先需要理清操作系统层面的基础概念。程序都是运行在线程中的,线程是操作系统进行计算和调度的最小单位,每个线程拥有属于自己的上下文;而进程则是比线程更大的单位,拥有独立的内存等资源。一个进程内部可以包含多个线程,这些线程会共享该进程拥有的全部内存空间。换句话说,同一个进程内的多个线程都可以直接读写相同的变量。
当一个进程包含不止一个线程时,就会引出一个经典的并发问题——“竞争冒险”(Race Condition)。一个进程内的若干线程既可能在多核上同时运行,也可能在单核上交替调度运行,但无论运行方式如何,程序员都无法预先控制它们之间的相对执行顺序。码农高天举了一个直观的代码示例:假设线程 1 与线程 2 共享同一个变量 `A`,且两者都成功将 `A` 初始化为 `1`。线程 1 首先执行条件判断 `if A > 0:`,判断结果为真,正准备进入条件块执行 `A -= 1`;然而在线程 1 真正执行减法之前,操作系统调度切换到了线程 2,线程 2 同样去判断 `if A > 0:`,此时 `A` 依然是 `1`,因此线程 2 也顺利进入了条件块。最终结果是,两个线程都执行了减法操作,导致 `A` 被减了两次,而该程序本来的业务逻辑显然是希望将 `A` 减到 `0` 为止。码农高天总结道:“像这种由于线程之间的相对运行顺序不同导致的结果不同的情况,我们就叫做 racing condition,或者叫做竞争冒险。”
```mermaid flowchart TD accTitle: 竞争冒险执行时序 accDescr: 展示两个并发线程如何因为缺乏同步而错误地对共享变量执行两次减一操作。
subgraph Thread1 ["线程 1"] T1_Check["判断 if A > 0 (A 为 1, 成立)"] T1_Sub["执行 A -= 1 (A 变为 0)"] end
subgraph Thread2 ["线程 2"] T2_Check["判断 if A > 0 (A 仍为 1, 成立)"] T2_Sub["执行 A -= 1 (A 变为 -1, 产生 Bug)"] end
T1_Check --> T2_Check T2_Check --> T1_Sub T1_Sub --> T2_Sub ```
---
[02:30](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=150s) 内存管理与引用计数的并发危机
在 C 与 C++ 等传统底层语言中,程序员必须显式地分配与释放内存;如果只分配而不释放,内存占用就会持续累积直至耗尽。而在 Python 中,开发者完全不需要手动管理内存,所有的 Python 对象(如列表、字典等)都是拿来即用。这种便利源于 Python 解释器内部实现的内存管理机制。
在内存管理中,“分配”相对直接,核心难点在于确定“何时可以安全地释放内存”。CPython 采用的基石机制是“引用计数”(Reference Counting)。其原理十分清晰:每一个 Python 对象内部都会记录当前有多少个地方引用了自己。每当出现一个新的引用,对象的引用计数就加 1;当引用失效或对象离开作用域时,引用计数就减 1。一旦引用计数归零,解释器就知道没有任何地方再需要该对象,便可以直接调用底层释放函数(如 `Py_DECREF` 中的内存回收逻辑)将其占用的内存释放掉。
然而,引用计数一旦置于多线程环境下,就会立刻遭遇致命的竞争冒险: - **操作非原子性**:虽然在 C 语言源码中对计数的减少看起来只有一行(例如对 `ob_refcnt` 进行 `--` 操作),但它并不是原子操作(Atomic)。在底层 CPU 指令集层面,该操作必须拆分为三个离散步骤:读取计数值、在寄存器中减 1、将新值写回内存。 - **并发交错的灾难**:在这三个微步骤执行的间隙,其他线程极有可能插入并同时修改该计数。如果发生交错,就会导致计数出现多数或少数的偏差。 - **后果**:一旦引用计数紊乱,Python 解释器就无法准确判断对象的生命周期,进而导致极为严重的内存泄漏,或者过早释放内存引发悬空指针崩溃。
---
[05:00](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=300s) 加锁机制与 GIL 的诞生
在多线程编程中,解决竞争冒险最通用的方案是“加锁”(Locking)。加锁的本质是确保受保护的代码段在同一时刻仅能被一个线程执行。码农高天打了一个非常生动的比方:“你可以理解为你上厕所的时候有一个隔间,你进去之后你先把门插上,然后你在里面干什么事别管,剩下的人想进这个门要先排队,等你出来之后他才能进去。”
面对引用计数的并发修改,最直观的设想或许是在每次修改前显式加锁。但 Python 面临的并发问题远不止引用计数这一个点——几乎所有操作 Python 内部对象(`PyObject`)的底层 C 代码,都存在被多个线程同时读写的风险。如果为每个对象的细粒度访问分别加锁,系统的设计与维护复杂度将呈指数级爆炸。因此,Python 的核心设计者们采取了一种更为彻底的手段:为整个解释器引入一把全局锁,即 Global Interpreter Lock(GIL)。
在 CPython 解释器的核心主循环文件 `ceval.c`(约 1430 行的 `mainloop`)中,线程在运行前必须调用特定函数去获取这把全局锁。其调度逻辑保证了:如果线程收到了释放请求,会先释放锁再尝试重新获取。通过这种机制,Python 确保了任何一个线程在执行字节码(bytecode)时都必须持有该全局锁。换句话说,没有任何一条字节码的执行过程会被其他线程抢占打断。在单条字节码运行期间,底层的 C 语言代码天然处于线程安全状态,增减引用计数等底层操作均无需担心并发冲突。
---
[07:15](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=435s) 天使的一面:GIL 为何至今未被移除?
尽管如今 GIL 常被很多开发者诟病为阻碍 Python 迈向高性能并发的“桎梏”,但它在早期被采用并沿用至今,具备一系列非常关键的技术红利:
1. **架构极度简单且易于维护**:相较于为每个对象单独设计细粒度锁,全局锁的方案实现成本极低。码农高天特别强调了工程落地的核心法则:“越简单的程序你越有可能写对,你在维护上所需要的努力就越小,而这种全局锁相对于你可能每一个 object 都实现一个自己的锁要简单非常多。” 2. **根除死锁隐患**:当一个程序中同时存在两个及以上的锁时,极易因锁的争抢顺序不一致而触发死锁(Deadlock)。由于 GIL 是唯一的全局线程锁,解释器核心逻辑天然避免了多锁交叉死锁的风险。 3. **单线程执行性能极佳**:加锁和解锁本身都伴随着不可忽视的时间开销。GIL 保证了每条字节码执行最多只需要获取一次锁;如果改成每个对象各带一把锁,单条字节码在访问多个对象时就需要频繁加锁解锁数十次,这将大幅拖垮单线程程序的运行性能。 4. **极大地降低了 C 扩展开发门槛**:在字节码级别天然杜绝了竞争冒险后,第三方开发者编写 C 扩展(C Extension)去操作 Python 对象时,根本不需要处理繁杂的线程同步问题。Python 能拥有今日庞大的生态繁荣,很大程度上归功于早期 C 扩展编写的低门槛。
历史上并非没有人尝试彻底拿掉 GIL,但以往所有的尝试都面临无法逾越的阻碍:没有一种方案能在移除 GIL 的同时保证单进程单线程下的性能不发生明显倒退。此外,另一个更严峻的挑战在于向后兼容性(Backward Compatibility)——海量现存的第三方 C 扩展库在编写时均默认持有 GIL,一旦贸然移除,整个既有生态的代码都将面临大面积损坏。
---
[09:30](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=570s) 恶魔的一面与多核时代的破局之道
讨论完 GIL 的优势,就必须直面其被广泛诟病的“恶魔”一面。理解这一缺陷需要结合计算机体系架构的发展历史。很多人潜意识里觉得 Python 是一门新兴语言,但它实际上诞生于 20 世纪 90 年代初期,甚至比 Java 问世还要早。在那个年代,消费级多核处理器基本不存在,多线程技术的核心价值在于让任务轮流执行,防止某一个耗时计算卡死整个界面或阻塞其他任务,本质上是“时间片切片并行”。由于物理上只有一颗 CPU 核心,在当时使用单一的全局解释器锁完全没有暴露缺陷。
然而迈入 21 世纪后,多核心 CPU 已经成为计算机的绝对标配,操作系统的多线程能够被真正分配到各个独立的物理核心上并行运行。例如原本需要计算 100 个数字,在 4 核 CPU 上让每个核心分担 25 个,理想情况下速度可以直接提升 4 倍。但在这种硬件背景下,GIL 产生了严重的“水土不服”:因为单个 Python 解释器进程在任意时刻只允许唯一一个线程执行字节码,无论代码创建了多少个线程,物理上同时运行 Python 代码的永远只有一个核心。这直接导致 Python 的原生多线程无法通过多核并行来加速 CPU 密集型任务。
```mermaid flowchart LR accTitle: 多核硬件下的 GIL 瓶颈 accDescr: 对比操作系统多核 CPU 与 Python 解释器受制于 GIL 的执行瓶颈。
subgraph Hardware ["多核 CPU 硬件"] C1["Core 1"] C2["Core 2"] C3["Core 3"] C4["Core 4"] end
subgraph CPython ["CPython 解释器"] GIL{{"GIL 全局锁"}} T1["线程 1 (持有锁运行中)"] T2["线程 2 (阻塞等待锁)"] T3["线程 3 (阻塞等待锁)"] end
T1 -.->|"占用"| C1 GIL --- T1 GIL -.->|"互斥拦截"| T2 GIL -.->|"互斥拦截"| T3 ```
面对这一硬件时代的代沟,Python 社区演化出了多种规避与破局方案: - **多进程(`multiprocessing`)**:这是最简单、也最具“Pythonic”风格的解决方案。由于 GIL 仅存在于单个解释器进程之内,通过开启多个独立的进程,每个进程拥有独立的解释器与内存空间,便能完美绕开 GIL 的束缚,将所有的 CPU 物理核心跑满。 - **编写 C 扩展并在底层实现多线程**:开发者可以在 C 扩展中编写密集计算逻辑,并将多线程派发给底层的 C 代码运行,此时脱离了 Python 字节码解释层,自然可以实现真正的多核多线程并行。但这种方式要求开发者必须自行处理底层线程的竞争冒险问题,尤其在并发访问 Python 原生对象时需要极度小心。 - **选用无 GIL 的替代解释器**:除了官方的 CPython 之外,市面上还存在其他解释器实现。例如运行在 JVM 上的 Jython 以及基于.NET 的 IronPython 内部都没有实现 GIL;不过需要注意的是,以 JIT 编译著称的 PyPy 解释器依然包含 GIL。
## Notable quotes > [07:15](https://www.youtube.com/watch?v=XjBsk8JGHhQ&t=435s) 越简单的程序你越有可能写对,你在维护上所需要的努力就越小,而这种全局锁相对于你可能每一个 object 都实现一个自己的锁要简单非常多。 — 码农高天
## Mentioned 码农高天, Python, CPython, GIL, C, C++, Java, JVM, Jython, .NET, IronPython, PyPy
## Video description 这期视频讲一个呼声很高的话题,GIL。很多人对GIL一知半解,甚至有人不知道它是个什么东西。这期我们从它的由来现状优缺点等等各种方向仔细聊聊,一期视频,你就再也不迷茫了!
[Source](https://www.youtube.com/watch?v=XjBsk8JGHhQ)