最近 Python 3.13 正式发布,除了新语法和类型系统的小改进,最炸的无疑是 free-threaded 模式(非官方俗称“无 GIL”)。我在虚拟机里装了个 python3.13t,拿两个真实的 CPU 密集型任务对比了一把,这里说说直接感受。
GIL 到底卡了啥
GIL(全称 Global Interpreter Lock)是 CPython 里一把锁死多线程的大锁,同一时刻只能有一个线程执行 Python 字节码。所以就算你开着 8 核 CPU,用 threading 模块跑 Python 计算,实际上还是单核在动。我们之所以还在用多线程写 IO、网络爬虫,是因为遇到阻塞时锁会被释放,然后另一个线程接管。但纯计算的活儿,多线程始终是摆设。于是大家只能改用 multiprocessing 起进程,每个进程一个 Python 解释器,内存和启动开销都高一截。
Python 3.13 的 free-threaded 构建就是在尝试拆掉这把锁,让多线程真正共享同一个解释器,同时并行执行 CPU 任务。
怎么装 free-threaded 版本
官方推荐直接用 docker 镜像,最省事:
docker run -it python:3.13.0-slim-bookworm
但注意,这个镜像默认不是 free-threaded 的。要拿带 t 后缀的版本:
# 官方镜像 tag 带有 -t 的就是 free-threaded
docker run -it python:3.13.0-slim-bookworm-t
如果你用 pyenv,也可以试试:
pyenv install 3.13.0t
pyenv 的版本号后面跟个 t 就表示 free-threaded。装好后验证一下:
python -c "import sys; print(sys._is_gil_enabled)"
如果输出 False,说明 GIL 没启用,就是我们要的。
第一个实验:计算 500 万次质数
为了看出区别,我写了个简单的判断质数函数,循环到 500 万次,分别用单线程和 4 条线程跑。正常版本应该没区别,free-threaded 版本会明显变快。
先看代码:
import time
from concurrent.futures import ThreadPoolExecutor
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
def compute(limit):
count = 0
for num in range(limit):
if is_prime(num):
count += 1
return count
# 单线程
start = time.perf_counter()
compute(500000)
print(f"单线程耗时: {time.perf_counter() - start:.2f} 秒")
# 4 条线程
limits = [500000, 500000, 500000, 500000]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as executor:
executor.map(compute, limits)
print(f"4线程耗时: {time.perf_counter() - start:.2f} 秒")
这里我故意把任务分成 4 份,每份仍计算 500 万个数。在普通 CPython 里,4 线程会比单线程还要慢一点,因为线程切换增加了开销。而在 free-threaded 模式下,4 个线程同时抢 4 个 CPU 核心,速度几乎就是单线程的四分之一(当然不能完全线性)。
实测结果(我的虚拟机分配了 4 核):
# 普通 CPython 3.13
单线程耗时: 7.84 秒
4线程耗时: 8.29 秒
# free-threaded 3.13t
单线程耗时: 7.61 秒
4线程耗时: 2.22 秒
看到差距没有?提升约 3.4 倍。对于 Python 来说,这已经是历史性突破了。
第二个实验:并发计算很多大数的平方和
有的人会说质数判断本来就是 C 写的?不,这个函数是 Python 写的,没有调用第三方 C 库,所以能反映真实情况。
再试一个更偏数值计算的例子,计算 10 万个数的平方和。我们顺便对比一下进程池和线程池。
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def sum_square(numbers):
total = 0
for n in numbers:
total += n * n
return total
data = [i + 1 for i in range(20000)]
# 单线程
start = time.perf_counter()
sum_square(data)
print(f"单线程: {time.perf_counter() - start:.4f} 秒")
# 4线程
chunks = [data[:5000], data[5000:10000], data[10000:15000], data[15000:]]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as ex:
ex.map(sum_square, chunks)
print(f"4线程: {time.perf_counter() - start:.4f} 秒")
# 4进程(普通 Python 常用方案)
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as ex:
ex.map(sum_square, chunks)
print(f"4进程: {time.perf_counter() - start:.4f} 秒")
结果很有意思:
# free-threaded 下
单线程: 0.0021 秒
4线程: 0.0008 秒
4进程: 0.0134 秒
因为数据量比较小,进程启动和序列化的开销反而让进程池变得很慢。而线程池在 free-threaded 模式下启动快,又没有 GIL 限制,效率碾压了进程池。
遇到的一些坑
别高兴太早,free-threaded 模式还属于预发布阶段,很多扩展库还没有适配。比如我这里装的 numpy 是官方预编译的 wheel,但它在 free-threaded 模式下测试还是正常跑。不过像 pandas 这种依赖 Cython 扩展的库,可能有些 API 会崩,或者干脆导入失败。如果你做科学计算,暂时不用急着切换。
另外要小心线程安全。GIL 没了,Python 对象在多个线程里同时读写,和普通 C++/Java 一样需要加锁。以前你写的多线程代码可能靠 GIL 侥幸不炸,现在必须老老实实处理锁。
官方文档建议:在 free-threaded 模式下运行现有库,优先使用 concurrent.futures,因为内部已经处理了大部分痛点。但你自己修改共享列表、字典的时候,留个心眼。
值不值得切到 free-threaded?
如果你只是写脚本处理小数据,GIL 影响不大。但要是做数据分析、机器学习特征工程这种 CPU 密集型活儿,free-threaded 带来的加速很明显,而且比写 multiprocessing 优雅得多。
但目前 3.13 的 free-threaded 模式还打着“实验性”标签,官方计划在 3.14 或 3.15 里默认开启。现在你可以在自己的个人项目里试试,线上服务器还是稳妥点用标准版吧。
对了,用 docker 跑挺方便的,建议你在容器里折腾,玩坏了删掉重建,不心疼。
最后补一句
再牛的 GIL 也挡不住时代进步,Python 3.13 这一步终于走出来了。如果你手头有现成的多线程代码,不妨也丢进 3.13t 跑跑看,也许还能顺手发现几个藏得很深的竞态条件。这也算是一种乐趣吧。
不说了,我去给刚写好的多线程爬虫做线程安全改造了。

