Skip to main content

Chapter 45: GIL Internals

本章要建立一个可执行的判断能力:看到一个多线程 Python 程序时,先定位它运行在什么解释器和构建模式上,再追踪 OS thread、PyThreadState、interpreter state、GIL、bytecode 执行和 C 扩展边界之间的关系,最后判断瓶颈来自 Python 字节码串行执行、I/O 等待、用户锁竞争,还是扩展代码持续持有 GIL。

GIL(Global Interpreter Lock,全局解释器锁)在本章特指默认 CPython 构建中的全局解释器锁。CPython 官方 C API 文档把它定义为进入 Python 对象和 Python C API 前必须持有的全局锁,并说明普通多线程程序依靠它让解释器内部状态保持一致:Thread states and the global interpreter lock。这个边界很关键:GIL 保护的是 CPython runtime 对象和 C API 访问路径,用户代码里的业务不变量仍然要靠 threading.Lock、队列、单线程归属或进程隔离来维护。

本章默认版本边界是 CPython 3.13+ 到 3.14 的主线行为,并以 GIL-enabled 默认构建为主。Python 3.13 开始提供 free-threaded 构建,能够关闭 GIL,PEP 703 已把这条路线写入 CPython 演进计划:PEP 703。因此,本文提到“GIL 串行执行 Python 字节码”时,范围是默认 CPython GIL 构建;遇到 free-threaded 构建时,需要把结论改写成“多个线程可以同时处于 attached 状态,但对象内部锁、引用计数策略、GC 暂停和扩展兼容性成为新的边界”。

贯穿本章的观察对象是下面这个短程序。它同时包含纯 Python CPU 循环、阻塞 I/O 等待和共享对象修改,足以把 GIL 的三个常见误判拆开。

import threading
import time

counter = 0
records = []

def cpu_worker(rounds: int) -> None:
global counter
for _ in range(rounds):
counter += 1


def io_worker(delay: float) -> None:
time.sleep(delay)
records.append("done")

threads = [
threading.Thread(target=cpu_worker, args=(200_000,)),
threading.Thread(target=cpu_worker, args=(200_000,)),
threading.Thread(target=io_worker, args=(0.2,)),
threading.Thread(target=io_worker, args=(0.2,)),
]

for thread in threads:
thread.start()

for thread in threads:
thread.join()

print(counter, len(records))

这个程序用于建立分析顺序,运行结果只作为观察材料:cpu_worker 反复执行 Python bytecode,默认 CPython 中同一时刻只有一个线程在跑这些 bytecode;io_worker 进入 time.sleep 后底层可以释放 GIL,其他线程可以继续取得解释器执行权;counter += 1 看起来短,但它跨越读取、计算和写回多个 runtime 动作,需要显式同步才能作为业务原子操作使用。

45.1 GIL, interpreter state, and thread state

理解 GIL 要从三个对象层级开始:OS thread 是操作系统调度的线程,PyThreadState 是 CPython 记录某个线程当前执行状态的数据结构,interpreter state 是一个解释器实例拥有的全局运行时状态。GIL 连接的是后两个层级:线程想访问 Python 对象和 Python C API,必须先拥有 attached thread state;在默认 GIL 构建中,attached thread state 通常意味着该线程已经持有 GIL。

PyThreadState 可以理解为“这个线程此刻在 Python runtime 里的身份证和现场记录”。它关联到一个 interpreter,保存当前 frame、异常状态、递归深度、tracing/profiling 信息,以及执行器需要从当前线程取出的其他状态。一个 OS thread 可以存在于进程里很久,但只有当它进入 Python runtime 并关联了 PyThreadState 后,CPython 才能把它当成一个可以执行 Python 代码的线程。

interpreter state 可以理解为“这个解释器实例的共享运行环境”。模块表、导入相关状态、内置对象、运行时配置、垃圾回收相关结构和执行器共享状态都和它有关。多个 Python 线程共享同一个 interpreter 时,它们面对的是同一批 Python 对象和解释器级状态。GIL 的作用是在默认构建中把这些共享状态的可变访问串行化,让引用计数、容器内部结构、frame 切换、异常状态等底层不变量在普通 C 操作下保持一致。

下面的关系图只描述默认 CPython GIL 构建中的主干路径:

图中的关键关系是:OS thread 先通过 PyThreadState 进入某个 interpreter,再由当前 frame 交给 bytecode evaluator 执行。GIL 是解释器级入口约束,作用范围落在 CPython 共享状态和对象访问路径上;业务临界区仍然由 threading.Lock 或更明确的所有权设计承担。

回到贯穿程序,四个 threading.Thread 都会映射到四个 OS thread。每个线程启动后,CPython 为它准备线程状态。两个 cpu_worker 线程都想执行同一个 interpreter 下的 Python bytecode,因此默认 GIL 构建会让它们在解释器层面轮流推进。两个 io_worker 线程进入 time.sleep 后,底层阻塞等待可以脱离 Python 对象访问,CPython 可以释放 GIL,让其他已准备好的 thread state 获得执行机会。

这个模型还能解释一个常见现象:多线程程序的 OS 线程数量很多,CPU 也有多个核心,但默认 CPython 里纯 Python 计算仍然保持串行推进。操作系统可以调度多个线程,CPython 对 Python 对象和 bytecode 执行路径加了一层解释器级入口。真正限制 Python bytecode 并行的是“同一时刻只有一个持有 GIL 的线程在该 interpreter 中执行 Python 代码”。官方 threading 文档也把这个边界写成实现细节:默认 CPython 中,由于 GIL,同一时刻只有一个线程执行 Python 代码;线程仍然适合 I/O-bound 任务:threading — GIL and performance considerations

需要保留一个版本边界:free-threaded 构建仍然要求线程拥有 attached thread state 才能访问 Python runtime,但多个线程可以同时 attached。此时 GIL 这层全局串行约束被移除或重新开启,安全性转移到对象锁、原子操作、biased reference counting、stop-the-world GC 暂停和扩展模块声明上。CPython 3.14 的 free-threading HOWTO 明确提到内置 dictlistset 使用内部锁来保护并发修改,同时列出 frame、iterator、内存占用等限制:Python support for free threading

45.2 Bytecode lock, thread switching, and eval breaker

GIL 的执行过程容易被误读成“每条 bytecode 前后都加锁”。更准确的运行图是:线程取得 GIL 后进入 bytecode evaluator,连续执行一段指令;解释器在安全检查点观察是否需要处理异步事件、信号、pending call、线程切换请求或其他维护动作;当切换条件成立时,当前线程释放 GIL,等待线程取得 GIL 后继续执行自己的 frame。

sys.setswitchinterval() 控制的是理想切换间隔,单位是秒。它给解释器一个调度倾向:运行线程持续一段时间后,其他等待 GIL 的线程可以请求当前线程让出执行权。官方 C API 文档把线程切换描述为解释器在 bytecode 指令之间定期尝试切换线程,并指向 sys.setswitchinterval();这说明切换点位于解释器安全点,操作系统不会直接抢占并改写 Python 对象内部状态。

在 CPython 源码阅读中,相关路径通常从三个方向观察:Python/ceval.c 承载 bytecode evaluator 主体,Python/ceval_gil.c 承载 GIL 管理细节,内部头文件中会出现 eval breaker 相关状态。具体文件和字段会随版本变动,源码阅读时应以当前 checkout 为准。阅读目标是确认三个事实:谁提出切换请求,当前执行线程在哪个检查点看到请求,释放与重新获取 GIL 时 thread state 如何保存和恢复。

下面的状态图把一次切换压缩成四个阶段:

Running 阶段代表线程正在执行当前 frame 的 bytecode。CheckBreaker 代表执行器读到一个快速标志并进入慢路径处理。这个慢路径可能处理信号、pending call、异步异常、GC 相关暂停或 GIL 切换请求。DropGIL 代表当前线程保存状态并释放解释器入口,让其他 thread state 获得机会。

贯穿程序中的两个 cpu_worker 线程会在这个状态图中反复轮转。某个线程执行一段 counter += 1 的 bytecode 后,eval breaker 触发检查;如果另一个线程正在等待 GIL,当前线程可能释放 GIL。另一个线程随后在自己的 frame 中继续循环。操作系统层面可能两个线程都处于 runnable 状态,但解释器层面始终通过 GIL 控制进入 bytecode evaluator 的线程数量。

这个路径也解释了 sys.setswitchinterval() 的边界。减小切换间隔可能提升多线程交替响应性,但会增加切换开销;增大切换间隔可能降低切换开销,但会让一个 CPU-bound Python 线程更久占有解释器。它不把纯 Python 多线程变成多核并行,也不替代用户级锁。对 CPU 密集型代码,切换间隔只是“谁先推进一段 bytecode”的调度参数。

长时间运行的 C 函数会改变这张图。如果 C 函数持有 GIL 并持续计算,它可能让其他 Python 线程长时间等在 GIL 外面;如果 C 函数在不访问 Python 对象的阶段释放 GIL,其他 Python 线程可以继续执行。诊断多线程卡顿时,必须确认热点位于 Python bytecode、持有 GIL 的 C 扩展、已经释放 GIL 的 native 计算,还是阻塞 I/O。

45.3 CPU-bound limitation and I/O behavior

GIL 对 CPU-bound 与 I/O-bound 的影响不同,原因在于二者占用的执行资源不同。CPU-bound 纯 Python 工作负载主要消耗 bytecode evaluator 时间;默认 CPython 中这些 bytecode 需要持有 GIL 才能访问 Python 对象。I/O-bound 工作负载大量时间停在操作系统文件、网络、sleep、数据库驱动或其他阻塞等待上;等待期间如果底层代码释放 GIL,其他线程就能执行 Python 代码。

贯穿程序里的 cpu_worker 属于纯 Python CPU 路径。counter += 1 每轮都要读取全局变量、读取常量、执行数值加法、写回全局变量。这些动作都在 Python object 与 frame 状态之间移动,需要解释器持有 GIL。两个这样的线程同时启动后,默认 CPython 会交替执行它们,两个核心无法同时执行两份 Python bytecode。任务完成时间可能因为线程切换略有变化,但多核 CPU 无法直接把这段纯 Python 循环压成一半时间。

io_worker 的关键动作是 time.sleep(delay)。sleep 期间线程等待操作系统计时器,既不读取 Python frame 的局部变量,也不修改 Python 对象。CPython 可以在这类阻塞调用周围释放 GIL。等待结束后,线程重新取得 thread state/GIL,再执行 records.append("done")。因此,两个 I/O 线程的等待时间可以重叠,程序表现出并发效果。

这条规则可以推广到真实服务端程序。Web 爬虫、日志上传、数据库查询、RPC 调用和文件读写往往包含大量外部等待,threadingThreadPoolExecutor 可以用较低改造成本隐藏等待时间。图像处理、压缩、加密、数值计算、机器学习推理如果调用的是释放 GIL 的 C/C++/Fortran/Rust 库,也可能和 Python 线程并发推进。真正要判断的是“热点时间在 Python bytecode 内,还是在释放 GIL 的 native 区间”。

下面这组对比用于定位瓶颈来源:

观察现象主要执行位置GIL 影响常见处理方式
多线程跑纯 Python 循环,CPU 单核接近满载bytecode evaluatorPython bytecode 串行推进multiprocessingProcessPoolExecutor、算法下沉到释放 GIL 的 native 库
多线程等待网络、文件、sleep,CPU 使用率低OS 阻塞等待等待期间可释放 GILthreading、线程池、异步 I/O,按连接数和延迟调参
多线程调用 native 数值库,CPU 多核利用率上升native code取决于库是否释放 GIL查库文档、控制库内部线程数、测量 Python 层调度开销
多线程卡在某个扩展调用,其他 Python 线程也停顿持有 GIL 的 C 扩展扩展长时间占有 GIL修改扩展释放 GIL,或把调用放入进程隔离

这张表的用途是把“线程慢”拆成可验证的执行位置。看到线程数增加后吞吐保持不变,应先定位执行位置,再判断 GIL 是否处在主路径上。先看 CPU 是否有多核利用,再看热点栈是否停在 Python bytecode,再看是否存在用户锁、队列阻塞、数据库连接池限制、远端限流和扩展内部线程池争用。

free-threaded 构建改变了 CPU-bound 纯 Python 的上限,但引入新的成本。CPython 3.14 free-threading 文档列出单线程性能额外开销、对象 immortalization、frame 跨线程访问限制、iterator 并发限制和内存使用增加等行为变化。也就是说,关闭 GIL 的收益依赖 workload:独立数据上的并行计算更容易受益,共享对象频繁读写会转向对象锁、引用计数和内存回收的竞争。

45.4 Atomic illusion and extension boundary

GIL 会制造一种“很多操作看起来原子”的现象。默认 CPython 中,同一时刻只有一个线程执行 Python bytecode,许多内置容器操作在 C 层也会在持有 GIL 时完成。因此,list.appenddict.__setitem__ 这类单次容器内部修改通常能保持容器内部结构一致。这个事实保护的是 CPython 对象内部不变量,例如长度字段、元素数组、哈希表状态和引用计数关系。

业务原子性是另一层判断。counter += 1 需要读出旧对象、计算新对象、把名字重新绑定到新对象。线程 A 读出旧值后,线程 B 也可能读出同一个旧值;两个线程分别计算并写回后,最终值可能少一次更新。即使某个版本、某台机器、某次运行没有暴露这个结果,代码仍然需要补上“读-改-写”整体互斥的语义。正确判断是:共享可变状态跨越多个 Python 动作时,使用显式锁或把状态归属给单一线程。

下面的改写把业务不变量交给 threading.Lock

import threading

counter = 0
counter_lock = threading.Lock()


def add_one() -> None:
global counter
with counter_lock:
counter += 1

这里的锁保护的是“读取旧值、计算新值、写回名字”这一组业务动作。GIL 仍然保护 CPython 内部对象访问,但代码的正确性证据来自 counter_lock 的临界区。换成 dict 的“先查再写”、缓存的“未命中后创建”、列表的“检查长度再弹出”,同样需要把多个动作放进同一临界区,或者改成 queue.Queue、不可变快照、消息传递和单所有者模型。

C 扩展边界是 GIL 诊断中最容易漏掉的一层。扩展代码持有 GIL 时,可以安全访问 Python 对象和 Python C API;扩展代码释放 GIL 后,可以执行不触达 Python 对象的阻塞 I/O 或长时间 native 计算。官方 C API 文档给出常见宏 Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS,它们展开后会通过 PyEval_SaveThread() 分离当前 thread state 并释放 GIL,再通过 PyEval_RestoreThread() 恢复 thread state 并等待重新取得 GIL。

下面是简化的 C 扩展形态,用于表达边界,省略错误处理和模块定义细节:

static PyObject *do_blocking_io(PyObject *self, PyObject *args) {
Py_BEGIN_ALLOW_THREADS
run_blocking_system_call();
Py_END_ALLOW_THREADS

Py_RETURN_NONE;
}

run_blocking_system_call() 处于释放 GIL 的区间,所以它必须只处理脱离 Python 对象图的 C 层数据和系统资源,Python C API 调用要放回恢复 GIL 之后。扩展进入这个区间前应把需要的 C 层参数复制好,必要时持有自己的引用;回到 Python 对象世界后,再重新取得 GIL 并处理返回值、异常和引用计数。这个规则把扩展性能和内存安全连在一起:释放 GIL 可以让其他 Python 线程推进,但释放期间必须离开 Python runtime 对象图。

free-threaded 构建下,扩展边界更严格。PEP 703 设计了扩展模块声明机制,未声明可在 no-GIL 环境安全运行的扩展可能触发运行时重新启用 GIL 或警告。工程上要把“能在默认 GIL 构建中工作”和“能在 free-threaded 构建中正确并发”分开验收。后者需要确认对象访问、全局 C 状态、引用计数、缓存、回调和第三方库线程安全,并以显式同步或对象所有权替代默认构建的全局串行化假设。

45.5 GIL diagnosis checklist

GIL 问题的诊断目标是把一个模糊症状拆成执行位置、等待对象和修复边界。可复用顺序如下:先确认解释器和构建模式,再分类 workload,再看线程状态和栈,再区分 GIL 等待、用户锁等待、I/O 等待和扩展持锁,最后选择线程、进程、async、native 释放 GIL 或 free-threaded 构建中的一种方案。

第一步是确认运行环境。platform.python_implementation() 可以区分 CPython、PyPy 等实现;sys.version 和构建信息可以帮助确认 Python 版本;Python 3.13+ 还要确认是否使用 free-threaded 构建。GIL 是 CPython 默认构建的实现细节,把 PyPy、Jython、IronPython 或 no-GIL 构建混进同一结论,会让后续判断失真。

第二步是给线程里的热点分类。热点如果是 Python 函数里的循环、对象创建、字典访问、属性查找、正则回调和 Python 层序列处理,优先按 Python bytecode 竞争 GIL 处理。热点如果是 socket.recv、文件读写、time.sleep、数据库驱动等待,优先按 I/O 等待处理。热点如果进入 numpy、压缩库、哈希库、图像库或自研 C 扩展,必须查文档、源码或 profile 栈,确认它在重计算或阻塞阶段是否释放 GIL。

第三步是观察线程现场。纯 Python 侧可以用 threading.enumerate()sys._current_frames() 取得每个线程当前 frame,用 faulthandler.dump_traceback_later() 在卡顿时导出栈。下面的片段只用于观察 Python 栈,不说明 native 栈或 GIL 持有者:

import sys
import threading

frames = sys._current_frames()

for thread in threading.enumerate():
frame = frames.get(thread.ident)
if frame is None:
continue
code = frame.f_code
print(thread.name, code.co_filename, code.co_name, frame.f_lineno)

这段代码的作用是定位线程停在什么 Python 函数和行号。如果多个线程都停在 queue.getlock.acquire 或连接池等待,GIL 可能只是背景条件;如果一个线程长时间停在纯 Python CPU 循环,其他线程也频繁等待执行机会,GIL 才更可能成为主要瓶颈。需要 native 栈时,再结合系统 profiler、py-spyperf、Instruments 或扩展库自身日志。

第四步是检查共享状态的保护方式。贯穿程序中的 counter += 1 应被归类为业务竞态风险,即使默认 CPython 的 GIL 让对象内部结构保持一致。出现偶发错计数、重复初始化、缓存覆盖和顺序错乱时,先查是否存在跨多个动作的不变量,再查是否由 LockRLockConditionQueue 或单线程归属保护。GIL 诊断和数据竞争诊断要分开完成。

第五步是选择改造路径。纯 Python CPU-bound 任务通常迁移到 multiprocessingProcessPoolExecutor、Cython/Rust/C 扩展、向量化库或释放 GIL 的 native 实现。I/O-bound 任务可以继续使用线程池,也可以按连接规模、取消语义和背压需求迁移到 asyncio。扩展持有 GIL 导致卡顿时,改造点在扩展边界:把不触达 Python 对象的长操作包进释放 GIL 区间,或者把不安全扩展放到独立进程。free-threaded 构建可作为并行路线评估,但需要重新评估依赖库兼容性、对象共享方式、内存占用和单线程开销。

把这五步套回贯穿程序,结论会很稳定:两个 cpu_worker 线程在默认 CPython 中竞争同一个 interpreter 的 GIL,纯 Python 循环保持单解释器串行推进;两个 io_worker 线程在 sleep 阶段可以让出 GIL,所以等待时间能够重叠;records.append 通常保持列表内部结构一致,但如果业务要求顺序或去重,仍然需要额外约束;counter += 1 需要显式锁才能作为可靠计数逻辑。

最小自检任务

阅读下面代码,无需运行。请判断它在默认 CPython GIL 构建中的并发行为,并指出哪里需要显式锁,哪里主要受 GIL 限制,哪里可以和其他线程重叠等待。

import threading
import time

hits = 0
cache = {}

def compute(key: str) -> None:
global hits
for _ in range(100_000):
hits += 1

if key not in cache:
time.sleep(0.1)
cache[key] = object()

threads = [threading.Thread(target=compute, args=("same",)) for _ in range(2)]

for thread in threads:
thread.start()

for thread in threads:
thread.join()

print(hits, len(cache))

答案要点

hits += 1 属于纯 Python 读-改-写路径。两个线程会在默认 CPython 中轮流执行 bytecode,保持单解释器串行推进;同时,这组动作跨越多个 runtime 步骤,业务层面需要 threading.Lock 才能保证计数语义。

time.sleep(0.1) 属于阻塞等待。CPython 可以在这类等待期间释放 GIL,所以两个线程的 sleep 时间可以重叠,其他准备好的 Python 线程也可以取得解释器执行权。

if key not in cache: cache[key] = object() 是 check-then-act 业务不变量。GIL 保护 dict 内部结构,业务上的“只初始化一次”需要额外临界区。两个线程都可能在 sleep 前观察到 key 缺失,然后都执行创建和写入。结果中的 len(cache) 可能仍然是 1,因为相同 key 后写覆盖先写;这只能说明最终映射里保留了一个 key,创建次数仍由临界区设计决定。

正确改造方向是把 hits += 1 的计数临界区和 cache 的检查创建临界区分开保护,或使用队列、单线程所有者、future 缓存等设计把共享状态归属固定下来。若目标是提升纯 Python CPU 循环吞吐,线程锁只能保证正确性,性能路线应转向进程、native 实现、释放 GIL 的库或经过验证的 free-threaded 构建。

本章知识点总结

  • GIL 边界:默认 CPython 中,线程访问 Python 对象和 Python C API 前需要持有 GIL,并通过 attached PyThreadState 进入解释器。
  • 状态分层:OS thread 负责系统调度,PyThreadState 记录线程的 Python 执行现场,interpreter state 保存解释器共享运行环境。
  • 执行串行:默认 GIL 构建中,同一 interpreter 的纯 Python bytecode 在解释器层面轮流推进,多个 CPU 核心无法直接并行执行这些 bytecode。
  • 切换机制:线程取得 GIL 后连续执行一段 bytecode,eval breaker 在安全点触发慢路径处理并完成线程切换、信号和 pending call 等维护动作。
  • 切换间隔sys.setswitchinterval() 影响线程交替响应性和切换开销,不改变纯 Python bytecode 的并行上限。
  • I/O 行为:阻塞 I/O、sleep 和部分 native 库可以在等待或长计算区间释放 GIL,使其他 Python 线程继续推进。
  • CPU 瓶颈:纯 Python CPU-bound 线程增加数量后通常竞争同一个 GIL,适合用进程、释放 GIL 的 native 库或 free-threaded 构建评估并行路线。
  • 原子错觉:GIL 保护 CPython 对象内部不变量,不自动保护跨多个 Python 动作的业务不变量。
  • 显式锁职责counter += 1、check-then-act 缓存、复合容器操作和顺序约束需要 threading.Lock 或更明确的所有权设计。
  • 扩展释放:C 扩展可用 Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS 在不访问 Python 对象的区间释放 GIL,并在回到 Python C API 前恢复 thread state。
  • 诊断顺序:GIL 诊断先确认解释器和构建模式,再分类 CPU、I/O、native 和用户锁等待,最后选择线程、进程、async、扩展释放或 free-threaded 构建。
  • 版本边界:Python 3.13+ 的 free-threaded 构建改变 GIL 结论,但会引入对象锁、引用计数、GC 暂停、扩展兼容性和内存开销等新的判断点。