Python 3.14 自由线程实战:拆掉 GIL 之后,收益与代价分别有多大

2026-10-16 0 421

去年有个数据团队找我帮忙看一个图像批处理服务。每个请求送进来一批图片,要做缩放、色彩变换、外加打水印,纯 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 曾经是一张隐形的网,让很多并发问题显示不出来。自由线程把这网撤了,代码里原有的隐患立刻暴露。

实际能做的几件事:

  1. 扫出所有模块级可变变量(global 修改的、模块级的 dict / list / 计数器),逐个确认有无并发写
  2. 对类实例上的可变属性,凡是”多线程可能同时读写的”,都加锁
  3. 能改成不可变对象的尽量改(比如用 frozenset 代替 set,用 tuple 代替 list)
  4. 能用现成并发安全容器的用现成的(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 曾经是一张保护网,撤掉之后,有些责任需要代码自己扛起来。

如果现在手头有项目想试,我建议的第一步是:装一个自由线程版本,把现有代码跑一遍基准测试。不用急着改,先看数据。数字能告诉你值不值得继续往下走。

Python 3.14 自由线程实战:拆掉 GIL 之后,收益与代价分别有多大
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python Python 3.14 自由线程实战:拆掉 GIL 之后,收益与代价分别有多大 https://www.taomawang.com/server/python/2946.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务