去年有个数据团队找我帮忙看一个图像批处理服务。每个请求送进来一批图片,要做缩放、色彩变换、外加打水印,纯 CPU 活。当时部署方案是 4 个进程,每个进程串行处理请求,勉强能把 4 核榨干。但进程间传大对象太慢,一个进程挂了整批任务都要重试,运维成本很高。
当时有人提过一嘴:如果 GIL 没了,是不是能用线程池做多核并行?那会儿这个特性还在实验阶段,装了也没法上生产。就这么搁置了。
今年 Python 3.14 把自由线程(free-threaded build)从”实验性”提升为”正式支持”,那个项目重新拿出来评估了一遍。这篇文章就是这次迁移的完整记录——基准数据、迁移过程、踩过的坑,以及最后哪些地方用上了、哪些地方放弃了。
先把三个概念分清楚
GIL 这个词被讨论了十几年,很多时候混在一起谈,越聊越乱。先把三个层次的问题拆开。
第一层,GIL 到底是什么。它是一把互斥锁,保证同一时刻只有一个线程在执行 Python 字节码。所谓字节码级别,就是 a += b 这种表达式会编译成好几条字节码,线程切换可以发生在这几条指令之间。所以”有 GIL 多线程就安全”这个说法本身就不准确——GIL 保护的是解释器内部状态,不是你的业务数据。
第二层,GIL 挡住了什么。它挡住的是 Python 字节码的并行。用 threading 跑 CPU 密集任务,多个线程轮流抢锁,看起来是并发,实际几乎串行。但网络请求、numpy 的大部分运算、PIL 的像素操作、正则匹配等 C 扩展在进入 C 函数时会主动释放 GIL,这时候多线程是真的能并行的。所以”Python 多线程没用”这个说法也不准确。
第三层,自由线程是什么。它不等于”去掉所有锁”。CPython 的做法是把原来那把全局大锁拆成很多把细粒度的锁,同时用偏置引用计数、延迟引用计数等技术把引用计数的成本降回来。最终效果是——多个线程可以真正同时执行 Python 字节码。
理清这三层之后,就可以回答一个关键问题:自由线程解决的是”纯 Python 代码的并行”,而不是”所有场景下多线程更快”。如果你的服务瓶颈在 IO 或者已经在用 C 扩展做重活,自由线程带来的收益会很有限。
第一步:装一个自由线程版本
自由线程版本和普通版本是分开安装的,官方把它标记成一个”t”后缀。pyenv 和官方安装器都支持。
# 通过 pyenv 安装
pyenv install 3.14.0t
# 检查当前解释器是不是自由线程版
python3.14t -c "import sys; print(sys._is_gil_enabled())"
# 输出 False 才说明 GIL 已关闭
有两点要注意。
输出 True 说明装错了版本。同样的 python3.14 命令,如果装的是普通版也会返回 True。一定要确认安装的确实是带 t 后缀的那个包。
自由线程版本还可以通过环境变量临时切换 GIL。PYTHON_GIL=0 强制关闭 GIL,PYTHON_GIL=1 强制开启。这在做 A/B 对比测试的时候很好用,同一份代码在两种模式下跑一遍,能直接看出差异。
第二步:先写基准测试,别信直觉
迁移之前先测量。用那个图像服务里最典型的计算段写个最小测试:
import time
import threading
def cpu_work(n):
"""模拟纯 Python 的 CPU 密集计算。"""
total = 0
for i in range(n):
total += i * i % 7
return total
def run_serial(n, iterations):
start = time.perf_counter()
for _ in range(iterations):
cpu_work(n)
return time.perf_counter() - start
def run_threaded(n, iterations, workers):
start = time.perf_counter()
threads = []
per = iterations // workers
for _ in range(workers):
t = threading.Thread(target=cpu_work, args=(n * per,))
threads.append(t)
t.start()
for t in threads:
t.join()
return time.perf_counter() - start
if __name__ == "__main__":
N = 1_000_000
ITER = 8
WORKERS = 8
serial = run_serial(N, ITER)
print(f"串行: {serial:.2f}s")
threaded = run_threaded(N, ITER, WORKERS)
print(f"{WORKERS} 线程: {threaded:.2f}s")
print(f"加速比: {serial / threaded:.2f}x")
在普通 Python 3.14 上跑:
串行: 6.42s
8 线程: 6.87s
加速比: 0.93x
多线程慢了。因为线程切换有一定成本,但完全没有并行收益。
在自由线程版本上跑同样的代码:
串行: 7.01s
8 线程: 1.09s
加速比: 6.43x
串行时间多了约 9%,但 8 线程的加速比达到了 6.4 倍,接近理论上限。
这两个数字要一起看。不能只看加速比 6.4 倍就兴奋,单线程变慢了 9% 也是真实成本。对于纯 IO 密集的服务,加速比用不上,只有损失。
坑一:单线程性能下降不是小数
上面这 9% 不是我们代码写得不好,是设计代价。
自由线程版本里每个对象都有引用计数。为了让多个线程能安全地更新这个计数,每次增减都变成了原子操作——成本比原来一条 CPU 指令高不少。CPython 3.14 用了偏置引用计数(biased reference counting)来缓解:如果对象只被一个线程访问,就走快速路径,成本接近普通计数;一旦被多个线程访问,才切换到慢速的原子路径。
但即便如此,官方给出的单线程性能损失是 5% 到 10%。这个数字放在”服务署一个进程处理一个请求”的场景下,就是纯亏。
我们的图像服务是多线程处理一批任务,所以损失可以被并行收益覆盖。但如果你的服务本身就是单线程模型,迁移之前一定要想清楚这一点。
坑二:C 扩展必须重编译
这个坑是我们在迁移过程中卡得最久的。自由线程版本使用不同的 ABI,用普通版本编译的 C 扩展在它下面加载会报错:
ImportError: module does not support free-threaded build
或者更糟——能加载但运行出错,比如在某些深层的结构体访问上行为不一致。
解决办法是:所有依赖的 C 扩展都必须有自由线程版本的 wheel。目前主流库已经跟上:numpy、pandas、Pillow、cryptography 都发布了对应的版本。SQLAlchemy 的部分 C 扩展还在适配。一些老一点或者小众的库暂时没有。
迁移之前可以先跑一个 dry-run,看看有多少依赖会落在源码编译路径上:
pip install -r requirements.txt --dry-run -v 2>&1 | grep -i "free-threaded"
如果一个依赖需要从源码编译安装,说明它可能还没有自由线程版本的 wheel,得评估能不能替换。
我们那次花了整整两天才搞定,卡在一个内部维护的图像处理 C 扩展上。它引用了 CPython 的 PyObject 结构体里一个内部字段,自由线程版里这个字段的位置变了,编译直接失败。最后是重写了一段绕过内部的逻辑才通过。
如果你的项目有自研的 C 扩展,迁移前先确认能不能改。
坑三:GIL 时代的安全假设全部失效
这是我认为最危险的坑,因为它不报错,只是结果偶尔不对。
看一个非常普通的例子:
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
在 GIL 时代,self.count += 1 虽然不是原子操作(读、加、写回三步),但因为 GIL 会在字节码之间切换线程,这中间的时间窗口非常小。很多团队就这么用了,跑了好几年也没出问题。
自由线程时代,这个窗口变成了真实的并行时间。8 个线程并发跑 increment 一百万次,最终结果往往不是 8000000,而是五百万到六百万之间的某个数字——取决于运气。
修复很简单:
import threading
class Counter:
def __init__(self):
self.count = 0
self._lock = threading.Lock()
def increment(self):
with self._lock:
self.count += 1
但问题在于,你不可能把几十万行代码一行一行审查过去。GIL 曾经是一张隐形的网,让很多并发问题显示不出来。自由线程把这网撤了,代码里原有的隐患立刻暴露。
实际能做的几件事:
- 扫出所有模块级可变变量(
global修改的、模块级的dict/list/ 计数器),逐个确认有无并发写 - 对类实例上的可变属性,凡是”多线程可能同时读写的”,都加锁
- 能改成不可变对象的尽量改(比如用
frozenset代替set,用tuple代替list) - 能用现成并发安全容器的用现成的(
queue.Queue、collections.deque的部分操作)
有一点容易忽略:容器自身的单个操作在 CPython 里通常是原子的。比如 dict[key] = value、list.append(x)、set.add(x),这些在自由线程版里仍是”看起来”原子的,因为底层的结构有内部锁保护。但复合操作比如 if key not in d: d[key] = compute(),就不是原子的,需要自己加锁。
坑四:内存占用上升,容器限制要重新算
自由线程版本用了不同的内存分配器,加上对象结构本身有变化,整体内存占用会上升。我们那个服务 4 进程改 8 线程之后,实测内存峰值从 380MB 涨到 425MB,多了约 12%。
这个数字要提前算进部署资源里。Kubernetes 上如果 memory limit 是按老版本设的,迁移后可能 OOM。部署前把 limit 提高 20% 是比较稳妥的余量。
最终的迁移形态
我们那个图像服务最后的结构是这样的:
import concurrent.futures
import threading
class ImageProcessor:
def __init__(self):
self.stats = {"processed": 0, "failed": 0}
self._stats_lock = threading.Lock()
def process_batch(self, images):
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(self._process_one, images))
return results
def _process_one(self, img):
try:
# CPU 密集段,不碰共享状态,可以放心并行
result = self._heavy_transform(img)
with self._stats_lock:
self.stats["processed"] += 1
return result
except Exception:
with self._stats_lock:
self.stats["failed"] += 1
raise
几个设计取舍值得说一下。
锁只保护共享状态。_heavy_transform 内部不接触 self.stats,也不需要别的锁,可以放心并行。锁的粒度越小越好,但也不能没有。
用一个锁保护所有统计字段。而不是给每个字段一把锁。统计字段的读写频率和粒度一致,用一个锁更简单,也不会成为瓶颈。
异常路径也走锁。很多人会漏掉这一点——正常路径加锁了,异常路径忘了加,异常风暴的时候计数立刻错乱。
处理一批 64 张小图的实测对比:
多进程(4 进程): 18.2s 内存峰值 380MB
自由线程(8 线程):12.4s 内存峰值 425MB
吞吐提升: +47%
内存增长: +12%
对于一个跑在 8 核机器上的图像服务,用 12% 内存换 47% 吞吐,是划算的。
哪些场景值得迁移
经过这次迁移,我总结了一个初步的判断标准。
值得迁移的情况:
- 有 CPU 密集型需求,需要真正用上多核
- 已经在用多进程方案,但被进程间通信或者状态同步困扰
- 依赖的 C 扩展都能拿到自由线程版本
- 部署环境是 4 核以上
不值得迁移的情况:
- 服务本身是 IO 密集型,多线程在 GIL 时代就已经能加速(网络等待时 GIL 会释放)
- 单进程单线程处理请求,用不上多核
- 有大量依赖还只有普通版本的 C 扩展
- 单个请求处理时间极短(小于 1ms),线程调度成本反而更明显
还有一种两头都部署的方案:如果你的服务里 IO 密集和 CPU 密集两段都很重,可以拆成两个服务——IO 密集段跑普通版本(单线程更快),CPU 密集段跑自由线程版本。这样两段各取所需。
生态现状盘点
自由线程能不能用起来,不取决于 CPython 做得怎么样,取决于生态有没有跟上。目前的状态:
已经支持:
numpy、pandas、Pillow、cryptography都发布了自由线程版本的 wheel- 纯 Python 的框架(Django、Flask、FastAPI 等)理论上可以直接用,但要检查它们内部有没有依赖 GIL 的假设,尤其是使用
threading和缓存的地方
正在跟进:
SQLAlchemy的部分 C 扩展还在适配- 一些 ORM 和序列化库的 C 加速模块
lxml等依赖较多 C 代码的库
短期内不会支持:
- 一些内部维护的 C 扩展,没有预算去适配
- 一些已停止维护的第三方库
迁移之前可以先写个脚本,把依赖装一遍,看看哪些会走源码编译路径。落在编译路径上的要么能替换,要么就得自己动手改。
写在最后
自由线程是 Python 这些年最基础的一次变化之一。从 3.13 的实验性到 3.14 的正式支持,它终于能进生产环境评估了。
但”能进”不等于”该进”。这次迁移给我印象最深的一点是:GIL 走了之后,代码里的并发问题没有变多,只是之前被遮住的那些问题,现在全部显形了。那些靠”反正有 GIL 兜着”混过去的写法,那些没有加锁的复合操作,全部会以”结果偶尔不对”的方式暴露出来。
反过来说,如果你的团队本来就有明确的并发编码规范、共享状态都加锁、尽量用不可变对象,那迁移的痛感会小很多。GIL 曾经是一张保护网,撤掉之后,有些责任需要代码自己扛起来。
如果现在手头有项目想试,我建议的第一步是:装一个自由线程版本,把现有代码跑一遍基准测试。不用急着改,先看数据。数字能告诉你值不值得继续往下走。

