Skip to content

Python 线程能不能用上多核 ​

4 个线程各算一份同样的任务,4 核的机器上用时是单线程的 3.96 倍:四个线程合起来只干了一个核的活。换一个解释器构建,同一份代码、同样 4 个线程,用时是 1.17 倍,相当于 3.43 个核。再给它加一个环境变量,又回到 3.46 倍。

「Python 多线程用不上多核」说的是 CPython 的全局解释器锁(GIL)。从 Python 3.13 起,CPython 提供了去掉 GIL 的 free-threaded 构建,3.14 把它列为官方支持(PEP 779),但它不是默认构建:从 python.org 或官方 Docker 镜像装到的 python 仍然带 GIL。所以这个问题现在的答案取决于用的是哪个构建,以及任务是哪一类。

Java 服务端工程师写 Python 时,容易按 JVM 的直觉去开线程池。本文用同一份代码在 CPython 3.14.8 的两种构建上对照,容器限 4 个 CPU。实验见文末。

一、三种运行方式的对照 ​

任务是一段纯 Python 的整数循环。「相当于几个核」= 线程数 × 单线程完成一份的用时 ÷ 多线程总用时。

默认构建,4 个线程1.01 个核free-threaded 构建,4 个线程3.43 个核free-threaded + PYTHON_GIL=11.16 个核默认构建,4 个进程3.71 个核01234核
图 1 · CPython 3.14.8,容器限 4 个 CPU,4 个线程(或进程)各完成一份同样的纯 Python 计算。默认构建的 4 个线程合起来只相当于 1.01 个核;free-threaded 构建是 3.43 个核;同一个 free-threaded 二进制用 PYTHON_GIL=1 重新启用 GIL,又回到 1.16。默认构建要用上多核,靠的是多进程
1 个线程完成 1 份2 个线程各 1 份4 个线程各 1 份
默认构建121ms1.02 个核1.01 个核
free-threaded 构建143ms1.96 个核3.43 个核
free-threaded 构建,PYTHON_GIL=1142ms1.08 个核1.16 个核

默认构建上,线程数从 1 加到 4,总用时跟着变成 4 倍,吞吐量没有变化。free-threaded 构建上 4 个线程接近 4 个核。第三行是同一个 free-threaded 二进制,只用环境变量把 GIL 重新打开,并行度立刻消失,说明差别确实来自 GIL,而不是两个构建的其他差异。

单线程一列还有一个信息:free-threaded 构建跑同样的代码慢了约 17%(143ms 对 121ms)。去掉全局锁之后,引用计数和内置容器要用更细粒度的同步来保护,单线程也要付这个成本。官方文档给的数字是 pyperformance 基准上平均慢 1%—8%,视平台而定;这里的 17% 只代表这个整数循环。

二、GIL 管的是什么 ​

默认构建(有 GIL):纯计算线程 1线程 2线程 3轮流执行free-threaded 构建:纯计算线程 1线程 2线程 3同时执行,约三分之一的时间完成持有 GIL,在执行等 GIL并行执行
图 2 · 有 GIL 时,同一时刻只有持有 GIL 的线程在执行 Python 字节码,其余线程在等;线程阻塞在 I/O 上时会释放 GIL,所以 I/O 密集的任务不受影响。free-threaded 构建去掉了这把全局锁,线程可以真正同时执行;以前靠「同一时刻只有一个线程在执行」才没出错的代码,现在会出错

GIL 保证同一时刻只有一个线程在执行 Python 字节码。它保护的是解释器自己的数据结构(引用计数、对象的内部状态),不是你的程序逻辑。

线程在不执行字节码的时候会释放 GIL:阻塞在网络、磁盘、time.sleep 上,或者进入了主动释放 GIL 的 C 扩展代码。所以任务类型决定了线程有没有用:

任务默认构建上开线程
等待 I/O(HTTP 调用、数据库查询)有用。实测 4 个线程各阻塞 200ms,总用时是 1 个线程的 1.00 倍
纯 Python 计算没用,上表第一行
调用释放 GIL 的扩展(NumPy 的大数组运算、压缩、哈希)可能有用,取决于具体函数;本文没有验证

Web 服务多数时间在等 I/O,所以默认构建下的线程池和 asyncio 一直够用。问题出在请求里夹着一段纯 Python 的重计算(大 JSON 的序列化、复杂的模板渲染、数据清洗):它持有 GIL 的那段时间,同一进程里其他请求线程都在等。

三、默认构建上怎么用多核 ​

用多进程。每个进程有自己的解释器和自己的 GIL。实测默认构建上 4 个进程各完成一份计算,相当于 3.71 个核。

python
from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(cpu_task, inputs))

代价是进程之间不共享内存:参数和返回值要序列化后在进程间传递,大对象来回复制的开销可能超过计算本身;进程启动比线程慢,内存占用是每个进程一份。Web 服务通常直接在部署层面做这件事:起多个工作进程(Gunicorn、uvicorn 的 --workers),由操作系统或前面的负载均衡分发请求。在容器里,工作进程数要按 CPU 限额而不是宿主机核数来定,原因见容器不是轻量虚拟机。

四、去掉 GIL 之后,线程安全要自己负责 ​

4 个线程各对同一个计数器做 20 万次 counter += 1,不加锁:

结果
默认构建正好 800000
free-threaded 构建少于 800000
free-threaded 构建,PYTHON_GIL=1正好 800000
三种方式,加 threading.Lock800000

counter += 1 是读、加、写三步。默认构建上这次没有丢失更新,是因为当前版本切换线程的时机没有落在这三步中间,这不是语言的保证,换一种写法或者换一个版本就可能不成立。free-threaded 构建上线程真正同时执行,丢失更新立刻出现。

内置容器自身的操作仍然是安全的:4 个线程向同一个 list 各 append 10 万次,三种方式下长度都是 400000。free-threaded 构建用每个对象上的锁保证单个操作不会破坏容器。但「单个操作安全」不等于「一组操作安全」,先检查再修改(if key not in d: d[key] = …)、读出来改完再写回,都要自己加锁。这和 Java 里 ConcurrentHashMap 的单个方法线程安全、组合起来不安全是一回事,见 volatile、CAS 与 LongAdder。

很多 Python 代码从来没有在真正并行的环境里跑过。迁移到 free-threaded 构建时,以前依赖「同一时刻只有一个线程」的地方都要重新检查:模块级的缓存字典、惰性初始化的单例、不加锁的计数器。哪些对象其实是共享的,常常不明显,见函数默认参数里的列表,为什么越用越长。

五、现在该不该用 free-threaded 构建 ​

几件需要先确认的事(依据 Python 3.14 的官方文档):

  • 它是单独的构建。 可执行文件通常叫 python3.14t。用 python -VV 看输出里有没有 free-threading build,或者在代码里用 sys._is_gil_enabled() 判断当前是否真的没有 GIL。
  • C 扩展要显式声明支持。 导入一个没有声明支持 free threading 的扩展模块时,解释器会自动重新启用 GIL 并打印警告,整个进程回到第一节第三行的状态。依赖里只要有一个这样的包,就得不到并行。上线前在目标环境里检查 sys._is_gil_enabled()。
  • 单线程有开销,内存占用更高。 对已经用多进程部署、每个进程主要在等 I/O 的服务,换构建未必有收益。
  • 迭代器等对象不能跨线程共用。 文档列出了已知的限制。

适合尝试的情况:进程内有大量需要共享的数据(多进程要各复制一份),又有可以并行的纯 Python 计算。此时线程加共享内存比多进程加序列化更合适,前提是依赖都支持,并且做过并发测试。

多数服务目前的做法不需要变:I/O 用线程池或 asyncio,CPU 密集的部分用多进程或者交给释放 GIL 的原生库。asyncio 里任务的失败、取消与超时,见 asyncio.gather 里一个任务失败了,其他任务还在跑。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权