码农高天
Python 3.10起仅在跳转与函数调用处检查eval_breaker,虽使简单循环暂时避开竞争冒险,但该行为属内部实现细节而非语言特性,生产环境放弃线程锁将带来严重风险。
AI-assisted summary of 【python】听说因为有GIL,多线程连锁都不需要了? — original source. Check the original for full context.
Source published:
Key ideas
- 在 Python 3.11 中运行多线程全局累加代码可能不再出现数据丢失,但这并非因为 GIL 让线程变得天生安全,而是底层字节码调度机制变化导致的副作用。 (0:00)
- Python 3.10 对主循环字节码分发机制进行了重构,常规指令执行完后直接跳转至 predispatch,跳过了顶部释放 GIL 的 eval_breaker 检查。 (5:00)
- 从 Python 3.10 开始,GIL 的释放基本被严格限制在函数调用(Call)和分支跳转(Jump)指令中发生,顺序执行的简单字节码序列不会在中间被切走。 (5:00)
- 官方从未将特定字节码序列的原子性作为语言特性(Feature)公开承诺,这种底层行为属于实现细节(Implementation Detail),多线程代码中仍必须规范加锁。 (5:00)
Detailed notes
## Background 该视频由“码农高天”于2024年8月31日发布在YouTube平台上。视频简介指出,本期内容主要探讨GIL(全局解释器锁)的释放机制,并深入剖析为什么在Python 3.10之后经典的竞争冒险示例代码不会报错了。
## TL;DR 视频通过一个经典的多线程计数器累加案例切入,解释了为何在Python 3.11中看似没有发生竞争冒险、结果恰好是200万的现象。作者深入对比了CPython 3.9与3.10+的源码架构,指出早期版本绝大多数字节码在DISPATCH时都会检查eval_breaker并可能释放GIL,而3.10+重构后跳过了常规检查,仅在函数调用和跳转(如循环边界)时才显式释放GIL。作者最后强调,这种现象纯属底层实现细节(Implementation Detail)而非官方语言特性,开发者绝不能以此为由在多线程编程中盲目省略线程锁。
## Atmosphere & style 整体氛围极具硬核技术极客的探索感,节奏紧凑且逻辑缜密。高天的讲解风格清晰直白、循序渐进,既有深入CPython底层C源码与宏定义的硬核剖析,又带着幽默风趣的反思视角。当戳破“有GIL就不需要锁”的误区时,其语调坚定而理性,在展现底层机制趣味性的同时,传递出成熟工程师对代码可维护性与生产规范的敬畏之心。
## Bulletin - [00:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=0s) 在 Python 3.11 中运行多线程全局累加代码可能不再出现数据丢失,但这并非因为 GIL 让线程变得天生安全,而是底层字节码调度机制变化导致的副作用。 - [05:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=300s) Python 3.10 对主循环字节码分发机制进行了重构,常规指令执行完后直接跳转至 predispatch,跳过了顶部释放 GIL 的 eval_breaker 检查。 - [05:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=300s) 从 Python 3.10 开始,GIL 的释放基本被严格限制在函数调用(Call)和分支跳转(Jump)指令中发生,顺序执行的简单字节码序列不会在中间被切走。 - [05:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=300s) 官方从未将特定字节码序列的原子性作为语言特性(Feature)公开承诺,这种底层行为属于实现细节(Implementation Detail),多线程代码中仍必须规范加锁。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=0s) GIL 的核心职责是防止两个线程同时运行字节码,但它无法杜绝多线程间的竞争冒险(Race Condition)。 - [00:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=0s) 在 Python 3.11 下测试两个线程各累加 100 万次的经典代码时,运行结果罕见地达到了完美的 200 万,容易引发“不用加锁”的误解。 - [00:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=0s) 如果将累加和赋值拆分到不同循环或跨越执行边界,或者退回 Python 3.9 环境,竞争冒险会立刻重现。 - [02:30](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=150s) Python 虚拟机的字节码调度在 PyEval_EvalFrameDefault 的 main loop 中运行,通过 eval_breaker 标志位判断当前线程是否需要交出 GIL。 - [02:30](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=150s) Python 3.9 中绝大多数普通字节码结尾采用 DISPATCH 宏(本质是 continue),会直接回到主循环顶部触发 eval_breaker 检查并释放 GIL。 - [02:30](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=150s) 在 Python 3.9 中,counter += 1 在执行完 BINARY_ADD 且写回变量前就可能释放 GIL,被其他抢占执行的线程覆盖结果,导致竞争冒险。 - [05:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=300s) Python 3.10+ 将 DISPATCH 改为直接 goto 到 predispatch 标签,使常规字节码执行完毕后完全跳过循环顶部的 eval_breaker 检查。 - [05:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=300s) Python 3.10+ 将 GIL 释放检查收敛到显式调用的 CHECK_EVAL_BREAKER 宏,通常仅在函数调用和分支跳转(Jump)两类指令中触发。 - [05:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=300s) Python 3.11 中单次循环内的简单累加属于连续顺序指令,中途绝不包含分支或函数调用,因此在计算完成并写回前不会发生线程切换。 - [05:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=300s) Faster CPython 团队在 3.10 至 3.13 期间对帧循环进行了大规模重构,调度机制属于随时可能变动的底层实现细节而非稳定的语言规范。 - [05:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=300s) 优秀代码的核心是清晰易懂,在生产环境利用不公开的底层实现细节去“炫技”不加锁,升级版本时会引发严重的稳定性隐患。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=0s) GIL与多线程竞争冒险:Python 3.11 为何“不报错”了?
关于 Python 的全局解释器锁(GIL, Global Interpreter Lock),网上的科普大多只停留在“GIL 是为了防止两个线程同时运行字节码”的层面上,但很少有人会深入探讨其具体的释放机制,即:每一个线程究竟在什么时候才会释放这个锁?
为了说明这个问题,码农高天给出了一个极简的经典竞争冒险(Race Condition)示例代码:定义一个函数 `f`,其逻辑非常简单,就是通过循环让一个全局变量 `counter` 不断自增(`counter += 1`)。接着,程序创建两个线程同时运行这个函数 `f`,各自累加 100 万次。理论上,最终打印出的 `counter` 只有两种可能:要么完美等于 200 万,要么因为多线程竞争冒险导致数据丢失而小于 200 万。
令人意外的是,如果在 Python 3.11 中直接运行这段代码,打印出来的结果居然恰好是 200 万整,竞争冒险并没有出现。这时可能有人会误以为:“这是因为 Python 有 GIL,由于 GIL 的存在,Python 的多线程程序压根就不会出现竞争冒险,也就是说 Python 的线程不用加锁。”面对这种推论,码农高天直言:“绝对不存在什么由于有了 GIL 我的多线程就不需要锁这种说法。”
只要稍微调整一下代码,就能戳破这种错觉: 1. 如果在同一个循环内把 `counter + 1` 先赋值给一个局部变量,再把局部变量赋值回全局变量 `counter`,运行结果依然是 200 万。 2. 但如果将逻辑拆分,比如调换相关执行顺序,让计算和赋值跨越了循环的边界,竞争冒险便会立刻重现,最终结果又不再是 200 万了。 3. 此外,如果退回到 Python 3.9 版本中去跑最原始的累加代码,竞争冒险同样会赫然出现,结果绝不会是 200 万。
要想搞清楚为什么 3.11 和 3.9 表现完全不同,就必须深入 CPython 的源码,去探寻 GIL 究竟是在哪一个具体的执行节点被释放的。
```mermaid flowchart LR accTitle: GIL 释放在 Python 3.9 与 3.10+ 中的行为对比 accDescr: 展示 Python 3.9 依靠字节码 dispatch 频繁检查 eval_breaker,而 Python 3.10+ 改为仅在分支和函数调用时显式检查的过程。
subgraph Py39["Python 3.9 循环机制"] A1["运行字节码 (如 BINARY_ADD)"] --> B1{"宏调度: DISPATCH"} B1 -->|"触发 main loop 顶部"| C1["检查 eval_breaker"] C1 -->|"释放 GIL"| D1["线程切换 -> 触发竞争冒险"] end
subgraph Py310["Python 3.10+ 循环机制"] A2["普通计算字节码"] --> B2{"宏调度: DISPATCH"} B2 -->|"跳转到 predispatch"| C2["跳过 eval_breaker 检查"] E2["分支/跳转/调用 (Jump / Call)"] --> D2["显式 CHECK_EVAL_BREAKER"] D2 -->|"仅在此时"| C1 end ```
---
[02:30](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=150s) Python 3.9 的 GIL 调度:为什么普通累加会触发竞争?
Python 虚拟机的核心执行逻辑位于 `PyEval_EvalFrameDefault` 函数中。在每个 frame(即函数调用栈帧)中,该函数通过一个 `main loop` 负责逐个读取并执行字节码。
在 Python 3.9 的源码设计中,`main loop` 循环的开头会对 `eval_breaker` 进行检查。这个 `eval_breaker` 中存储的关键标志位之一,就是当前线程是否需要交出 GIL。真正执行释放与交接 GIL 的逻辑位于 `_PyEval_EvalFrameDefault` 调用的 `eval_frame_handle_pending` 等函数中。大体上,每一次主循环对应执行一条字节码指令,如果每执行一条指令都去检查一次 GIL,系统开销(overhead)就会过大,因此 Python 做了一定的宏调度优化: - `FAST_DISPATCH`:直接使用 `goto` 跳转到对应的目标标签,从而跳过循环顶部的 `eval_breaker` 检查。 - `DISPATCH`:本质上是一个 `continue`,它会回到 `main loop` 的最上方,直接触发下一次循环并检查 `eval_breaker`。
在 Python 3.9 的代码库中,使用 `DISPATCH` 的字节码占绝大多数(有 100 多个),而使用 `FAST_DISPATCH` 的只有区区 20 多个。这意味着绝大多数普通字节码在执行完毕后,都有可能在下一个循环交出 GIL。
在最原始的 `counter += 1` 语句中,对应的字节码包括 `LOAD_GLOBAL`、`LOAD_CONST` 以及相加计算的 `BINARY_ADD` 等。由于 Python 3.9 中的 `BINARY_ADD` 结尾使用的是 `DISPATCH`,因此在这个加法计算完成后、尚未把新值通过赋值字节码写回 `counter` 之前,当前线程就很有可能触发了检查并把 GIL 交给了另一个线程。另一个线程抢占执行后,疯狂地自增循环了半天;当控制权最终还给原本的线程时,原本的线程才慢悠悠地把很久之前算好的旧结果写回 `counter`,这就导致了另一线程期间所有的计算结果被硬生生覆盖掉,构成了经典的竞争冒险。
---
[05:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=300s) Python 3.10+ 的架构重组与“炫技”陷阱
到了 Python 3.10 及以后的版本,底层字节码分发机制发生了非常关键的重构: 1. 原先会触发 `continue` 的 `DISPATCH` 宏被改写成了 `goto _predispatch`,而原有的 `FAST_DISPATCH` 被直接移除。 2. 循环顶部释放 GIL 的逻辑依然存在,但由于普通的 `DISPATCH` 现在全部直接跳向了循环下方的 `predispatch` 标签,绝大多数常规字节码在执行完后根本不会再回到顶部,从而**完全跳过了 GIL 的检查**。 3. 源码中新增了一个名为 `CHECK_EVAL_BREAKER()` 的宏,并且只在极少数与分支、跳转相关的字节码中显式调用。
码农高天强调,在 Python 3.10 之后,显式检查 `eval_breaker` 释放 GIL 的情况基本只收窄到了两个场景: - **函数调用(Function Call)** - **跳转指令(Jump)**:即字节码脱离常规顺序执行,最典型的对应场景就是 `if` 判断、`for` 循环或 `while` 循环等分支控制流。
这也就解释了为何在 Python 3.11 中运行单次循环内的 `counter += 1` 结果会是完美的 200 万:因为整个取值、相加到写回的字节码执行流都是顺序指令,中间根本没有包含任何分支跳转或函数调用,GIL 在这几条指令执行期间是绝对不可能发生转移的。然而,一旦把操作拆成不同的循环,或者引入了包含 jump 的控制逻辑,GIL 依然会在循环边界被释放并触发线程切换,竞争冒险自然就卷土重来。
那么,既然掌握了这个机制,开发者是否可以在工作中写不加锁的多线程代码,甚至在别人质疑时去嘲笑对方“你在第三层,我在第五层”呢?
码农高天给出了斩钉截铁的否定,并给出了两条至关重要的理由: - **拒绝无意义的炫技**:“一个优秀的程序员写出来的代码应该是大家能读得懂的,不是所有人都读不懂的。” - **这是实现细节(Implementation Detail)而非语言特性(Feature)**:“Python 从来没有官方宣布过这个 feature,换句话说现在这个情况它是一个 behavior,它不是一个 feature。”
从 Python 3.10 到 3.13,由 Faster CPython 团队主导的解释器帧循环进行了翻天覆地的重写与激进优化,其底层调度机制随时都可能被再次推翻。如果业务代码依赖这种未公开的底层实现细节,未来必然会在版本升级中遭遇灾难性的后果。码农高天在结尾总结道,这项冷知识“很酷,但是可能对你没什么用”,绝不能成为在实际生产中抛弃线程锁的借口。
## Notable quotes > [00:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=0s) 绝对不存在什么由于有了 GIL 我的多线程就不需要锁这种说法。 — 码农高天
> [05:00](https://www.youtube.com/watch?v=qQt7G5qhRS8&t=300s) 一个优秀的程序员写出来的代码应该是大家能读得懂的,不是所有人都读不懂的。 — 码农高天
## Mentioned 码农高天, Python, CPython, Faster CPython, GIL, Python 3.9, Python 3.10, Python 3.11, Python 3.13
## Video description 这期视频我们来聊一下GIL的释放,为什么在Python3.10之后,经典的竞争冒险代码不出问题了呢?
[Source](https://www.youtube.com/watch?v=qQt7G5qhRS8)