本节目标:从 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.5 | C 代码显式 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 buildsys.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 |
小结
- GIL 保护的是解释器内部共享状态(引用计数、内存分配器、GC、容器结构),而不是你的某条语句。
- 强制释放由 **eval breaker 在安全点(
CALL、JUMP_BACKWARD)**触发,间隔由sys.setswitchinterval()控制;实测监控线程最长饥饿时间随间隔线性增长。 - 实测影响边界:本机 10 核下,纯 Python CPU 密集 4 线程加速比 1.0x;阻塞 I/O 可达 3.2x~7.8x。
- GIL 只保证字节码级原子性:裸
counter += 1零丢失,但表达式里插入一次函数调用后实测丢失 22%~65%。 PYTHON_GIL/-X gil在标准构建上是硬错误;判断构建类型要看sysconfig.get_config_var("Py_GIL_DISABLED")。
本节界定了 GIL 的能力边界,下一节 自由线程构建与迁移影响 会拆开 PEP 703 的对象头与偏向引用计数,看 CPython 打算怎么把这把锁拆掉。
阅读导航:上一节:内存泄漏定位 · 下一节:自由线程构建与迁移影响 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。