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 的整数循环。「相当于几个核」= 线程数 × 单线程完成一份的用时 ÷ 多线程总用时。
PYTHON_GIL=1 重新启用 GIL,又回到 1.16。默认构建要用上多核,靠的是多进程| 1 个线程完成 1 份 | 2 个线程各 1 份 | 4 个线程各 1 份 | |
|---|---|---|---|
| 默认构建 | 121ms | 1.02 个核 | 1.01 个核 |
| free-threaded 构建 | 143ms | 1.96 个核 | 3.43 个核 |
free-threaded 构建,PYTHON_GIL=1 | 142ms | 1.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 保证同一时刻只有一个线程在执行 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 个核。
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.Lock | 800000 |
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 里一个任务失败了,其他任务还在跑。
配套实验
- codesphere-labs/engineering/python-gil-and-free-threading:CPython 3.14.8 默认构建、free-threaded 构建、free-threaded 构建加
PYTHON_GIL=1三种方式下,1、2、4 个线程的纯 Python 计算、阻塞等待、进程池、不加锁与加锁的计数、list 追加(验证记录)
参考资料
- Python 3.14 文档:Python support for free threading(识别方式、
PYTHON_GIL、扩展模块、线程安全、已知限制与单线程开销) - What's New In Python 3.14(PEP 779:free-threaded 构建成为官方支持)
- PEP 703:Making the Global Interpreter Lock Optional in CPython
- PEP 779:Criteria for supported status for free-threaded Python
- Python 3.14 文档:concurrent.futures