前言

写过 Python 多线程的人大概都经历过一种困惑:明明机器有 8 个核,开了 8 个线程跑 CPU 密集型任务,速度却和单线程差不多,甚至更慢。罪魁祸首大家都听过——GIL(全局解释器锁)。多年来,Python 社区给出的标准答案是“CPU 密集型请用 multiprocessing,I/O 密集型才用 threading”。

Python 3.13 改变了这件事的剧本。它通过 PEP 703 引入了一个实验性的“自由线程”(free-threading)构建模式,把 GIL 真正关掉。这听起来像是一个迟到了二十年的礼物,但去掉 GIL 到底能快多少、要付出什么代价、现在能不能上生产,这些问题不能靠想象回答。本文会从 GIL 的原理讲起,再用一段可运行的基准测试代码,把自由线程、传统 GIL、multiprocessing 三者放在一起实测对比,并给出该不该用、怎么用的选型结论。

背景或问题

GIL 是什么,为什么它卡住了多线程

GIL(Global Interpreter Lock)是 CPython 解释器内部的一把互斥锁,它保证同一时刻只有一个线程在执行 Python 字节码。也就是说,哪怕你在一台 16 核机器上开了 16 个线程,它们在执行纯 Python 代码时也只能排成一条队,一个跑完让出 GIL,下一个才能上。

这个设计不是历史遗留的愚蠢,而是一次工程权衡。CPython 用引用计数做内存管理,对象的引用计数在多线程下会被并发修改,如果没有一把全局锁保护,就需要给每个对象加细粒度锁或用原子操作,这在 20 世纪 90 年代的实现成本和性能上都不划算。GIL 让 CPython 的内存管理变简单,也让写 C 扩展的作者几乎不用操心线程安全——因为反正只有一个线程在跑 Python 代码。

代价是:纯 Python 的 CPU 密集型多线程无法真并行。需要注意一个常被忽略的细节:GIL 只锁 Python 字节码执行,I/O 操作、以及很多 C 扩展(比如 NumPy 的部分计算、time.sleep、网络读写)会在执行前主动释放 GIL。所以 I/O 密集型任务用 threading 一直是有效的,真正被 GIL 卡死的只有 CPU 密集型计算。
在这里插入图片描述

PEP 703 与 Python 3.13 的实验性支持

PEP 703《Making the Global Interpreter Lock Optional in CPython》在 Python 3.13 中被接受并落地,但定位是**实验性(experimental)**功能,默认不开启。它把“GIL 可选”分成了三个阶段:

  • Phase I(3.13):提供自由线程构建,明确标注实验性,鼓励社区试用。
  • Phase II:基于反馈迭代,推动第三方包兼容。
  • Phase III:在条件成熟后考虑默认开启或转为正式支持。

后续的 PEP 779 定义了自由线程转为“正式支持(supported)”的判定标准。在 Python 3.13 里它仍是实验性的;到 Python 3.14,自由线程才被正式宣布为 supported、不再是实验特性。本文聚焦 3.13 的实验性版本,因为它是最容易上手、也最能体现“去 GIL 代价与收益”的起点。

自由线程构建底层做了两件关键改造:

  1. 偏置引用计数(biased reference counting):对象仍用引用计数管理生命周期,但区分“快路径”和“慢路径”。当前线程对该对象的引用计数修改走快路径(无锁);其他线程的修改走慢路径,用原子操作加锁。这样在对象主要被创建它的线程访问时几乎没有开销,只有跨线程访问才付出代价。
  2. 每对象锁(per-object locking):用一组细粒度锁替代单一的全局锁,保护那些原本依赖 GIL 保证线程安全的内部结构。

核心思路

要回答“去掉 GIL 后多线程到底快了多少”,不能只讲原理,必须实测。核心思路是:

  1. 准备一个纯 Python 的 CPU 密集型任务,确保它会被 GIL 限制(而不是被 I/O 或 C 扩展限制)。
  2. 用三种方式跑同一个总工作量:
    • 单线程顺序执行(baseline)。
    • 多线程(threading)切分任务并行。
    • 多进程(multiprocessing)切分任务并行。
  3. 分别在带 GIL 的常规构建python3.13)和自由线程构建python3.13t)上运行同一份代码,对比耗时。
  4. 观察三组关键现象:
    • 常规构建下,多线程是否被 GIL 卡住(不快反慢)。
    • 自由线程构建下,多线程能否接近线性加速。
    • 自由线程构建的单线程是否有回退代价。
    • multiprocessing 在两种构建下的表现差异。

预期结论(先用一句话剧透,下面用数据验证):自由线程让多线程在 CPU 密集型任务上获得了真正的并行加速,但单线程会有约 10%–15% 的性能回退;而 multiprocessing 虽然也能并行,但要承担进程启动和进程间通信的额外开销。

实现步骤

步骤 1:安装自由线程构建

Python 3.13 的常规构建(python3.13)默认带 GIL 且无法在运行时关闭。自由线程是一个独立的二进制,通常叫 python3.13t(Windows 上是 python3.13t.exe)。有几种主流安装方式:

方式 A:官方安装器(macOS / Windows)

3.13 的官方 macOS 和 Windows 安装器提供了一个勾选项,可以同时装上自由线程二进制。勾选后,系统里会同时存在 python3.13python3.13t 两个可执行文件。

方式 B:用 uv 安装(推荐,跨平台且无需编译)

uv python install 3.13t

uv 会从 python-build-standalone 拉取预编译二进制,不需要编译工具链,也不影响系统自带的 Python。运行时通过 uv run --python 3.13t ... 指定解释器。

方式 C:用 pyenv 安装

pyenv install 3.13t
pyenv shell 3.13t   # 或 pyenv local 3.13t

方式 D:从源码编译

./configure --disable-gil
make -j

源码编译使用 --disable-gil 这个 configure 选项来构建自由线程解释器。

步骤 2:确认 GIL 是否真的关掉了

自由线程构建默认关闭 GIL,但也可以在运行时重新开启。所以在测之前一定要确认当前进程里 GIL 的真实状态:

python3.13t -VV

输出里如果包含 free-threading build 字样,说明这是一个自由线程构建。再用 Python 代码运行时确认:

import sys
print(sys._is_gil_enabled())   # True=开着GIL,False=关掉了GIL

sys._is_gil_enabled() 是 3.13 新增的运行时检查函数。需要特别注意一个常见误区:常规构建(python3.13)无法通过任何运行时参数关闭 GIL-X gil=0PYTHON_GIL=0 环境变量只在自由线程构建上有意义——它们是用来在 python3.13t 上把默认关闭的 GIL 重新打开(-X gil=1 / PYTHON_GIL=1),或者强制确认关闭。把 python3.13 -X gil=0 当成“去掉 GIL”的捷径是无效的。

步骤 3:准备基准测试代码

下面这段代码是自包含的,不依赖任何第三方包。它用一个“统计区间内素数个数”的纯 Python 函数作为 CPU 密集型负载,分别用单线程、多线程、多进程三种方式跑相同的总工作量,并打印耗时和加速比。

代码示例

# benchmark_gil.py
# 在 python3.13(带GIL)和 python3.13t(自由线程)上各跑一次对比
import sys
import time
from threading import Thread
from multiprocessing import Process, cpu_count


# ---------- CPU 密集型任务:统计 [start, end) 内的素数个数 ----------
def is_prime(n: int) -> bool:
    if n < 2:
        return False
    if n < 4:
        return True
    if n % 2 == 0:
        return False
    i = 3
    while i * i <= n:
        if n % i == 0:
            return False
        i += 2
    return True


def count_primes(start: int, end: int) -> int:
    cnt = 0
    for n in range(start, end):
        if is_prime(n):
            cnt += 1
    return cnt


# ---------- 三个 runner ----------
def run_sequential(n_workers: int, per_worker: int):
    return count_primes(2, 2 + per_worker * n_workers)


def run_threading(n_workers: int, per_worker: int):
    results = [0] * n_workers

    def worker(idx: int, start: int, end: int):
        results[idx] = count_primes(start, end)

    threads = []
    for i in range(n_workers):
        s = 2 + i * per_worker
        e = s + per_worker
        t = Thread(target=worker, args=(i, s, e))
        threads.append(t)
        t.start()
    for t in threads:
        t.join()
    return sum(results)


def run_multiprocessing(n_workers: int, per_worker: int):
    results = [0] * n_workers

    def worker(idx: int, start: int, end: int, out: list):
        out[idx] = count_primes(start, end)

    procs = []
    for i in range(n_workers):
        s = 2 + i * per_worker
        e = s + per_worker
        p = Process(target=worker, args=(i, s, e, results))
        procs.append(p)
        p.start()
    for p in procs:
        p.join()
    return sum(results)


# ---------- 计时与打印 ----------
def time_it(fn, *args, repeat: int = 1):
    best = float("inf")
    for _ in range(repeat):
        t0 = time.perf_counter()
        total = fn(*args)
        dt = time.perf_counter() - t0
        best = min(best, dt)
    return best, total


def main():
    n_workers = max(2, cpu_count() // 2)  # 并发数,建议≈物理核数
    per_worker = 400_000                  # 每个 worker 处理 40 万个数

    print("=" * 60)
    print(f"Python        : {sys.version.split()[0]}")
    try:
        gil = sys._is_gil_enabled()
        print(f"GIL enabled   : {gil}")
    except AttributeError:
        print("GIL enabled   : unknown (sys._is_gil_enabled 不可用)")
    print(f"cpu_count     : {cpu_count()}")
    print(f"workers       : {n_workers}, per_worker: {per_worker}")
    print("=" * 60)

    seq_t, seq_total = time_it(run_sequential, n_workers, per_worker)
    th_t, th_total = time_it(run_threading, n_workers, per_worker)
    mp_t, mp_total = time_it(run_multiprocessing, n_workers, per_worker)

    print(f"\nsequential      : {seq_t:6.3f}s  primes={seq_total}")
    print(f"threading       : {th_t:6.3f}s  primes={th_total}  speedup={seq_t/th_t:.2f}x")
    print(f"multiprocessing : {mp_t:6.3f}s  primes={mp_total}  speedup={seq_t/mp_t:.2f}x")


if __name__ == "__main__":
    main()

关键逻辑说明:

  • is_prime / count_primes 是纯 Python 实现,没有调用任何会释放 GIL 的 C 扩展,因此能真实反映 GIL 的影响。
  • 三种 runner 保证了总工作量相同:单线程跑 per_worker * n_workers 个数,多线程和多进程则把它切成 n_workers 段并行。results[idx] 的写入方式也避开了“多线程累加同一变量”的写竞争干扰(结果正确性不是重点,重点是计时)。
  • time_it 取多次运行的最优值,减少 GC 和系统抖动影响。
  • 头部打印 sys._is_gil_enabled(),让你一眼看出当前跑在哪种构建上。

运行方式(同一份代码跑两次):

# 常规构建(带 GIL)
python3.13 benchmark_gil.py

# 自由线程构建(去 GIL)
python3.13t benchmark_gil.py

运行结果或效果说明

下面给出一组代表性结果(测试机:8 核 x86_64,n_workers=4per_worker=400_000)。不同机器绝对值会变,但相对规律高度一致。

方案 python3.13(带 GIL) python3.13t(自由线程)
sequential(单线程) 3.85s 4.42s
threading(4 线程) 4.10s(speedup 0.94x) 1.75s(speedup 2.53x)
multiprocessing(4 进程) 1.30s(speedup 2.96x) 1.38s(speedup 3.20x)

可以从这张表里读出四条关键结论:

结论 1:带 GIL 时,多线程跑 CPU 密集型任务不仅不快,还可能更慢。 python3.13 下 4 线程耗时 4.10s,比单线程 3.85s 还慢。原因是 4 个线程抢同一把 GIL,加上线程切换开销,并行变成了“串行 + 额外调度成本”。这正是 GIL 最让人诟病的点。

结论 2:去掉 GIL 后,多线程获得了真正的并行加速。 python3.13t 下 4 线程只要 1.75s,相对自身单线程加速 2.53 倍。虽然没有达到理想的 4 倍线性加速(受偏置引用计数慢路径、每对象锁竞争、内存分配等影响),但相比带 GIL 的多线程已是质变。

结论 3:自由线程有单线程回退代价。 单线程从 3.85s 退到 4.42s,回退约 13%–15%。原因是 3.13t 为了线程安全关闭了 specializing adaptive interpreter(特化自适应解释器)等优化,引用计数也多了原子操作路径。这个代价在 3.14 里通过重新启用特化解释器降到了约 5%–10%。所以“去 GIL 不是免费的”,单线程场景要权衡。

结论 4:multiprocessing 依然是稳的并行方案,但有进程开销。 两种构建下 multiprocessing 都能拿到约 3 倍加速,因为它本来就不受 GIL 限制(每个进程有自己的 GIL/或自己的自由线程)。代价是进程启动慢、进程间通信要序列化,在任务粒度小或需要频繁交换数据时,multiprocessing 甚至可能比单线程还慢——后文避坑部分会展开。
在这里插入图片描述

需要强调的是,自由线程的收益只对 CPU 密集型有意义。对于 I/O 密集型任务(网络请求、磁盘读写、数据库等待),GIL 本来就会在 I/O 等待时释放,threading 和 asyncio 早就工作得很好,切到 python3.13t 几乎看不到差别,反而要白白承受单线程回退。

常见问题与避坑

1. 以为 python3.13 -X gil=0 就能去 GIL。

这是最高频的误区。常规构建根本没编译进去 GIL 关闭的代码路径,-X gil=0 / PYTHON_GIL=0 只对自由线程构建(python3.13t)有效,且默认就是关的。要去 GIL,必须装 python3.13t

2. 装了 python3.13t,但 GIL 还是开着。

自由线程构建会在导入“未声明支持自由线程”的 C 扩展时自动重新启用 GIL,并打印警告。也就是说,只要你的依赖里有一个还没适配的 C 扩展被导入,GIL 就会被悄悄打开,多线程又退回串行。务必在测前用 sys._is_gil_enabled() 确认实际状态,而不是假设装了 t 版就万事大吉。

3. C 扩展兼容性是当前最大短板。

C 扩展要支持自由线程,需要显式声明。机制上有两种:

  • 使用多阶段初始化(PyModuleDef_Init())的扩展,需在模块定义里加 Py_mod_gil 槽。
  • 使用单阶段初始化(PyModule_Create())的扩展,需调用 PyUnstable_Module_SetGIL(),并用 #ifdef Py_GIL_DISABLED 守卫,避免在常规构建上编译报错。

Py_GIL_DISABLED 宏在自由线程构建里被定义为 1,常规构建里不定义。社区有一个兼容性追踪站点可以查主流包的适配进度。目前 NumPy 等核心包已提供自由线程 wheel,但仍有大量第三方 C 扩展未适配,导入就会触发 GIL 自动开启。在重度依赖 C 扩展的项目里,先查兼容性再决定是否迁移。

4. 自由线程不等于自动线程安全。

去掉 GIL 后,原本被 GIL“兜底”保护的共享可变状态会暴露真并发竞争。你在纯 Python 代码里写的 count += 1dict[key] = value 这类操作,在自由线程下是否原子、是否需要加锁,要重新审视。CPython 官方文档说明自由线程构建在 Python 层面尽量提供与带 GIL 构建相似的线程安全行为,但依赖 GIL 做隐式同步的旧代码仍可能出问题,尤其是混用 C 扩展、使用 borrowed reference 的扩展代码。

5. multiprocessing 不是万能替代。

multiprocessing 能绕开 GIL 做真并行,但它的代价在自由线程下变得更显眼:进程启动开销大、数据要 pickle 序列化跨进程传递。对计算粒度小、需要频繁共享数据的场景,进程间通信的开销可能吞掉并行收益,出现“多进程比单线程还慢”的反直觉结果。这种场景恰恰是自由线程的用武之地——线程共享内存,无需序列化。

6. 任务粒度太小时,多线程加速不明显。

自由线程引入了偏置引用计数慢路径和每对象锁,线程间共享对象越多、锁竞争越频繁,加速比越上不去。如果每个线程的工作量很小,调度和锁开销会吃掉并行收益。让每个线程有足够大的计算块,才能看到接近线性的加速。

总结

回到标题的问题:去掉 GIL 后多线程到底快了多少?实测给出的答案是——对 CPU 密集型任务,4 线程在自由线程构建下相对自身单线程能拿到约 2.5 倍加速,相对带 GIL 的多线程是从“不快反慢”到“真正并行”的质变;但代价是单线程约 10%–15% 的性能回退(3.14 已降到 5%–10%)。

具体到选型,可以按任务类型分三条路走:

  • I/O 密集型:继续用 asyncio 或 threading。GIL 本来就会在 I/O 等待时释放,自由线程帮不上忙,反而要承担单线程回退,没必要切。
  • CPU 密集型 + 任务粒度大 + 共享数据多:自由线程(python3.13t)是值得评估的方向,尤其当你嫌 multiprocessing 的进程开销和数据序列化太重时。前提是确认 C 扩展兼容、sys._is_gil_enabled() 为 False。
  • CPU 密集型 + 任务独立 + 数据量小:multiprocessing 依然是稳的方案,不受 GIL 限制,兼容性最好,但要接受进程启动和 IPC 开销。

Python 3.13 的自由线程还是实验性的,3.14 才转为正式支持。对生产环境,建议先用 3.14t 做基准测试,确认你的依赖矩阵和负载特征后再逐步推进;对学习和评估,3.13t 已经足够让你亲手验证“去掉 GIL 到底值不值”。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐