ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

CPython重构三件事:自由线程、内存瘦身与编译优化

2026/10/7 12:21:42 拓冰建站 浏览量
CPython重构三件事:自由线程、内存瘦身与编译优化 一聊到“重构 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 版本开始引入的稳定 ABIStable 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_workers8) 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.13CPython 进一步引入分层执行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 的未来不会从天而降它就藏在这几次看似保守的重构里慢慢长成所有人都能用的样子。