Python 3.13 free-threaded 实测:去掉 GIL 之后,我的代码为什么反而更慢了

🔑 关键词:Python free-threading, GIL, PEP 703, 多线程性能, Python 3.13

📖 摘要:用 4000 万行 JSON 的真实任务对比 Python 3.13 free-threaded 与普通 GIL 版本,发现取消 GIL 后多线程反而比多进程慢 43%。文章拆解了 biased reference counting 带来的 cache line 抖动问题,并给出三条判断是否值得迁移的具体标准。

上周为了一个数据清洗的活,我把公司那台 32 vCPU 的机器翻出来折腾了两天。任务很无聊:读 4000 万行 JSON Lines,正则提取几个字段,然后按 key 聚合计数。单线程 Python 跑完 11 分 20 秒,我早就想试试 3.13 那个 free-threaded 版本——就是大家嘴里说的「去掉 GIL」。折腾完的结论先放这儿:换到 free-threaded 之后我这个任务反而更慢了,多线程也没干过多进程,而且慢得不是一点点。

图片

先把环境搞清楚,这一步就劝退了一半人

Windows 用户在 python.org 下载 3.13 安装包的时候,第一个界面往下拉会看到一个「Free-threaded binaries (experimental)」的勾选框,勾上装完就会有 python3.13t.exe。Linux 上我是用 conda-forge 的 python-freethreading 包:conda create -n ntg -c conda-forge python-freethreading=3.13,装完之后 which python 出来的那一堆文件里,只有名字以 t 结尾的那个才是真正关掉 GIL 的解释器,很容易拿错。

装完第一件事,跑这句:

python -c "import sys; print(sys._is_gil_enabled(), sys.version)"

图片

free-threaded build 会打印 False。如果打印 True,要么你用的是普通版本,要么你设了环境变量 PYTHON_GIL=1 把 GIL 强行打开了——这个变量在 free-threaded build 上是可以反悔的,调试的时候挺方便,但也很容易忘掉自己设过,然后对着数字发呆半小时。

真正烦人的是第三方包。free-threaded 的 ABI tag 是 cp313t,pip debug --verbose 能看到一堆带 t 的 tag。numpy 从 2.1 开始提供 free-threaded wheel,pandas、scipy 也跟上了,但我当时要用的一个做文本预处理的库没有,pip 直接开始本地编译,编到一半报错退出来。最后我是在普通解释器里先把数据预处理完、存成 parquet,再切到 free-threaded 里跑核心计算。这一点如果你依赖链比较长,先花十分钟查清楚,别像我一样白等。

数据是真的不好看

图片

测试机是 32 vCPU 的云主机,AMD EPYC,Python 3.13.0。数据是 4000 万行 JSON Lines,平均每行 380 到 420 字节,总共约 15.6GB,放在本地 NVMe 上。正则我提前 re.compile 好了,聚合用普通 dict。

四组数字:普通 GIL 版本单线程,11 分 20 秒;普通 GIL 版本用 ProcessPoolExecutor(max_workers=16),1 分 52 秒;free-threaded 版本单线程,14 分 55 秒;free-threaded 版本用 ThreadPoolExecutor(max_workers=16),2 分 41 秒。

free-threaded 单线程慢 31%,这个我有心理准备,官方文档里明说了有性能代价。但多线程比多进程慢了 43%,这就有点难受了。我又加了个 max_workers=32 的对照组,2 分 33 秒,几乎没变化,说明瓶颈根本不在线程数量上。后来又试了 max_workers=8,3 分 05 秒,从 8 加到 16 只快了 13%,扩展性曲线已经很平了。

慢在哪儿:不是你算法的问题,是引用计数

图片

我一开始怀疑是正则。把正则那步全删掉、只做 json.loads 和计数,加速比稍微好一点,但还是远达不到 16 倍线性。后来我把聚合的 dict 换成预分配的 array,加速比从大约 6.0 提到 9.3,这才确认了第一层原因:dict 的并发写竞争。

第二层原因更隐蔽,也是我觉得大部分人没意识到的地方。free-threaded CPython 用的是 biased reference counting:对象被创建它的那个线程访问时是普通操作,一旦被别的线程碰到,就得升级成原子操作,而那个对象的引用计数所在 cache line 会在两个核心之间来回弹。你的代码里每 list.append 一次、每 dict[k] = v 一次,都在干这个活。我拿 perf stat 看了一下,cache-miss 率比 GIL 版本高了大概 2.6 倍。

所以有个反直觉的结论:free-threaded 对「少量大对象加原地修改」友好,对「海量小对象加频繁增删」极其不友好。我这个任务恰好是后者,4000 万行每行都产生一堆临时字符串和 dict,全是小对象,生命周期还短。如果你的场景是操作一个 8GB 的 numpy 数组分块做原地运算,情况会完全不同,那个场景下它确实能接近线性扩展。

还有个细节容易被忽略:free-threaded build 里的 GC 也改了,多线程下的循环垃圾回收策略比 GIL 版本保守。我那个脚本跑的时候 gc.collect() 的耗时占比从 1.8% 涨到了 4.7%,不算致命但也不是零。如果你代码里对象创建量大,这块的隐形成本得算进去。

图片

我现在的看法,可能跟主流不太一样

网上讨论 free-threading 的文章,基本都在比「我这有个 CPU 密集任务,加速了多少倍」。我觉得这个比法意义不大。你的任务如果真的那么纯,ProcessPoolExecutor 早就能解决,多进程唯一的痛点是数据要序列化传过去——那你就用共享内存或者 mmap 文件,麻烦,但是一次性成本,写一次就完了,比调试并发 bug 便宜得多。

真正改变游戏规则的是另一件事,也是我这次踩到的:GIL 一直在替你的代码兜底,而你可能不知道。

图片

我原来的多进程版本里,聚合是在每个子进程里做局部 dict,最后合并。改成多线程之后,我想着共享内存多爽,直接往一个大 dict 里写。第一遍跑出来的计数总和比单线程少了 0.3%,第二遍多了 0.1%,我还以为是正则的问题。盯着那段 d[k] = d.get(k, 0) + 1 看了半天才反应过来——这就是教科书上的 read-modify-write 竞态,以前它不出问题纯粹是因为 GIL 让这一行看起来是原子的。换成 collections.Counter 也一样不安全,Counter 内部还是 dict,+= 照样不是原子操作。

所以你如果打算迁移,第一件事不是跑 benchmark,是把所有跨线程共享的可变状态找出来,加锁或者换结构。这个工作量的体感,跟你把一个单线程项目改成多进程差不多,甚至更麻烦,因为多进程的时候你被操作系统强制隔离了,多线程是你自己骗自己,能跑通不代表是对的。

什么时候值得用 free-threaded?我现在的判断标准是三条:一,你有跨线程共享的大块内存(几百 MB 以上)需要原地改,且拷贝成本超过并行收益;二,你的对象数量少、创建销毁不要太频繁,最好数据是预分配的数组或缓冲区;三,你的依赖链里没有缺 cp313t wheel 的库。三条都满足才值得折腾,否则继续用多进程,别为了新东西给自己找活。

Python 3.14 里 PEP 779 把 free-threading 从实验状态推到了正式支持阶段,单线程性能损失也比 3.13 小了一些。但我觉得短期内它的定位不会变——它是给框架作者和科学计算的人用的,不是给你写 CRUD 用的。至少我那个数据清洗脚本,最后还是老老实实改回了 ProcessPoolExecutor,一晚上跑完,省心。

🏷️ 标签: