《Python高级编程》5.1 GIL 的实现与影响边界

从 CPython 源码机制出发拆解 GIL:解释器循环为何必须持有全局锁、切换间隔怎样在安全点强制释放线程,并用本机 10 核实测数据画出 CPU 密集与 I/O 密集的影响边界,顺带用实测纠正「counter += 1 会丢更新」这一流传很广的误解。

本节目标:从 CPython 的实现机制出发讲清 GIL 到底锁住了什么、在什么时机被强制释放,并用本机实测数据画出 CPU 密集与 I/O 密集的影响边界。
适用版本:Python 3.12+(实测 3.14.6)

5.1 GIL 的实现与影响边界

网上关于 GIL 的文章大多停在「它让多线程不能并行」这句结论上。这一节换个角度:先看它在 CPython 里具体实现成什么,再看它什么时候被释放,最后用本机跑出来的数字界定它的影响边界——包括一个和流行说法相反的实测结论。

5.1.1 GIL 锁的到底是什么

CPython 的字节码求值循环(_PyEval_EvalFrameDefault)在进入循环体、取出一条字节码准备执行之前,必须持有 GIL。所以严格说,GIL 保护的不是「你的语句」,而是解释器自身的共享状态:

受 GIL 保护的内部状态为什么需要它
对象引用计数(ob_refcnt)加减引用计数是普通读改写,多线程同时改会丢计数、提前析构
内存分配器 pymalloc / PyMem_Malloc分配器内部维护 free list,非线程安全
循环 GC 的标记阶段分代回收扫描对象图时要求引用关系稳定
dict / list 等容器内部结构结构变化(扩容、resize)期间必须串行

换句话说,GIL 是用一把大锁换掉无数把细粒度锁的历史选择:1990 年代单核是主流,细粒度锁的正确性与单线程性能代价都不划算。理解这一点,后面所有现象都顺理成章。

5.1.2 强制释放:切换间隔与 eval breaker

如果某个线程一直持有 GIL 不放,其他线程就永远饿死。CPython 的解法是周期性强制释放。求值循环并不在每条字节码后检查,而是在**若干特定的「安全点」**检查一个 eval breaker 标志位——最典型的是函数调用(CALL)和循环回跳(JUMP_BACKWARD)。如果距上次释放已超过 sys.getswitchinterval(),当前线程就放下 GIL,让调度器换人。

import sys

# 默认切换间隔,单位秒
print(sys.getswitchinterval())          # 3.14.6 实测:0.005
sys.setswitchinterval(0.02)             # 改成 20ms
print(sys.getswitchinterval())          # 0.02
sys.setswitchinterval(0.005)            # 恢复默认

这个间隔直接决定了「一个纯计算线程最多能霸占 GIL 多久」。实测方法是:一个 CPU 死循环线程霸占 GIL,另一个监控线程反复记录自己两次被调度之间的时间差,取最大值。

import sys, time, threading

def measure(interval, dur=0.5):
    sys.setswitchinterval(interval)
    stop = False
    def hog():
        while not stop:
            pass
    gaps = []
    def monitor():
        last = time.perf_counter()
        while not stop:
            now = time.perf_counter()
            gaps.append(now - last)
            last = now
    h = threading.Thread(target=hog, daemon=True)
    m = threading.Thread(target=monitor, daemon=True)
    h.start(); m.start()
    time.sleep(dur)
    stop = True
    h.join(); m.join()
    return max(gaps)

for iv in (0.005, 0.05, 0.2):
    print(f"switchinterval={iv:<6} max_gap={measure(iv)*1000:.1f}ms")
sys.setswitchinterval(0.005)
switchinterval=0.005  max_gap=11.0ms
switchinterval=0.05   max_gap=55.1ms
switchinterval=0.2    max_gap=410.1ms

监控线程的「最长饥饿时间」几乎随切换间隔线性增长——这从侧面证明了切换是被 eval breaker 主动触发的,而不是抢占式的。把间隔调大,纯计算吞吐不变,但线程响应延迟显著变差。这就是为什么 setinterval 不是性能调优旋钮,而是延迟与吞吐的取舍旋钮。

5.1.3 影响边界:CPU 密集不提速,I/O 密集照常提速

先看 CPU 密集。本机是 10 核(os.cpu_count() 与 os.process_cpu_count() 均返回 10)。让每个线程都做满 N=8_000_000 次整数运算:

import time, threading

def cpu_bound(n):
    s = 0
    for i in range(n):
        s += i * i
    return s

N = 8_000_000
t0 = time.perf_counter(); cpu_bound(N); print("1 thread:", time.perf_counter() - t0)

for k in (2, 4):
    ts = [threading.Thread(target=cpu_bound, args=(N,)) for _ in range(k)]
    t0 = time.perf_counter()
    for t in ts: t.start()
    for t in ts: t.join()
    print(f"{k} threads:", time.perf_counter() - t0)
1 thread (N=8_000_000): 0.351s
2 threads x N:          0.686s
4 threads x N:          1.369s
serial 4xN:             1.400s

4 个线程跑 4 份工作 = 1.369s,串行跑 4 份 = 1.400s,两者在误差内相等。也就是说 10 核机器上,CPU 密集多线程的加速比是 1.0x——GIL 把多核彻底压成了单核。

再看 I/O 密集。线程在 time.sleep() 和阻塞 socket 调用里会主动释放 GIL:

import time, threading

def io_sleep(_):
    time.sleep(0.2)

t0 = time.perf_counter()
for _ in range(8): io_sleep(0)
print("serial:", time.perf_counter() - t0)     # 约 1.6s

ts = [threading.Thread(target=io_sleep, args=(i,)) for i in range(8)]
t0 = time.perf_counter()
for t in ts: t.start()
for t in ts: t.join()
print("threaded:", time.perf_counter() - t0)    # 约 0.2s
sleep serial x8:   1.628s
sleep threaded x8: 0.208s   speedup=7.8x
socket serial x50: 0.124s  threaded x50: 0.038s  speedup=3.2x

sleep 场景加速 7.8x(接近 8 个线程的理论上限),本机回环 socket 的请求-响应 50 次加速 3.2x(受 syscall 与调度开销限制)。结论很清晰:

负载类型GIL 是否限制并行实测加速比(本机 10 核)机制
纯 Python CPU 密集是1.0x(4 线程)eval breaker 串行持有 GIL
阻塞 I/O(sleep / socket)否3.2x ~ 7.8x进入 syscall 时释放 GIL
C 扩展释放 GIL 的计算视操作而定见 5.1.5C 代码显式 Py_BEGIN_ALLOW_THREADS

真实程序往往是混合负载。让 4 个 CPU 线程和 4 个 I/O(sleep)线程同时跑:

import threading, time
def cpu_bound(n):
    s = 0
    for i in range(n): s += i * i
def io_sleep(_): time.sleep(0.2)

N = 8_000_000
cpu = [threading.Thread(target=cpu_bound, args=(N,)) for _ in range(4)]
io  = [threading.Thread(target=io_sleep, args=(i,)) for i in range(4)]
t0 = time.perf_counter()
for t in cpu + io: t.start()
for t in io: t.join()
print("IO 全部完成:", time.perf_counter() - t0)
for t in cpu: t.join()
print("全部完成:", time.perf_counter() - t0)
4 CPU + 4 IO 并发: IO 全部完成=0.479s  全部完成=1.450s

两个观察:I/O 线程仍能在 0.479s 内全部完成(单独跑约 0.21s,多出的延迟来自它们要等 CPU 线程在安全点让出 GIL);而总时间 1.450s 基本等于 4 个 CPU 线程串行的 1.37s——I/O 线程的插入几乎没有拖慢 CPU 侧,但也拿不到额外加速。这说明 GIL 下的多线程对「CPU + I/O 混合」的利用是部分有效的:I/O 等待被隐藏了,CPU 计算没有被并行。

5.1.4 GIL 保证字节码原子,但不保证语句原子

一个流传很广的说法是「counter += 1 会丢更新,因为 GIL 会在中间切换」。本机实测这个说法对全局变量的裸 += 并不成立:

import threading

counter = 0
def bump():
    global counter
    for _ in range(1_000_000):
        counter += 1

ts = [threading.Thread(target=bump) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
print("expected=4000000 got=", counter)
expected=4000000 got=4000000        # 5 次实测全部等于 4_000_000,零丢失

原因在 5.1.2 已经埋下:切换只发生在 CALL 与 JUMP_BACKWARD 这两个安全点。counter += 1 编译出来是一段连续的、不含安全点的字节码:

LOAD_GLOBAL  0 (counter)
LOAD_SMALL_INT  1
BINARY_OP    13 (+=)
STORE_GLOBAL 0 (counter)
JUMP_BACKWARD 18 (to L1)      ← 唯一的安全点在这里,STORE 之后

读-改-写三步之间没有安全点,所以 GIL 的强制释放恰好保证了这个序列的原子性。但只要在表达式里插入一次函数调用,序列中间就出现了 CALL 安全点,丢更新立刻出现:

counter = 0
def helper(): return 1
def with_call():
    global counter
    for _ in range(1_000_000):
        counter = counter + helper()      # CALL 落在读与写之间

# 4 线程 × 1_000_000,期望 4_000_000,实测 5 次:
# [2183588, 1617517, 1866300, 3139522, 1388024]   → 丢失 22%~65%

所以准确的表述是:GIL 提供的是「单条字节码级」的原子性,而不是「语句级」的原子性。+= 恰好因为没跨安全点而看起来安全,换个写法就不安全了——这比「+= 一定会丢」更值得记住,因为它解释了边界在哪里。

5.1.5 与运行环境相关的开关

import sys, sysconfig, os

sys._is_gil_enabled()                       # True(标准构建)
sysconfig.get_config_var("Py_GIL_DISABLED") # 0(0 表示非自由线程构建)
os.process_cpu_count()                      # 10:本进程可用 CPU 数(3.13+,受亲和性限制)
  • PYTHON_GIL 环境变量与 -X gil=0/1 命令行开关只在自由线程构建上生效。在标准构建上它们会直接报错退出:

    $ PYTHON_GIL=0 python -c "print('x')"
    Fatal Python error: config_read_gil: Disabling the GIL is not supported by this build
    
  • sys.getswitchinterval() 返回当前切换间隔(默认 0.005 秒),是观测 GIL 行为最直接的接口。

  • 3.14 的字节码里出现了 LOAD_FAST_BORROW、LOAD_SMALL_INT 等新指令,说明求值循环仍在演进——这也是为什么「按字节码猜 GIL 行为」必须以你实际使用的版本为准。

自由线程构建到底长什么样、能不能真正拿掉这把锁,是下一节 自由线程构建与迁移影响 的主题。

5.1.6 切换间隔是延迟旋钮,不是吞吐旋钮

把切换间隔从 5ms 调到 100ms,再跑同一批 CPU 密集线程:

import sys, threading, time
def cpu_bound(n):
    s = 0
    for i in range(n): s += i * i
def run(interval, k=4, N=4_000_000):
    sys.setswitchinterval(interval)
    ts = [threading.Thread(target=cpu_bound, args=(N,)) for _ in range(k)]
    t0 = time.perf_counter()
    for t in ts: t.start()
    for t in ts: t.join()
    return time.perf_counter() - t0
for iv in (0.005, 0.02, 0.1):
    print(f"switchinterval={iv:<6} 4-thread cpu wall={run(iv):.3f}s")
sys.setswitchinterval(0.005)
switchinterval=0.005  4-thread cpu wall=0.711s
switchinterval=0.02   4-thread cpu wall=0.699s
switchinterval=0.1    4-thread cpu wall=0.704s

吞吐完全不变(0.699s~0.711s)。因为 GIL 是独占锁,切换间隔只影响「多久换一次人」,不影响「总共能执行多少字节码」。它真正的效果体现在 5.1.2 的响应延迟上——间隔越大,其他线程越容易被饿住。所以要调它,目标永远是降低尾延迟,而不是提高吞吐。

GIL 行为可观测的接口汇总如下:

接口作用本机实测值
sys.getswitchinterval()当前强制释放间隔(秒)0.005
sys.setswitchinterval(s)设置强制释放间隔立即生效
sys._is_gil_enabled()运行进程里 GIL 是否启用True
sysconfig.get_config_var("Py_GIL_DISABLED")构建是否支持自由线程0
os.cpu_count()系统逻辑 CPU 数10
os.process_cpu_count()本进程可用 CPU 数(3.13+,受亲和性限制)10

小结

  1. GIL 保护的是解释器内部共享状态(引用计数、内存分配器、GC、容器结构),而不是你的某条语句。
  2. 强制释放由 **eval breaker 在安全点(CALL、JUMP_BACKWARD)**触发,间隔由 sys.setswitchinterval() 控制;实测监控线程最长饥饿时间随间隔线性增长。
  3. 实测影响边界:本机 10 核下,纯 Python CPU 密集 4 线程加速比 1.0x;阻塞 I/O 可达 3.2x~7.8x。
  4. GIL 只保证字节码级原子性:裸 counter += 1 零丢失,但表达式里插入一次函数调用后实测丢失 22%~65%。
  5. PYTHON_GIL / -X gil 在标准构建上是硬错误;判断构建类型要看 sysconfig.get_config_var("Py_GIL_DISABLED")。

本节界定了 GIL 的能力边界,下一节 自由线程构建与迁移影响 会拆开 PEP 703 的对象头与偏向引用计数,看 CPython 打算怎么把这把锁拆掉。

阅读导航:上一节:内存泄漏定位 · 下一节:自由线程构建与迁移影响 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时