有了 GIL 还要加锁:Python 共享变量为什么仍会被改坏

GIL 是 CPython 解释器里的一把全局互斥锁,它保证同一时刻只有一条线程执行字节码,保护的是引用计数、对象分配和 GC 这些解释器内部状态。很多人由此推出两个相反的结论:要么以为 Python 多线程毫无用处,要么以为有 GIL 就不用自己加锁,两者都不对。阻塞 I/O 时锁会释放,线程照样能重叠等待;而读-改-写、先检查后使用这类跨多条字节码的操作,GIL 一概不管。本文用一段必然复现的取款代码说明共享不变量如何被破坏,并讲清切换时机、CPU 与 I/O 两类任务的选型,以及 free-threading 构建的真实代价。

是什么

GIL(Global Interpreter Lock,全局解释器锁)是 CPython 解释器内部的一把互斥锁:任何时刻,一个进程里只有一条线程能执行 Python 字节码。它保护的不是你的业务变量,而是解释器自己的共享状态——对象的引用计数、内存分配器、循环垃圾回收器维护的链表。直觉上它像一座只有一部电梯的写字楼:电梯一次只载一个人,所以不会超载;但两个人各自拿着合同去改同一条款,电梯管不了。典型场景是 Web 服务等数据库、等下游接口,或者本地跑一个 CPU 密集的批处理:前者线程大多在等,后者线程都在算,GIL 对这两类任务的意义完全相反。把 GIL 和“线程安全”“多核并行”混为一谈,是后面所有误区的起点:它只回答“解释器状态会不会被同时改坏”,不回答“你的业务逻辑会不会被同时改坏”,也不回答“两条线程能不能同时算”。

核心原理

为什么需要一把这么大的锁。 CPython 用引用计数管理对象生命周期,引用计数的加减、对象分配、GC 链表插入都发生在解释器最热的路径上。如果给每个对象配一把锁,单线程也要反复加解锁,C 扩展作者还得逐个证明自己线程安全。用一把粗粒度的锁把这些操作串起来,实现简单、单线程快,代价是 Python 层线程无法同时执行字节码。这是工程取舍,不是语言规定:PyPy 也有 GIL,Jython、IronPython 则没有。而且它是隐式持有的:执行字节码时自动持有,进入阻塞调用时自动释放,Python 代码既不能“只锁一半”,也无法用它表达“这两个操作必须成对完成”。

锁什么时候让出去。 一是阻塞调用:time.sleep、socket 读写、文件 I/O 在进入内核等待前,CPython 会把 GIL 放掉,醒来再抢回来。二是时间片:sys.setswitchinterval() 默认 0.005 秒,解释器在函数调用、循环回跳等检查点判断要不要换人。注意“检查点”三个字:切换不会发生在一条字节码的中间,所以单条字节码对其他 Python 线程来说像是原子的;但一条语句往往编译成好几条字节码。

读-改-写没有原子性保证。 balance -= amount 在字节码层面是“取值、做减法、写回”几步。CPython 的切换点只落在函数调用、循环回跳这类位置,所以对普通整数的一次 -= 往往能整段跑完,看起来像原子的;但这只是检查点位置的实现细节,不是语言承诺——换成自定义 __isub__,或者把读和写拆到两次调用之间,窗口立刻出现。更常见的是“先检查后使用”:检查余额和扣款之间只要有一次 I/O,另一个线程就能挤进来。下面这段代码每次都会透支:

import threading, time

balance = 100
ready = threading.Barrier(2)

def withdraw(amount):
    global balance
    if balance >= amount:      # ① 检查
        ready.wait()           # ② 两个线程都通过检查
        time.sleep(0.001)      # ③ 模拟扣款前的 I/O,GIL 在这里释放
        balance -= amount      # ④ 扣款

ts = [threading.Thread(target=withdraw, args=(80,)) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print(balance)                 # -60:两笔各 80 的取款都成功了

两个线程都看到 100 ≥ 80,都通过了检查;sleep 让出 GIL,另一个线程也通过;最后各自扣 80,余额变成 -60。把 sleep 换成查数据库、读配置、发消息,形状完全一样。GIL 在这里没有失职——它保护的是解释器,不是“余额不能为负”这条业务不变量。

CPU 密集时,线程在排队。 纯计算线程不主动让锁,只能等时间片到点被换下;换来换去还要保存恢复现场,所以四线程的墙钟时间往往接近串行,甚至更长。一次四核环境的实测里,单线程 0.21 秒的计算量,四个线程各跑一份约 0.87 秒;换成四个 sleep(1) 的线程,总耗时约 1.0 秒,因为等待期间锁是放开的。这也解释了为什么“Python 多线程没用”和“Python 多线程很有用”都能找到证据——它们说的是不同任务。

为什么不能简单删掉。 引用计数要改成原子操作或延迟计数,内存分配器要线程安全,C 扩展的 ABI 要重新约定。3.12 起 PEP 684 允许子解释器各持一把 GIL,但那是 C API 层面的能力,threading 并不直接暴露;3.13 起提供实验性的 free-threading 构建(--disable-gil),它和常规构建是两个二进制,官方文档同时提醒可能有 bug、单线程性能会明显回退。常规构建仍然带 GIL,所以“新版本就没有 GIL 了”是误读。

常见考点与误区

  • 误区一:“有 GIL 就不用加锁。” GIL 保证的是解释器内部不被踩坏,不保证你的组合操作。+=、先查后写、先判断再插入,只要跨了多条字节码或中间有 I/O,就必须自己用 Lock,或者改成队列、单写者模型。
  • 误区二:“GIL 是 Python 的语言规范。” 它只是 CPython 的实现细节。换一个实现可能根本没有 GIL,代码若依赖“某条语句原子”就会翻车;面试里把 GIL 说成语言特性是典型扣分点。
  • 误区三:“多线程一定更快,或者一定没用。” I/O 密集用线程划算,CPU 密集用进程才并行;但如果计算发生在释放 GIL 的 C 扩展里(例如 NumPy 的部分运算),线程也能拿到多核。判断标准是任务在等还是在算。
  • 误区四:“把 switchinterval 调小,线程就更公平、更快。” 它只是建议解释器多久检查一次切换,调小会增加切换与抢锁开销,不会让两条线程同时执行字节码;调大则可能让 CPU 密集线程更霸道。
  • 考点五:“GIL 和普通互斥锁有什么区别?” 普通锁保护某个资源,谁持有谁访问;GIL 保护整个解释器状态,运行字节码时隐式持有,却不表达“这段业务逻辑要串行”。所以它替代不了 Lock,更替代不了数据库事务。
  • 考点六:“给每个对象配一把锁,替换 GIL 不就行了?” 听起来合理,实际要付三笔账:单线程快路径上每次访问都要加解锁;C 扩展必须逐个改写成线程安全;多把锁之间还会引入新的死锁与锁顺序问题。GIL 是拿并行换实现简单,这也是 free-threading 推进缓慢的原因。

实践建议

  • 先分类再选型。 用 perf_counter 和 CPU 利用率判断任务是在等还是在算:等网络、等磁盘的用 ThreadPoolExecutor 或 asyncio,算 CPU 的用 ProcessPoolExecutor 或 multiprocessing。不要因为“线程比进程轻”就直接下结论。
  • CPU 密集要算进程成本。 进程启动、参数与返回值的序列化、每个进程一份解释器和内存都要计入;传大数组用共享内存或分块,别把整个数据集反复 pickle。spawn 启动方式下记得写 if __name__ == "__main__":。
  • 共享状态要么加锁,要么别共享。 临界区只包住必要的读写,持锁时不要做网络或磁盘 I/O;能用一个 Queue 传结果,就不要让多个线程改同一个字典。锁用 with lock:,确保异常时也能释放。
  • 异步里不要塞阻塞调用。 asyncio 是单线程事件循环,一个同步数据库驱动或 requests.get 就能把整个循环卡住;换异步驱动,或用 run_in_executor 把阻塞调用挪到线程池。
  • 线程数按瓶颈定,不按核数拍脑袋。 I/O 密集的线程数取决于同时能等待多少请求,通常远大于核数;CPU 密集的进程数接近核数即可,再多只会增加切换与内存占用。压测时看吞吐和 p99,而不是只看平均值。
  • free-threading 先验证再上。 3.13+ 的 free-threading 构建适合做兼容性试验:确认依赖的 C 扩展有对应 wheel,量一下单线程性能回退,再用 sys._is_gil_enabled() 确认运行时到底关没关 GIL。生产环境里,默认构建仍是稳妥选择。

一句话总结:GIL 保住的是解释器内部,不是你的共享变量;线程能不能加速,看任务在等 I/O 还是在抢 CPU。

相关阅读

← 返回文章志