上一篇验证了 GIL 让纯计算多线程完全没有加速——很容易顺着这个结论推出"既然同一时刻只有一个线程在执行,那共享变量肯定不会出问题"。这一篇专门戳破这个推论:GIL 保证的是"字节码指令级别不会被打断",不是"一段 Python 语句、更不是一整个业务逻辑不会被打断"。本机真实实验里,先是怎么都没能把一个经典的计数器竞争条件测出来(一个意外的真实结果),查源码搞清楚原因之后,换一种真实会触发切换的写法,稳定复现了丢更新,最后用真锁修复。
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 万次试了一遍。
一次没丢可能是运气。把 sys.setswitchinterval() 调到 1 微秒(默认是 5000 微秒,相当于把切换频率提高了 5000 倍),线程数加到 32、总操作数加到 1600 万,再试:
三轮,一次都没丢。这和官方文档"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_GLOBAL、BINARY_OP、STORE_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(写回)。四条里没有一条是 CALL 或 JUMP_BACKWARD——对照上面 _CHECK_PERIODIC 只嵌在这两种指令里的事实,这条语句从头到尾就不存在真正的切换检查点。也就是说:只有在循环跳回起点(JUMP_BACKWARD)或者发生函数调用(CALL)的时候,解释器才会真的检查该不该让出 GIL。纵使别的线程已经"申请"了切换,当前线程也会一口气把这四条指令执行完才可能被打断。这就是为什么无论怎么调 switchinterval、开多少线程,单纯的 i = i+1 在这台机器这个版本上都测不出丢失。
time.sleep() 这类阻塞调用不受 interval 计时器控制,进去就无条件释放 GIL——在读和写之间插一个真实的 time.sleep(0),制造一个必然存在的切换窗口。
2400 次递增,丢了 2034 次——绝大多数更新都因为多个线程用同一个"读到的旧值"覆盖式写回而消失了。这才是官方 FAQ 那句"i = i+1 不是原子的"真正想说明的风险:一旦复合操作中间存在任何真实的让出点(阻塞 I/O、sleep、甚至纯粹运气不好被中断在循环跳转处),竞争就会真实发生。
一字不改前面的竞态逻辑,只是把"读、等待、写"三步包进 with lock:。
完全修复。锁保证的不是"字节码不被打断"(GIL 已经在做类似的事,但粒度是指令级、不是语句级),而是"临界区(读+算+写)作为一个整体,不允许被第二个线程插入"——这正是 GIL 本身不提供、也从来没打算提供的保证。
两个线程各做一次"读、算、写",初始值 5。第一遍不加锁,按最坏情况交替执行;第二遍加锁,重放同样的交替请求。
数据来自独立验证过的 Python 模拟(读/算/写三个微步骤,和真实字节码序列对应),两种场景的最终结果、每一步"谁读到了什么值",都用一套独立的暴力回放算法核对过;加锁场景还额外校验过"任意时刻临界区内不会同时出现两个线程"。