Python 系列 · 第一篇

Python 开了 8 个线程算数,为什么比 1 个线程还慢一点点?

CPython(也就是我们平时装的那个 Python)有一把全局解释器锁——GIL(Global Interpreter Lock)。同一时刻,不管开多少个线程,只有一个线程能真正执行 Python 字节码。这一篇在本机真实的 Python 3.14.7 上做了四组实测:纯计算任务开线程完全不加速、真实网络 I/O 开线程接近线性加速、换成多进程计算任务才真正加速,以及直接测出 GIL 在两个线程之间切换的真实节奏。

这台机器有 10 个 CPU 核心,但下面会看到,8 个线程算同一件事,总耗时和 1 个线程几乎一样。

0.95x ~ 1.06x
本机实测:纯计算任务,2/4/8 线程相对单线程的加速比
5ms
GIL 默认切换间隔,真实源码常量 DEFAULT_INTERVAL

问题:为什么要有这把锁

CPython 对象的内存管理靠引用计数——每个对象头里有个计数器,记录"有多少个地方在引用我",减到 0 就立刻释放。这个计数器的加减,得是线程安全的。

没有 GIL

每个对象都要精细加锁

多线程同时修改同一个对象的引用计数,不加保护会算错(计数丢失、提前释放导致悬空指针)。给每个对象单独加锁,性能开销和实现复杂度都很高——这是 3.13 之前多次尝试去掉 GIL 都不成功的核心原因。

有 GIL

一把大锁,简单但粗暴

干脆整个解释器同一时刻只让一个线程跑 Python 字节码,引用计数天然不会被多线程同时改——不需要给每个对象单独加锁,解释器实现简单,单线程性能也没有额外负担。

代价

纯计算型多线程不能真并行

不管开多少个线程,同一时刻只有一个在真正跑 Python 代码——CPU 密集型任务开线程完全不会变快。但线程做阻塞调用(I/O、sleep)时会先把 GIL 让出去,这段时间别的线程能真正并行执行。

2024 年通过的 PEP 703 给了 CPython 一条去掉 GIL 的正式路径(需要用特殊选项单独编译"自由线程"版本)。本机安装的 Python 3.14.7 是标准版,可以用真实 API 确认:

真实实测 $ python3 -c "import sys; print(sys._is_gil_enabled())" True

真实源码:锁是怎么在线程之间倒手的

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,和本机实测输出完全对上:

真实实测 $ python3 -c "import sys; print(sys.getswitchinterval())" 0.005

真实实测(一):纯计算任务,开线程不加速

本机真实跑:把 4000 万次浮点运算平均分给 1/2/4/8 个线程,分别计时。

真实实测 $ python3 exp_cpu_bound.py Python: 3.14.7 GIL enabled: True 1 thread (all work in one thread): 1.442s 2 threads (work split 2 ways): 1.521s speedup=0.95x 4 threads (work split 4 ways): 1.356s speedup=1.06x 8 threads (work split 8 ways): 1.363s speedup=1.06x

这台机器有 10 个 CPU 核心,理论上 8 个线程应该能获得接近 8 倍加速——但实测加速比全程在 0.95x~1.06x 之间晃,基本就是噪声,没有任何有意义的加速。这就是 GIL 最直接的后果:纯 Python 计算代码,线程数量对总耗时几乎没有影响。

真实实测(二):I/O 密集任务,线程接近线性加速

阻塞调用(time.sleep、真实网络 I/O)会先释放 GIL,再去等结果——等待期间,其他线程可以真正并行运行。用真实本地 TCP 连接(带 20ms 服务端延迟)测一遍。

真实实测 $ python3 exp_io_bound.py # time.sleep() 工作负载 1 thread : 0.966s 2 threads: 0.473s speedup=2.04x 4 threads: 0.243s speedup=3.98x 8 threads: 0.117s speedup=8.28x # 真实本地 TCP socket I/O(自己起了个真实监听 20ms 延迟的服务端) 1 thread : 0.487s 2 threads: 0.249s speedup=1.95x 4 threads: 0.126s speedup=3.86x 8 threads: 0.050s speedup=9.67x

和上面纯计算任务的 0.95x~1.06x 形成鲜明对比——不管是 sleep 还是真实 socket 收发,加速比都近似线性(8 线程接近 8x,甚至因为等待期间系统调度重叠而略微超过)。同一台机器、同一个 GIL,任务类型不同,多线程的效果天差地别。

真实实测(三):想要 CPU 真并行,得换成多进程

每个进程有自己独立的解释器和 GIL,互不干扰——用 multiprocessing 跑同样的计算任务对比。

真实实测 $ python3 exp_multiprocessing.py cpu count: 10 1 process (baseline) : 1.409s 2 processes : 0.840s speedup=1.68x 4 processes : 0.457s speedup=3.08x 8 processes : 0.365s speedup=3.86x

加速比不是完美线性(进程创建、进程间通信本身有开销,加上这台机器同时还在跑其他程序抢 CPU),但趋势非常清楚:进程数越多确实越快,和多线程那条纹丝不动的 0.95x~1.06x 完全不是一回事。这也是 multiprocessing 存在的意义——用进程边界换 CPU 并行,代价是每个进程数据独立、通信要序列化。

真实实测(四):亲手测出 5ms 切换节奏

两个纯计算线程互相竞争 GIL,记录每次"连续拿着锁跑了多久"——理论上应该聚在 sys.setswitchinterval() 设的值附近。

真实实测 $ python3 exp_switch_interval.py default switchinterval: 0.005 switchinterval=0.005s -> 247 switches observed, avg burst duration = 6.13ms switchinterval=0.001s -> 1189 switches observed, avg burst duration = 1.22ms switchinterval=0.050s -> 29 switches observed, avg burst duration = 57.80ms

switchinterval 分别设成 5ms、1ms、50ms,实测的平均"连续持锁时长"分别是 6.13ms、1.22ms、57.80ms——都比设定值略高一点(前面源码那段注释已经解释过原因:interval 只是"最早什么时候可以发切换请求",真正切换还要等当前字节码指令执行完,加上 Python 线程调度本身的系统开销,实测数字比设定值大一截是预期之中的),但数量级和趋势和源码描述完全吻合——调大调小切换间隔,连续持锁时长跟着同比例变化。

演示:两个计算线程 + 一个带 I/O 的线程,GIL 怎么倒手

简化的离散时间片模型复现上面的机制:纯计算线程每持有 GIL 满 5 个 tick(且有其他线程在等)就主动让出;做 I/O 的线程会先把 GIL 完全放掉,I/O 期间不占用它。

GIL 倒手演示
持有 GIL,正在计算 做阻塞 I/O(不需要 GIL) 想算但在等 GIL 已完成

线程 A、B 各自需要 14 个 tick 的纯计算;线程 C 需要 4 个 tick 计算 + 10 个 tick 的阻塞 I/O + 4 个 tick 计算。规则和真实源码一致(每 5 个 tick 检查一次是否该让出),每一格的状态都经过独立交叉验证(比如 GIL 互斥——任意时刻最多只有一个线程处于"持有"状态)。

参考与说明

  • 四组真实实测(纯计算多线程、sleep 与真实本地 TCP socket I/O 多线程、多进程、切换间隔测量)均在本机真实运行的 Python 3.14.7 上完成,数据未做删改;运行环境为 10 核 CPU 的本机,多进程加速比受进程创建开销和机器当时的其他负载影响,不是理想线性。
  • 源码引用(Python/ceval_gil.c 的实现说明注释、DEFAULT_INTERVAL 常量、take_gil() 逻辑)取自 python/cpython 仓库 v3.14.7 标签,与本机安装的 Python 3.14.7 完全一致(通过 python3 --version 确认)。
  • 演示动画的三线程调度模型由 Python 独立实现,用离散 tick 模拟真实的"持锁上限 + 主动让出 + I/O 期间完全不占用 GIL"规则,并做了两层独立校验:(1)重放事件日志,确认任意时刻不会有两个线程同时处于"持有 GIL 计算"状态;(2)确认每一次 I/O 的开始/结束事件正确配对、持续时间吻合。这是一个简化的教学模型,不是真实条件变量/互斥锁机制的逐行复刻。
  • 没有涉及:Python 3.13+ 实验性"自由线程"(no-GIL)构建的具体实现方式与生态兼容性现状、asyncio 单线程事件循环与 GIL 的关系(后面生成器/协程那篇会讲)、C 扩展如何在耗时计算中主动释放 GIL(Py_BEGIN_ALLOW_THREADS)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电