CPython(也就是我们平时装的那个 Python)有一把全局解释器锁——GIL(Global Interpreter Lock)。同一时刻,不管开多少个线程,只有一个线程能真正执行 Python 字节码。这一篇在本机真实的 Python 3.14.7 上做了四组实测:纯计算任务开线程完全不加速、真实网络 I/O 开线程接近线性加速、换成多进程计算任务才真正加速,以及直接测出 GIL 在两个线程之间切换的真实节奏。
这台机器有 10 个 CPU 核心,但下面会看到,8 个线程算同一件事,总耗时和 1 个线程几乎一样。
CPython 对象的内存管理靠引用计数——每个对象头里有个计数器,记录"有多少个地方在引用我",减到 0 就立刻释放。这个计数器的加减,得是线程安全的。
多线程同时修改同一个对象的引用计数,不加保护会算错(计数丢失、提前释放导致悬空指针)。给每个对象单独加锁,性能开销和实现复杂度都很高——这是 3.13 之前多次尝试去掉 GIL 都不成功的核心原因。
干脆整个解释器同一时刻只让一个线程跑 Python 字节码,引用计数天然不会被多线程同时改——不需要给每个对象单独加锁,解释器实现简单,单线程性能也没有额外负担。
不管开多少个线程,同一时刻只有一个在真正跑 Python 代码——CPU 密集型任务开线程完全不会变快。但线程做阻塞调用(I/O、sleep)时会先把 GIL 让出去,这段时间别的线程能真正并行执行。
2024 年通过的 PEP 703 给了 CPython 一条去掉 GIL 的正式路径(需要用特殊选项单独编译"自由线程"版本)。本机安装的 Python 3.14.7 是标准版,可以用真实 API 确认:
GIL 本质就是一个被互斥锁保护的布尔变量。真正有意思的是"什么时候强制换人"这件事怎么实现。
Python/ceval_gil.c · cpython @ v3.14.7, L11 Notes about the implementation: - The GIL is just a boolean variable (locked) whose access is protected by a mutex (gil_mutex) ... - A thread wanting to take the GIL will first let pass a given amount of time (`interval` microseconds) before setting gil_drop_request. This encourages a defined switching period, but doesn't enforce it since opcodes can take an arbitrary time to execute. The `interval` value is available for the user to read and modify using the Python API `sys.{get,set}switchinterval()`.
翻成大白话:线程 B 想要 GIL,但线程 A 正拿着——B 不会立刻抢,而是先等一个 interval(默认 5000 微秒)。等超时了,B 才把"该交出锁了"这个信号(gil_drop_request)发给 A。A 不是马上放,是在字节码执行的间隙检查到这个信号才放手——所以"5ms"是一个下限,不是精确保证,如果某一条字节码指令本身执行得很慢,实际切换会更晚。
Python/ceval_gil.c · cpython @ v3.14.7, L147 #define DEFAULT_INTERVAL 5000 static void _gil_initialize(struct _gil_runtime_state *gil) { gil->locked = -1; gil->interval = DEFAULT_INTERVAL; }
这个常量正是 sys.getswitchinterval() 默认返回值的来源——单位从微秒换算成秒,5000us = 0.005s,和本机实测输出完全对上:
本机真实跑:把 4000 万次浮点运算平均分给 1/2/4/8 个线程,分别计时。
这台机器有 10 个 CPU 核心,理论上 8 个线程应该能获得接近 8 倍加速——但实测加速比全程在 0.95x~1.06x 之间晃,基本就是噪声,没有任何有意义的加速。这就是 GIL 最直接的后果:纯 Python 计算代码,线程数量对总耗时几乎没有影响。
阻塞调用(time.sleep、真实网络 I/O)会先释放 GIL,再去等结果——等待期间,其他线程可以真正并行运行。用真实本地 TCP 连接(带 20ms 服务端延迟)测一遍。
和上面纯计算任务的 0.95x~1.06x 形成鲜明对比——不管是 sleep 还是真实 socket 收发,加速比都近似线性(8 线程接近 8x,甚至因为等待期间系统调度重叠而略微超过)。同一台机器、同一个 GIL,任务类型不同,多线程的效果天差地别。
每个进程有自己独立的解释器和 GIL,互不干扰——用 multiprocessing 跑同样的计算任务对比。
加速比不是完美线性(进程创建、进程间通信本身有开销,加上这台机器同时还在跑其他程序抢 CPU),但趋势非常清楚:进程数越多确实越快,和多线程那条纹丝不动的 0.95x~1.06x 完全不是一回事。这也是 multiprocessing 存在的意义——用进程边界换 CPU 并行,代价是每个进程数据独立、通信要序列化。
两个纯计算线程互相竞争 GIL,记录每次"连续拿着锁跑了多久"——理论上应该聚在 sys.setswitchinterval() 设的值附近。
把 switchinterval 分别设成 5ms、1ms、50ms,实测的平均"连续持锁时长"分别是 6.13ms、1.22ms、57.80ms——都比设定值略高一点(前面源码那段注释已经解释过原因:interval 只是"最早什么时候可以发切换请求",真正切换还要等当前字节码指令执行完,加上 Python 线程调度本身的系统开销,实测数字比设定值大一截是预期之中的),但数量级和趋势和源码描述完全吻合——调大调小切换间隔,连续持锁时长跟着同比例变化。
简化的离散时间片模型复现上面的机制:纯计算线程每持有 GIL 满 5 个 tick(且有其他线程在等)就主动让出;做 I/O 的线程会先把 GIL 完全放掉,I/O 期间不占用它。
线程 A、B 各自需要 14 个 tick 的纯计算;线程 C 需要 4 个 tick 计算 + 10 个 tick 的阻塞 I/O + 4 个 tick 计算。规则和真实源码一致(每 5 个 tick 检查一次是否该让出),每一格的状态都经过独立交叉验证(比如 GIL 互斥——任意时刻最多只有一个线程处于"持有"状态)。