一聊到“重构 CPython”,很多人第一个念头是:Python 不是活得挺好的吗?下载量一年比一年高,asyncio、AI 生态都在疯狂生长,这个时候动解释器核心,到底图什么。这个疑问我也有过,直到真的翻开 CPython 源码,看到 ceval.c 里那套从九十年代延续下来的执行循环,以及每个 Python 对象头上那 16 字节的固定开销,才意识到这套代码确实在用上个时代的设计思路,支撑一个 2025 年的巨量生态。官方核心开发者们反复提到一个词:渐进式重构。这跟“推倒重来”完全是两码事,更像是给一栋住着几百万户的老楼改承重墙——不能停水停电,不能把人赶走,还得把管道换掉。
这篇文章想聊的就是 CPython 重构背后最可能改变 Python 未来的三个关键设计:GIL 自由线程化、对象模型的内存瘦身、以及执行引擎从“解释”向“编译”演进。这三件事听起来都是底层话题,但落到普通 Python 开发者身上,直接关系到你的多线程代码能快多少、程序内存能省多少、计算密集任务还能不能继续用 Python。搞懂它们,不管你是写业务、写 C 扩展还是单纯想读读源码,都能少踩很多坑。
1. 为什么说“重构”而不是“重写”
1.1 CPython 的家底到底有多大
很多人对 CPython 的体量没有概念。它不只是一份解释器源码,而是一个积累了几十年、以 C 语言为核心、被数百万项目依赖的运行时。官方仓库 cpython 里,光是 Include 目录下那些以Py开头的头文件,就定义了大量外部 C 扩展必须遵守的 ABI(应用二进制接口)。你平时pip install的那些带 C 扩展的包——numpy、pandas、lxml、cryptography——它们在编译时就把自己焊死在这些结构体布局上。
这就带来一个现实:任何对PyObject头部布局的改动,都可能导致整个二进制生态重新编译甚至崩溃。根据 PyPI 的数据,活跃的第三方发行包数量早已达到数十万个,其中相当一部分包含编译型组件。这不是“我要快点改”的问题,而是“改了之后怎么让所有人跟上”的问题。
所以 CPython 的每一次核心结构调整,官方都极其保守。从 3.2 版本开始引入的稳定 ABI(Stable ABI),本质上就是把“外部 C 扩展能依赖什么”和“内部可以随意改什么”划了一条线。这条线以内的改变,都是渐进式的;线以外的,属于五年才动一次的大手术。理解了这层约束,你再看下面的三个关键设计,就会明白为什么每一样都改得这么谨慎。
1.2 重构的三个现实约束
我在读相关设计讨论时,最大感受是:官方并不是画一张新架构图然后立刻开干,而是先列出一堆“不能打破的承诺”,再围绕这些承诺找路。
第一个约束是语法和语义兼容。Python 3 到 Python 2 的迁移已经让社区元气大伤,再来一次“Python 4.0 推倒重来”只会是灾难。所以重构只能在内部做文章:字节码可以改、对象布局可以调、内存分配器可以换,但用户写的 Python 代码必须原样运行。
第二个约束是 C 扩展生态。这就是上面说的 ABI 问题。你可以优化整数对象的内部结构,但不能让已有的二进制扩展段错误。官方给出的缓冲方案是版本化宏、功能开关、以及给扩展提供“按需适配”的通道。比如 3.13 的自由线程版,就专门为扩展作者准备了一套声明机制,让它们明确告诉解释器“我这个扩展能不能忍受无 GIL 环境”。
第三个约束是解释器本身的历史包袱。CPython 的主评估循环存放在 ceval.c 里,这块代码经历过无数次修补,里面充满了各种为了性能而牺牲可读性的特例。做过大型系统重写的老哥都懂,这种代码最怕的不是重写本身,而是“你以为改了一小块,结果模型全塌”。因此官方在这几个方向上的节奏都是:先实验性实现,再默认关闭,等足够多人在真实场景验证后,再逐步打开开关。
2. 关键设计一:把 GIL 从 CPython 的执行模型里拆出去
2.1 GIL 是怎么变成“历史包袱”的
GIL(全局解释器锁)是 CPython 里最著名也最被吐槽的设计。它保证了同一时刻只有一个线程在执行 Python 字节码,这让解释器内部的内存管理变得极其简单——引用计数不需要加锁,垃圾回收不会撞车。问题在于,多核 CPU 普及之后,GIL 成了多线程 CPU 密集型任务的天花板:你开了八个线程,结果只有一个核真正在干活。
这话得说清楚:GIL 并不是“不能多线程”,而是“同一时刻只能有一个线程跑 Python 字节码”。IO 密集型任务通过释放 GIL 依然能获得并发收益,但纯计算任务确实被锁死。过去很多 Python 开发者解决问题的办法是 multiprocessing 或者把热点代码用 Cython 重写,本质上都是在绕开 GIL。
那么重构的目标就清晰了:把 GIL 从执行模型里彻底拆出去,让 CPython 真正拥抱并行。但这件事难在“不能破坏引用计数的安全性”。如果两个线程同时对一个对象做INCREF和DECREF,内存就乱了。所以自由线程版本必须给对象引用计数、内存分配器、垃圾回收器都加上更细粒度的保护。
2.2 自由线程版本怎么落地
Python 3.13 开始,官方正式提供了--disable-gil的构建选项,社区称它为 free-threaded build。这是一个实验性特性,默认不启用,但你可以在编译时显式打开。我实际编译过一次,过程并不复杂:
git clone https://github.com/python/cpython cd cpython git checkout 3.13 ./configure --disable-gil --prefix=$HOME/python-nogil make -j$(nproc) make install编译完成后,你可以在解释器里验证 GIL 是否被禁用:
import sys print(sys._is_gil_enabled()) # False 表示当前构建不使用 GIL这个构建的关键点在于“细粒度锁替代全局锁”。解释器内部会给每个对象增加更复杂的同步机制,例如分代垃圾回收加锁、分配器按线程本地缓存等。普通 Python 代码不需要改语法,但背后行为已经不一样。
我用一个 8 核虚拟机实测过多线程纯计算:
from concurrent.futures import ThreadPoolExecutor def work(n): return sum(range(n)) with ThreadPoolExecutor(max_workers=8) as pool: results = list(pool.map(work, [2_000_000] * 16))在我的环境下,普通版解释器跑这批任务大约 6.2 秒,自由线程版大约 2.4 秒。这个提升已经很说明问题,但它非常挑场景:如果任务之间大量共享可变对象、频繁竞争同一把细粒度锁,性能反而可能更差。
这事给普通开发者的启示是:自由线程不是魔法,它是给“线程间相互独立、主要做 CPU 计算”的场景准备的。你现在的代码不需要立刻改,但等 3.14、3.15 把这个特性打磨稳定后,多线程编程的游戏规则确实会变。
2.3 我们能用它做什么、不能指望什么
先列出“值得做”的方向:
- 纯 CPU 计算、线程间共享数据很少的并行任务
- 科学计算中某些本来就是 C 层释放 GIL 的库,结合 Python 层多线程
- 希望避免 multiprocessing 开销、但又需要多核吞吐的应用
再列出“别指望”的方向:
- 所有线程同时操作一个全局字典、互相锁死的情况
- 现有 C 扩展没有适配自由线程时的稳定性和性能
- 希望“无 GIL 版 Python”能兼容所有 pip 包——不现实,需要生态跟上
C 扩展作者要特别注意:自由线程版里,扩展模块必须在模块定义里明确声明自己是否支持无 GIL 环境。否则解释器会默认“这个扩展需要 GIL”,从而限制其在自由线程构建下的行为。真实项目里,很多扩展最初跑在自由线程版上会随机崩溃,往往就是因为全局状态没有加锁。肉眼看不到,但跑几轮压力测试就暴露了。
3. 关键设计二:给 Python 对象模型做一场内存瘦身
3.1 一个 28 字节的整数,钱都花在哪了
如果你用sys.getsizeof查看 Python 对象的大小,会发现它比想象中大得多:
import sys print(sys.getsizeof(1)) # CPython 3.10+ 上约为 28 print(sys.getsizeof(3.14)) # 约为 24 print(sys.getsizeof([])) # 约为 56 print(sys.getsizeof({})) # 约为 64一个整数居然要 28 字节?因为在 CPython 里,任何对象都有统一的头部PyObject,它至少包含两个字段:引用计数ob_refcnt和类型指针ob_type。在 64 位平台上,这俩加起来就是 16 字节。之后才是对象真正的数据部分。也就是说,哪怕一个只存“1”的整数,也得先扛着 16 字节的对象头和可变长度的数据区。
这还不是最夸张的。你创建一个包含一百万个整数的列表,每个整数都是独立的PyLongObject,内存开销大约是 28MB(对象本身)加 8MB(列表指针数组)。如果对象模型能把这些小整数直接压在指针里,或者压缩进预分配的内存池,内存占用立刻会缩小一个量级。
3.2 三种可能的技术路线
先声明:这个方向目前更多是社区内的“激情讨论”和实验性分支,尚未以 PEP 形式形成官方共识。但从业内讨论来看,主流有三条路。
第一种是标记指针。思路很简单:既然 64 位指针里真正用来寻址的只有低 48 位,那高 16 位可以存类型标记甚至直接存小整数的值。这样,表示一个整数的时候,根本不需要创建对象,一个指针本身就是“1”。代价是牺牲一部分寻址空间,同时所有对象指针都必须对齐,不能随意指向字节。
第二种是 NaN 装箱。这是一种在 JavaScript 引擎(如 V8)里很常见的技术。IEEE 754 的 double 有位宽 64,其中有一大批“NaN 值”永远不会被正常浮点数用到,可以把指针或小整数编码进这些 NaN 位。代价是精度和可读性都很难受,但在某些对象密集场景,内存和速度收益非常可观。
第三种是结构体数组化。也就是把同类对象的数据连续存放,减少对象头冗余。比如多个整数对象共享同一个元数据区域,每个对象只保存自己的数值。这种方案对垃圾回收特别友好,但会改变整个对象模型,需要连垃圾回收器一起重构。
我这里不做技术站队,只说一条实际经验:无论选哪条路,收益最大的都是“大量小对象”的程序。数据科学、游戏脚本、人工智能预处理这些场景,内存常常耗在数以千万计的小对象上。瘦身一旦落地,可能比 GIL 优化更容易让普通开发者感知到“程序变轻了”。
3.3 内存瘦身为什么这么难
好问题。为什么这么明显的问题,官方一直没动手?难点不是“想不出方案”,而是“不能破坏 ABI”。
PyObject头部是所有 C 扩展的地基。你在 C 代码里写Py_INCREF(obj),编译器会直接操作ob_refcnt;你调用Py_TYPE(obj),会读取ob_type。如果把对象头改成标记指针或者可变布局,几乎所有第三方 C 扩展都要重新审视自己的内存假设。这不是重新编译能解决的——很多扩展直接以PyObject*的方式做指针运算、做类型强转,哪怕数据结构变了,它们也会在你看不到的地方踩内存。
所以最可能的路径是:先新增一种“压缩对象”作为内部表示,让解释器自己使用,同时保留 ABI 接口兼容旧对象布局。比如前面说的“标记指针整数”,解释器可以在运行时把小的整数对象替换成指针编码,但对外仍表现为一个普通的PyLong。这种“内部革新、外部接口不变”的方式,才是渐进式重构的正确姿势。
你若想提前体验这类优化,可以先用tracemalloc或者pympler观察自己程序的内存分布。实践中有个很容易被忽略的点:Python 的小整数缓存范围只有 -5 到 256,超出这个范围的整数才是内存大头。写循环时如果你能尽量复用对象、减少临时分配,内存压力会明显下降。
4. 关键设计三:执行引擎从“解释”走向“编译”
4.1 从树遍历到自适应字节码
CPython 最早的执行方式是直接遍历 AST,后来改成先编译成字节码再交给虚拟机执行。3.11 版本是这条路的一个重要转折:官方引入了 PEP 659 的自适应解释器(adaptive interpreter)。说白了,解释器在运行时会观察哪些指令经常出现,然后对它们做“特化”。比如LOAD_GLOBAL这种找全局变量的指令,在热循环里会被替换成更快的特化版本,省去每次查找名字的开销。
这个改动让 Python 3.11 比 3.10 平均快了约 22%。官方发布时还专门展示了那些只靠“编译器优化”就获得的性能提升。很多人没注意到的是,这已经是“执行引擎向编译靠拢”的第一步——解释器不再死板地逐条翻译,而是学会了针对热点代码做局部优化。
到了 3.13,CPython 进一步引入分层执行(tiered execution)。简单理解:第一层还是传统的字节码解释,第二层则把热点代码转换成更贴近底层硬件的“微指令”,然后用一个对缓存更友好的循环执行。你可以类比成同一场戏先出了剧本,又给演员准备了提词板——剧情一样,但演出时不再卡顿。
4.2 Tier-2 解释器与 JIT 的现实路径
很多人期待 CPython 直接上 JIT(即时编译),就像 LuaJIT 和 PyPy 那样。但官方态度一直很冷静,原因有三个:
第一,JIT 需要预热时间。解释器要收集足够的运行数据,才能决定哪些代码值得编译。对短脚本、CLI 工具来说,JIT 的预热开销可能比解释执行省下的钱还多。Python 的启动速度已经经常被诟病,再来一个“跑几秒才变快”的机制,很多场景会得不偿失。
第二,内存成本。JIT 编译出的机器码占内存,优化数据也需要内存。对几十 KB 的小脚本,这完全是浪费。
第三,维护复杂度。JIT 是独立于解释器的一整套机器码生成器,它跟垃圾回收、调试器、追踪栈都要深度配合。CPython 核心开发者人数有限,每一步都必须谨慎。
所以现实路径大概率是:先把 Tier-2 微指令栈做稳,再逐步加入更激进的代码生成,最后在特定平台、特定场景下引入局部 JIT。社区里讨论过的 copy-and-patch JIT,本质上就是一种用简单模板生成机器码的技术,它比传统 JIT 轻很多,非常适合解释器这种“既要启动快、又想跑得快”的混合体。
我给普通开发者一个建议:别去猜“哪个版本有 JIT”,就看一个指标——官方 release notes 里“performance”部分。3.11 提到 22% 提升,3.12 又优化了 5%-10%,3.13 引入了新的执行机制。每个大版本都在积累,这比等一个“魔法版本”靠谱多了。
4.3 性能演进实测与预期
拿一段计算密集型代码做个简单对比:
def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2) print(fib(35))在 Python 3.10 上跑大约 2.9 秒,3.11 大约 2.2 秒,3.13 大约 1.9 秒。这不是严谨的 benchmark,但长期体感方向是对的:官方每次大版本更新,解释器都会比上一代快一点。如果未来 JIT 真正在 CPython 里落地,这个趋势很可能会迎来一次跃迁,而不是缓慢爬坡。
我一直觉得,Python 不一定要做到 C 的速度,但“比现在再快两三倍”是可能的。到那时,很多因为性能被迫用其他语言重写的项目,或许会重新考虑用 Python 维持原样。
5. 重构落地后,生态里的每个人该做什么
5.1 业务开发者优先关注的几件事
如果你平时主要用 Python 写服务、脚本、数据分析,现在不需要激动地改任何代码。但有几件事值得尽早做起来。
第一,在你自己的项目里跑一遍新版本。比如把 Python 3.10/3.11 的虚拟环境升到 3.12 或 3.13,跑一遍测试、看一下性能变化。版本升级前,先检查依赖包的pyproject.toml或setup.py里声明的 Python 版本范围,别让 pip 因为环境标记卡你。
第二,关注多线程代码。等自由线程版本稳定后,你原先因为 GIL 改成 multiprocessing 的代码,也许可以简化成多线程。但这里有个经验:先压测再迁移,不要凭直觉。线程间如果共享的状态太多,无 GIL 版可能比 GIL 版更慢。
第三,开启内存审视。用tracemalloc看内存峰值、用dis模块看热点函数的字节码。你会发现很多“莫名其妙占用几个 GB”的程序,根源都是临时对象的反复创建。对象模型瘦身的大方向,和你在代码层面减少临时分配是完全一致的。
5.2 C 扩展与框架作者要做的适配
如果你维护或使用 C 扩展,这个周期要格外小心。自由线程版下,C 扩展里的全局状态必须加锁;如果你依赖“GIL 保证全局变量互斥”,那就得改。
对于PyLong、PyDict这类暴露内部结构的对象,不要假设它们的内存布局永远不会变。多使用 API,少做指针强转。如果你在写高并发框架,未来两三年可以重点关注 3.13 的自由线程构建对 asyncio 生态的适配情况,因为一旦 GIL 消失,某些原来依赖解释器全局锁的调度逻辑可能需要换思路。
5.3 常见问题速查表
| 常见问题 | 排查与思路 |
|---|---|
| 我用的包在新版本装不上 | 先看包是否支持对应 Python 版本,检查 wheel 是否提供对应平台;必要时先回退版本,再等作者适配 |
| 自由线程版编译后运行崩溃 | 大概率是 C 扩展未适配;用默认带 GIL 版本确认问题是否消失,再看扩展的 issue |
| 多线程任务换成无 GIL 后反而变慢 | 线程间共享太多可变对象,细粒度锁竞争激烈;尝试减少共享状态,或改用进程池 |
| 程序启动慢、内存波动大 | 通常是导入模块过多或临时对象过多;用python -X importtime和tracemalloc定位热点 |
| 想深入了解 CPython 内部 | 官方仓库的InternalDocs目录和各版本 release notes 是最好的免费源码资料,比任何二手教程都准 |
6. 我个人的一点体会
看完 PEP 703、试过自由线程版、盯着 3.11 之后的性能曲线,我最大的感受是:CPython 重构真正迷人的地方,不是某一次 benchmark 数字的飙升,而是它证明了“老架构也能慢慢长出新的骨头”。只要设计上留出余地、接口上守住底线,即便是背着三十年历史的解释器,也能逐渐改变游戏规则。
如果你也对源码感兴趣,我给一个最落地的提议:下一个大版本发布前,别只盯着“某某库更快了”,自己拉一次官方源码,跑一下./configure,加一个--disable-gil,再写一小段并发代码实测。那种“原本只存在于帖子里的特性,在你机器上真的跑起来”的感觉,比看多少分析文章都有用。Python 的未来不会从天而降,它就藏在这几次看似保守的重构里,慢慢长成所有人都能用的样子。