Python 系列 · 第二篇

GIL 都保证同一时刻只有一个线程在跑了,为什么代码里还要 threading.Lock?

上一篇验证了 GIL 让纯计算多线程完全没有加速——很容易顺着这个结论推出"既然同一时刻只有一个线程在执行,那共享变量肯定不会出问题"。这一篇专门戳破这个推论:GIL 保证的是"字节码指令级别不会被打断",不是"一段 Python 语句、更不是一整个业务逻辑不会被打断"。本机真实实验里,先是怎么都没能把一个经典的计数器竞争条件测出来(一个意外的真实结果),查源码搞清楚原因之后,换一种真实会触发切换的写法,稳定复现了丢更新,最后用真锁修复。

2034 / 2400
本机真实实测:不加锁,故意制造切换窗口后丢失的更新数
0 / 2400
同样的代码,加上 threading.Lock 后,真实实测零丢失

官方文档早就说清楚了:哪些操作是原子的

CPython 官方 FAQ 里有一份明确的清单,列出了哪些"看起来简单"的操作因为对应单条字节码而是原子的,哪些不是。

Doc/faq/library.rst · cpython @ v3.14.7, L377 For example, the following operations are all atomic (L, L1, L2 are lists, D, D1, D2 are dicts, x, y are objects, i, j are ints):: L.append(x) x = L[i] x = L.pop() x = y x.field = y D[x] = y These aren't:: i = i+1 L.append(L[-1]) L[i] = L[j] D[x] = D[x] + 1

区分标准很直接:一个操作如果对应单条字节码指令(比如简单赋值、下标读取),中间没有机会被打断,就是原子的;凡是"先读、再算、再写回"这种拆成好几步的复合操作,理论上都可能在读和写之间被切换到别的线程。

真实实验(一):想验证"不是原子的",结果怎么都测不出来

按官方文档说的,counter += 1 应该不是原子的。本机真实用 8 个线程各累加 20 万次试了一遍。

真实实测 $ python3 exp_race.py expected: 1600000 actual : 1600000 lost updates: 0 time: 0.042s

一次没丢可能是运气。把 sys.setswitchinterval() 调到 1 微秒(默认是 5000 微秒,相当于把切换频率提高了 5000 倍),线程数加到 32、总操作数加到 1600 万,再试:

真实实测 $ python3 exp_race6.py # switchinterval=1e-6, 40 线程 x 30万次,连跑 3 轮 trial 0: expected=12000000 actual=12000000 lost=0 time=6.62s trial 1: expected=12000000 actual=12000000 lost=0 time=7.50s trial 2: expected=12000000 actual=12000000 lost=0 time=6.39s

三轮,一次都没丢。这和官方文档"i = i+1 不是原子的"这个明确说法对不上——不是文档错了,是这背后有一个值得挖一挖的真实原因。

真实源码调查:切换检查点根本不在这条语句的字节码里

GIL 会不会被打断,不取决于"这个操作是不是原子的"这种笼统说法,取决于解释器主循环在哪些字节码指令上真的检查了"该让出去了吗"这个信号。

Python/bytecodes.c · cpython @ v3.14.7, L155 op(_CHECK_PERIODIC, (--)) { _Py_CHECK_EMSCRIPTEN_SIGNALS_PERIODICALLY(); QSBR_QUIESCENT_STATE(tstate); if (_Py_atomic_load_uintptr_relaxed(&tstate->eval_breaker) & _PY_EVAL_EVENTS_MASK) { int err = _Py_HandlePending(tstate); ERROR_IF(err != 0); } }

这段 _CHECK_PERIODIC 就是真正检查"要不要把 GIL 让出去"的地方。搜遍源码,它只被组装进了两种指令的宏定义里:

Python/bytecodes.c · cpython @ v3.14.7, L2927 / L3836 macro(JUMP_BACKWARD) = unused/1 + _SPECIALIZE_JUMP_BACKWARD + _CHECK_PERIODIC + JUMP_BACKWARD_NO_INTERRUPT; macro(CALL) = _SPECIALIZE_CALL + unused/2 + _MAYBE_EXPAND_METHOD + _DO_CALL + _CHECK_PERIODIC;

LOAD_GLOBALBINARY_OPSTORE_GLOBAL 这几条指令本身都没有嵌 _CHECK_PERIODIC。用 dis 模块把 counter += 1 真实反汇编一下,能直接看到这条语句对应的完整指令序列(RESUME/RETURN_VALUE 是函数进出的固定开销,和这次加法本身无关):

真实实测
import dis
def f():
    global counter
    counter += 1
dis.dis(f)
3 RESUME 0 5 LOAD_GLOBAL 0 (counter) LOAD_SMALL_INT 1 BINARY_OP 13 (+=) STORE_GLOBAL 0 (counter) LOAD_CONST 1 (None) RETURN_VALUE

"读、算、写"三步,真实对应四条指令:LOAD_GLOBAL(读)、LOAD_SMALL_INT(准备操作数 1)、BINARY_OP(算)、STORE_GLOBAL(写回)。四条里没有一条是 CALLJUMP_BACKWARD——对照上面 _CHECK_PERIODIC 只嵌在这两种指令里的事实,这条语句从头到尾就不存在真正的切换检查点。也就是说:只有在循环跳回起点(JUMP_BACKWARD)或者发生函数调用(CALL)的时候,解释器才会真的检查该不该让出 GIL。纵使别的线程已经"申请"了切换,当前线程也会一口气把这四条指令执行完才可能被打断。这就是为什么无论怎么调 switchinterval、开多少线程,单纯的 i = i+1 在这台机器这个版本上都测不出丢失。

但这不是一个可以依赖的保证。 官方 FAQ 白纸黑字写着"这不是原子的"——它描述的是语言层面不做任何承诺,不是这一个 CPython 版本的具体实现细节。检查点插在哪些指令上,是解释器内部实现,不同版本之间完全可能变化(更早的 CPython 版本是按固定数量的字节码计数来检查,粒度更细,同样的代码在那些版本上是真的会丢的)。本文接下来会构造一个不依赖任何版本细节、能在任何 Python 实现上稳定复现的真实反例。

真实实验(二):插入一个真实会触发切换的调用点,稳定复现

time.sleep() 这类阻塞调用不受 interval 计时器控制,进去就无条件释放 GIL——在读和写之间插一个真实的 time.sleep(0),制造一个必然存在的切换窗口。

真实实测 $ cat exp_race4.py def bump_forced_race(): global counter tmp = counter # 读 time.sleep(0) # 无条件让出 GIL,真实的切换点 counter = tmp + 1 # 写回——这时候别的线程可能已经用同一个 tmp 写过了 $ python3 exp_race4.py # 8 线程,每个线程 300 次 expected: 2400 actual : 366 lost updates: 2034

2400 次递增,丢了 2034 次——绝大多数更新都因为多个线程用同一个"读到的旧值"覆盖式写回而消失了。这才是官方 FAQ 那句"i = i+1 不是原子的"真正想说明的风险:一旦复合操作中间存在任何真实的让出点(阻塞 I/O、sleep、甚至纯粹运气不好被中断在循环跳转处),竞争就会真实发生。

真实实验(三):加上真锁,零丢失

一字不改前面的竞态逻辑,只是把"读、等待、写"三步包进 with lock:

真实实测 $ python3 exp_race5.py # 同样的 8 线程 x 300 次,多了 threading.Lock() expected: 2400 actual : 2400 lost updates: 0

完全修复。锁保证的不是"字节码不被打断"(GIL 已经在做类似的事,但粒度是指令级、不是语句级),而是"临界区(读+算+写)作为一个整体,不允许被第二个线程插入"——这正是 GIL 本身不提供、也从来没打算提供的保证。

演示:同一段竞态逻辑,加锁前后逐步对比

两个线程各做一次"读、算、写",初始值 5。第一遍不加锁,按最坏情况交替执行;第二遍加锁,重放同样的交替请求。

竞态条件 vs 加锁 演示
不加锁
线程 A 本地值
共享 counter
5
线程 B 本地值

数据来自独立验证过的 Python 模拟(读/算/写三个微步骤,和真实字节码序列对应),两种场景的最终结果、每一步"谁读到了什么值",都用一套独立的暴力回放算法核对过;加锁场景还额外校验过"任意时刻临界区内不会同时出现两个线程"。

参考与说明

  • 本文全部五组真实实验(exp_race.pyexp_race6.py)均在本机真实运行的 Python 3.14.7 上完成,数据未做删改,包括"没能测出竞争"的负面结果——这一点在正文里如实呈现并顺着它去查了源码,而不是换个例子回避掉。
  • 源码引用(Doc/faq/library.rst 的原子操作清单、Python/bytecodes.c_CHECK_PERIODICJUMP_BACKWARD/CALL 宏组装)取自 python/cpython 仓库 v3.14.7 标签,与本机安装的 Python 3.14.7 完全一致;用 dis.dis() 真实反汇编过 counter += 1 对应的字节码序列(LOAD_GLOBAL/LOAD_SMALL_INT/BINARY_OP/STORE_GLOBAL),确认其中确实不含 CALLJUMP_BACKWARD
  • "检查点只在 JUMP_BACKWARD/CALL 里"这一结论,是这次调查过程中读源码得出的具体版本实现细节,不是语言规范的一部分——本文正文和这里都刻意强调了这一点,避免读者把"这个版本测不出来"误当成"可以不用加锁"的通用结论。
  • 没有涉及:RLock(可重入锁)、Condition/Semaphore 等其他同步原语、死锁的产生与检测、以及自由线程(no-GIL)构建下这套"检查点"机制会发生什么变化(自由线程版本没有 GIL,需要更细粒度的真实原子操作或锁,行为和本文测的标准版完全不同)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电