Python 为什么这么慢?真凶不只是 GIL
你在网上可能听过无数遍:“Python 慢是因为有 GIL(全局解释器锁)。”
于是你心安理得地想:哦,那等到多解释器出来,或者我用多进程,它就该快起来了吧?
真的不是这样。GIL 只负责让你在多核心上“有劲使不上”,但 Python 单线程跑计算本身就慢得不正常。
同样的纯计算任务,C++ 比 Python 快 10~50 倍,Rust 快几十倍,就连同样动态类型的 JavaScript,也能比 Python 快 2~5 倍。
这些速度差异,和 GIL 没有任何关系——因为单线程不受锁的影响。
今天我们就用讲故事的方式,把 Python “基因里的慢”彻底拆解开。即使你没有计算机体系结构基础,看完也能明白:原来 Python 的慢,是刻在它最根本的设计哲学里的。
一、所有东西都是“盒子里的盒子”——Python 对象的真面目
从 C 语言的整数说起
在 C 语言里,定义一个整数int a = 42;只做两件事:
- 在内存里划出 4 个字节(通常),直接存下数字 42。
- 这 4 个字节可以放在 CPU 的寄存器里,或者紧挨着其他变量放在栈上,访问速度极快。
C 语言的数据,就像一张便利贴,写上数字,直接贴在电脑桌面上,随时能看。
Python 的整数:一个庞大的档案袋
在 Python 里写a = 42,底层发生的事要复杂得多。CPython(官方 Python 解释器)会创建一个类型为PyLongObject的结构体,它至少包含三样东西:
- 引用计数(
ob_refcnt,8 字节) —— 记录有多少个变量在用它。 - 类型指针(
ob_type,8 字节) —— 指向“我是整数”这件事的说明书。 - 真正的数值(
ob_ival,8 字节) —— 存着 42。
而且,这个结构体并不是直接放在紧邻着其他数据的地方,而是从操作系统的“堆”内存里单独申请一块空间,就像你每次需要一张纸条,都跑去仓库领一个标准公文袋,再把纸条装进去。整个公文袋至少 28 字节,为了存一个 42。
这个画面是不是已经很可怕了?但这才刚刚开始。
为什么说“堆分配”是性能灾难?
当你建立一个包含 1000 个整数的列表时:
- C 语言:一个连续的 4000 字节数组,所有数字肩并肩躺在内存里。
- Python:1000 个独立的“公文袋”散落在内存各处,列表本身存的是 1000 个指针(就是公文袋的地址)。
当你需要遍历这个列表,把每个数加起来:
- C 语言:CPU 可以一次预读一大块数据到缓存,顺序访问,就像生产线上的零件排好队流过来。
- Python:CPU 先读一个指针,根据地址去找公文袋,打开公文袋翻出数字,再放下,去找下一个地址……每一次访问都可能跳到内存里完全不相干的位置。现代 CPU 缓存对此根本无法预测,会频繁发生“缓存未命中”(cache miss),每次未命中都好比工厂要停工一百多个时钟周期去仓库取料,生产线立刻慢成乌龟。
这就是你听到的“所有数据都在堆上,很难放进 CPU 缓存”的通俗解释。Python 对象的内存布局天生就是碎片化、胖存储的,这是它慢的第一重根源。
二、动态类型的代价——每一步都在“猜”,猜错就倒大霉
加法到底怎么做?CPU 猜得头疼
对于a + b,C 语言在编译时就能确定:a、b 都是int,直接生成一条加法机器指令,CPU 到达这里,二话不说就加上,流水线畅通无阻。
Python 则完全不同。因为变量没有固定类型,上一秒a是整数,下一秒可能变成字符串,所以 CPython 执行a + b时必须走一个极其曲折的流程:
- 找到
a的公文袋,看它的类型指针,哦,是个整数。 - 找到
b的类型指针,也是整数,好,调用整数加法函数。 - 但如果
b是字符串呢?那就得抛异常或者调用不同类型对应的函数。 - 哪怕都是整数,加法内部还要检查会不会溢出,要不要把结果从“小整数”升级成“长整数”对象。
这个过程的每一步,在 CPU 眼里都是条件分支(if-else)。现代 CPU 为了高速执行,会像赌徒一样提前猜一条路跑下去,这叫“分支预测”。如果猜对了,流水线继续飞起;如果猜错了,已经跑到一半的指令全部作废,清空流水线,重新加载,代价十几到几十个时钟周期。
Python 这种动态分发,每次操作的类型都可能不同,CPU 的分支预测器根本建立不起规律,预测失败率高得惊人,流水线反复被打断。这就是“操作造成流水线停顿、分支预测失效”的真实含义。
用生活场景比喻:快递分拣员
想象你是一个快递分拣员,面前有过不完的包裹。每个包裹外面都贴着“易碎品”“普通件”“冷藏”等标签,你需要根据标签把包裹送去不同通道。
- 静态类型语言(C/Rust):上午所有包裹都是“普通件”,你看都不用看,闭着眼睛往右扔就对了。动作极其顺畅。
- 动态类型语言(Python):每个包裹都可能换标签,你每拿起一个都得翻过来仔细看标签,判断送去哪条通道,而这个判断本身就要暂停手上的动作,思考一下。要是偶尔判断错了,还得追回来重来。
Python 的程序执行过程,就是这样频繁地停顿、判断、改道,自然快不起来。
三、字节码分发——翻译官逐字念稿的效率
CPython 是个“解释器”,不是“编译器”
当你的 Python 代码运行时,CPython 并不是直接转化成 CPU 能懂的机器码,而是先变成一套叫作“字节码”的中间指令,然后在一个巨大的循环里一条一条执行。这个循环在 CPython 源码里叫ceval.c,就是著名的“解释器主循环”。
可以这样比喻:
- 编译语言(如 C、Rust)是直接把剧本交给演员,演员拿到的是详细的、现成的动作指令,上台就演。
- 解释型 Python则是一个翻译官,他看着剧本,读一句:“LOAD_FAST 0”,然后自己理解一下,哦,是把本地变量 0 取出来压到栈上;“BINARY_ADD”,哦,是把栈顶两个数加起来……每一步都要经过“取指令→理解指令→执行”的过程。
这个翻译官读剧本本身要花时间,而且读每条指令都要在那个巨大循环里跳转,又是一大堆分支。这就是“字节码分发开销”。
为什么 JavaScript 能快那么多?
JavaScript 也是动态类型脚本语言,但它背后有 V8(Chrome 的 JS 引擎)这样的高性能虚拟机。V8 做了几件 CPython 没做的事情:
- JIT 即时编译:检测到热点代码(比如一个循环被跑了很多次),直接把这段代码编译成本地机器码,下次再执行就跳过翻译官,直接给演员剧本。字节码分发开销一下子就没了。
- 隐藏类和内联缓存:V8 发现“这个对象总是被当成同一种结构使用”,就偷偷给它分配一个固定布局,把属性访问变成固定偏移量的内存读取,省掉了反复查字典、做分支判断的过程。而 CPython 仍然老老实实每次查字典。
- 小整数优化:V8 把常用的小整数直接编码到指针里,不用单独创建对象,完全省掉了堆分配和内存碎片。
这些优化,正好是对症下药地解决了我们前面说的“堆分配、动态类型、分支预测”问题。所以 JavaScript 比 Python 快 2~5 倍是完全合理的,它用工程能力在很大程度上抵消了动态语言的先天劣势,而 CPython 为了简单、可维护性,刻意没有加入这些复杂优化。
四、整合起来看:单线程 Python 到底慢在哪?
我们现在可以把“让你 Python 单线程跑得慢”的三个元凶排成一列:
肥胖的对象与糟糕的缓存局部性
一切数据都是堆上的对象,紧凑的数据消失了,CPU 缓存总是白忙碌,访存成为瓶颈。无处不在的动态分发与分支预测失败
每次操作都要查类型、选函数,CPU 流水线被频繁打断,预测失败不断发生。解释器循环的固定门票
每条字节码都要经过主循环分发,额外吃掉一层指令开销,而没有 JIT 加速。
当这三者叠加,哪怕是最简单的整数循环,Python 花在“读懂指令、找对象、判断类型”上的精力,远大于真正做加法的那一下。这就是为什么有 C 程序员调侃:“Python 是一个极好的抽象层,只不过为了这份抽象,你要付几十倍的税。”
五、GIL 呢?它在这些慢面前只是“配角”
现在你应该能区分开了:
- GIL是一个同步锁,导致多线程在 CPU 密集任务上无法利用多核心。
- 上述三个原因则是 Python 解释器执行本身的效率低下,让你单核就跑得慢。
如果把 Python 比喻成一辆车:
- GIL 是车上装了个限速器,使得在高速公路上(多核)你也只能占用一条车道。
- 而对象的堆分配、动态分派、解释循环,决定了这辆车本身就是一辆大货车,自重三吨,发动机还是小排量,在市区(单核)跑也远远跑不过别人家的小轿车。
所以,即便 Python 3.13/3.14 开始有办法绕过 GIL(自由线程、多解释器),Python 单线程的计算速度也不会因此质变,仍需要寄望于“Faster CPython”这类项目在解释器层面优化那些根因。而如果你想做数值密集型计算,现实的最优解依旧是:用NumPy(底层用 C 写成,规避 CPython 的对象开销),或者直接写 C 扩展。
结语:慢,是选择,而非缺陷
Python 的“慢”根植于它最宝贵的特性:极致的动态性、高度一致的“一切皆对象”模型,以及解释器实现的简单可维护性。这种设计让 Python 变得易学、易写、易读,但代价就是 CPU 时间的浪费。
理解这些底层原因后,你就不会再奇怪为什么一个简单的循环比 C 慢几十倍,也不会寄希望于某个新特性能瞬间让 Python 变成火箭。你会更明智地知道:什么时候该用 NumPy,什么时候该换 PyPy,什么时候该把性能敏感部分用 C/Rust 重写,什么时候就安心接受 Python 的便利,用它的慢来换你的开发效率。
毕竟,工具的选择永远是权衡。而我们已经清楚知道,天平的两端,到底放着什么。