Python 关掉 GIL 实测:3.13t 四线程只快了 2.4 倍,我踩了 6 个坑

🔑 关键词:Python无GIL, freethreading, Python 3.13t, cp313t wheel, Python多线程加速

📖 摘要:在 MacBook Air M2 上实测 Python 3.13t / 3.14 free-threading:纯 Python 循环、numpy 矩阵乘、hashlib 三类负载的对比数据,cp313t wheel 装包、PYTHON_GIL 开关、线程安全的坑,以及我判断该不该关 GIL 的 5 步自检顺序。

先给结论:别急着升级

图片

上周三下午,我们的优惠券批算服务 p99 飙到 4.1 秒(平时 800ms 左右)。这个服务是 FastAPI 加一坨纯 Python 写的规则引擎,单进程跑,CPU 直接打满。同事在群里丢了句「Python 3.13 不是能关 GIL 了吗,改多线程啊」。我当时觉得这事儿有戏,毕竟 Python 多线程是出了名的——开四个线程跑得比一个还慢。

于是我用两个晚上加一个周末,在 3.13t 和 3.14 的 free-threaded 构建上把同一套负载跑了一遍。结论先放这儿:no-GIL 确实能用了,但大概率救不了你手上那个服务。 它能提速的场景比你想象的窄,会踩的坑比你想象的多。下面全是实测数据,我这台机器上跑出来的,你那边数字肯定不一样,但量级和坑是同一个。

怎么装一个真的能关 GIL 的 Python

先纠正一个高频误解:GIL 不是运行时开关。你拿 pip 装的普通 Python 3.13,加什么参数都关不掉 GIL,必须装专门的 free-threaded 构建。官方在 python.org 上把它标成 experimental,可执行文件叫 python3.13t,结尾那个 t 就是 threading 的意思。

用 uv 最省事(版本号直接写 3.13t 它认得):

uv python install 3.13t
uv venv --python 3.13t
source .venv/bin/activate

图片

pyenv 的话:

PYTHON_CONFIGURE_OPTS='--disable-gil' pyenv install 3.13.1

装完先验三行,别急着跑业务代码:

import sys, sysconfig
print(sys.version)
# 3.13.1 experimental free-threading build (main, Dec  3 2024) [Clang 16.0.0]
print(sys._is_gil_enabled())                        # False
print(sysconfig.get_config_var('Py_GIL_DISABLED'))  # 1

想临时把 GIL 打开(有些 C 扩展就是不能在无 GIL 下跑),可以用 PYTHON_GIL=1 python3.13t main.py,或者命令行加 -X gil=1。反过来不行——普通构建没办法用 -X gil=0 变出多线程并行,必须是 t 版。

实测:三种负载,三种完全不同的结局

图片

机器是 MacBook Air M2 / 16G / macOS 15.2,并发数取 4(M2 是 4 性能核 + 4 能效核),没有做线程亲和性绑定。每个用例跑 3 次取中位数,测之前关掉了所有浏览器和 Docker。

  • 用例 A:纯 Python 计算,一个 5000 万元素的 range 求和加整数位运算,输出校验和
  • 用例 B:numpy 矩阵乘法,2000x2000 的 float64 矩阵乘自己,走 BLAS
  • 用例 C:hashlib 摘要,对内存里 1.2GB 的 bytes 反复算 sha256
用例 3.13 单线程 3.13 四进程 3.13 四线程 3.13t 单线程 3.13t 四线程
A 纯 Python 循环 10.4s 3.1s 10.6s 14.2s 4.3s
B numpy 矩阵乘 6.8s 2.5s 1.9s 9.1s 2.6s
C hashlib sha256 5.6s 1.7s 1.6s 7.4s 2.1s

这张表里最值得看的其实是 B 和 C 两行。3.13 四线程比四进程还快,因为 numpy 和 hashlib 在 C 层本来就主动释放 GIL——你的热点只要在 C 扩展里,你早就能用多线程提速了,跟 GIL 一点关系都没有。 而它们在 3.13t 上反而更慢,因为 free-threaded 构建本身单线程就要付一笔税,那些库的 cp313t 版本也还没针对新内存模型做优化。

只有 A 那一行,no-GIL 才真正有用:3.13 四线程 10.6 秒,跟单线程基本没差(还慢了一点,切换开销),3.13t 四线程 4.3 秒,快了 2.4 倍。但注意,它依然打不过 3.13 的四进程(3.1 秒),也没到「四核四倍」的理想值。原因就是前面说的那笔税:官方给的数字是 pyperformance 上单线程性能损失接近四成,再叠上锁竞争和对象分配的开销,剩下的就没多少了。

顺便说一句,numpy 那个用例的绝对值别太当真,BLAS 自己就会开多线程,我这里只是拿它看趋势。

踩到的 6 个坑,从装包到线程安全

图片

坑 1:wheel tag 是 cp313t,不是 cp313。 装包的时候务必加 --only-binary=:all:,否则 pip 会去找源码包然后当场开始编译,十分钟后报一个跟 GIL 毫无关系的错。numpy 2.1+、pandas 2.2.4+、scipy 1.15+、pydantic 2.10+ 都有 cp313t 的轮子,其余的包,去 PyPI 文件列表里搜一下文件名有没有 cp313t 再决定用不用。

坑 2:C 扩展直接崩,还没有 traceback。 老一点、还在直接摸对象头那套 API 的扩展基本都完蛋。我遇到的第一次崩溃来自一个内部 C 加速模块,段错误,Python 层面什么都看不到,gdb backtrace 里是一堆 _PyObject_GC 开头的符号。没有优雅的降级方案,只能升级或者整个拿掉。

坑 3:内存涨了。 同一个服务启动完常驻,3.13 是 386MB,3.13t 是 452MB,涨了大约 17%。biased reference counting 和变大的对象头都是要还的债,如果你跑的是几十个容器,这个数字值得先算一遍。

坑 4:以前「侥幸安全」的代码不再安全。 这里有个很多人搞反的点:单个的 list.append(x)dict[k] = vset.add(x) 在 free-threaded CPython 里仍然是线程安全的,PEP 703 明确保留了这些单操作保证,不用给它们上锁。危险的是复合操作和 check-then-act,我在代码里翻出来的典型长这样:

_stats = {}

def record(k):
    if k not in _stats:        # 两个线程可能同时通过这个判断
        _stats[k] = 0
    _stats[k] += 1             # 读-改-写,free-threaded 下必错

图片

换成 collections.Counter 也不保险,计数一样是复合操作。要么老老实实加 threading.Lock,要么改成每个线程维护一份局部 dict、最后再合并——后者通常比加锁快得多,也是我最后采用的做法。另外,迭代中修改容器依然会抛 RuntimeError: dictionary changed size during iteration,这块行为没变。

坑 5:调试工具链是断的。 我平时靠 py-spy record 看火焰图,那天 attach 到 free-threaded 进程直接报 failed to suspend process,换了两台机器都一样,最后只能退回 cProfile 加打日志。我用的某个 APM agent 也不认 python3.13t 这个可执行文件名,得手动软链成 python3 才认。

坑 6:第三方库会「假装」支持。 有些包纯 Python 那部分跑得挺好,一旦调到底下没有 cp313t 轮子的 C 依赖,就会在一个完全不相干的地方炸。我踩的是一个 otel exporter,import 阶段就挂了。

我的观点:这是给库作者的礼物,不是给应用开发的

跑完这一圈,我最大的收获不是那几个性能数字,而是一个自检顺序。你如果正在考虑 free-threading,先按这个顺序走一遍,能省掉我那两个晚上:

  1. 先量,别猜。py-spy record -o prof.svg --pid <pid> --duration 30 --native,看时间到底花在哪。火焰图顶部是 _PyEval_EvalFrameDefault,说明你是纯 Python 热点,可以继续往下;已经花在 numpy、zlib、_hashlib 或者数据库驱动的 C 层里,那你现在就能靠多线程提速,赶紧去改,跟 GIL 没关系。
  2. 确认热点真的能吃满多核。 有些代码看着是 CPU 密集,其实 80% 的时间在等锁或者等内存带宽,这种加线程只会更慢。
  3. 先试向量化,再试换解释器。 我那 10.4 秒的纯 Python 循环,numpy 重写之后是 0.4 秒。一个纯 Python 的 for 循环本来就不该出现在关键路径上,这个问题 GIL 消失了也还在。
  4. 真要上,先上进程。 我这个场景 ProcessPoolExecutor(max_workers=4) 直接把 p99 干到 0.9 秒,改动量 30 行左右,没动任何依赖,风险最低。
  5. 最后才轮到 free-threading。 它合适的场景是:热点确实在纯 Python 字节码里、向量化不了、又要共享大块内存(多进程的序列化成本吃不消)、依赖还全都有 cp313t 轮子。四个条件同时成立的概率,你自己算。

图片

顺便说说 3.14 和之后

3.14 这边有两个实质变化:PEP 779 让 free-threading 从 experimental 变成了官方支持的构建变体,python.org 也开始直接提供安装包;单线程的性能损失,官方目标是从 3.13 的四成左右压到个位数百分比。我在候选版本上重跑了用例 A,四线程 3.6 秒,比 3.13t 的 4.3 秒好了一截,单线程惩罚确实小了很多,但离「免费午餐」还差得远。

真正的瓶颈是生态。CPython 一年一个版本号,pybind11、pyo3 和一堆 C 扩展要跟着升级适配,保守估计还得等两三个大版本才敢在生产上默认打开。在那之前,free-threaded 更像是给 numpy、pandas 这类库作者准备的工具,让他们能写出真正并行的实现,而不是给你我拿来给业务代码绕过 GIL 的捷径。

最后

我那个服务最后没上 free-threading。规则引擎里最热的那段循环改写了,剩下的换 4 进程,p99 从 4.1 秒掉到 0.8 秒。折腾两个晚上好像白干了,但我现在至少能比较有底气地说出「你的场景不需要 no-GIL」,而不是跟着发布说明复读。

唯一的副作用是:M2 Air 没风扇,跑四进程压测的那一个多小时,笔记本烫得我只能垫在鼠标垫上打字。如果你也在自己的负载上跑过 free-threaded 构建,欢迎来对一下数字,尤其是 x86 服务器上的,我这台 ARM 的 Mac 参考价值确实有限。

🏷️ 标签: