从 GIL 到多核:Python 3.14 的并行选择与边界
写完 asyncio 那篇文章以后,我一直想补一个容易被混在一起的问题:异步、并发和并行,到底是不是一回事?
它们当然不是。
asyncio 很擅长让一个线程同时照看许多 I/O 任务;线程池适合把等待交给多个操作系统线程;进程池可以绕开传统 GIL;而 Python 3.14 又把解释器池正式带到了标准库里。与此同时,free-threaded Python 让关闭 GIL 的构建从实验功能走向了更正式的支持阶段。
选项变多以后,真正困难的反而不是“怎么开几个 worker”,而是判断:我的任务究竟需要并发、并行,还是只需要把阻塞的 I/O 挪开?
先把几个词说清楚
我会用下面的方式区分它们:
| 概念 | 关注点 | 一个直观例子 |
|---|---|---|
| 并发 | 多件事在同一段时间内推进 | 一个事件循环轮流等待多个请求 |
| 并行 | 多件事在同一时刻使用多个 CPU 核心 | 四个 CPU 密集任务同时计算 |
| 异步 | 通过主动让出控制权避免阻塞 | await 网络响应时处理别的任务 |
| 多线程 | 多个操作系统线程共享一个进程 | 同一进程里并发请求外部 API |
| 多进程 | 多个进程各自拥有解释器和内存 | 用进程池计算图像或特征 |
一个异步程序可以有很高的并发,却仍然只在一个核心上执行 Python 字节码。一个多线程程序也可以有很多线程,却因为 GIL 不能让纯 Python 的 CPU 计算真正同时运行。不要从 API 名称猜性能,先从任务的等待和计算比例开始。
Python 3.14 这次增加了什么
本文按 Python 3.14 系列的行为来写。最值得关注的是 concurrent.futures.InterpreterPoolExecutor:它是 ThreadPoolExecutor 的子类,每个 worker 在线程中运行,但拥有自己的解释器和 GIL,因此可以在同一进程里使用多个 CPU 核心。官方文档
它和普通线程池的差异,不是把同一份 Python 对象锁得更聪明,而是让 worker 之间默认隔离:模块、sys、builtins 和全局变量都各有一份,参数和返回值需要通过序列化传递。
另一条路线是 free-threaded Python,也就是带 t 后缀的、可以禁用 GIL 的构建。Python 官方文档说明,free-threaded 构建从 3.13 开始可用,但不是默认构建;Python 3.14 的 asyncio 也已经针对 free-threaded Python 做了相应支持。线程文档、asyncio 与 free-threaded Python
PEP 779 还给出了 free-threaded 进入“正式支持”阶段的标准,包括性能、内存、API 稳定性和文档等要求;这并不意味着它已经成为所有项目的默认选择。PEP 779
换句话说,Python 3.14 的现实不是“GIL 消失了”,而是多了一组可以按场景选择的运行模型。
先写一个可比较的 CPU 任务
不要一上来拿真实业务压测。先准备一个确定性、没有网络 I/O 的函数,比较不同执行器的额外成本:
1 | from __future__ import annotations |
在 Python 3.14 上可以把同一组参数交给线程池、进程池和解释器池:
1 | from concurrent.futures import ( |
这个实验只回答“这类函数在这台机器上的运行代价”,不回答“生产服务一定会快多少”。至少还应该记录:
- 单 worker 的基线时间;
- worker 数量变化后的吞吐和 p95;
- 进程或解释器启动时间;
- 参数和返回值的序列化时间;
- 常驻内存和峰值内存;
- 第三方扩展包是否能在目标构建中工作。
如果只看总耗时,很容易把一次偶然的缓存命中误认为并行收益。
普通线程池:别让 GIL 把它背成坏名声
在默认带 GIL 的 CPython 中,纯 Python 的 CPU 密集任务通常无法靠线程池获得线性加速。可是线程池并没有失去价值:网络请求、文件读写、压缩库和部分数值库会释放 GIL,线程也能让阻塞调用不堵住事件循环。
1 | import asyncio |
这里的收益来自“等待时可以做别的事”,不是因为 Python 代码突然获得了四个核心。线程池的工程问题也很实际:共享可变状态需要锁,线程数过多会增加上下文切换,第三方库还可能有自己的连接池和线程限制。
进程池:隔离清楚,但代价也清楚
进程池是处理 CPU 密集任务的传统方案。每个进程有自己的解释器和地址空间,通信通常通过 pickle 或管道完成。
1 | from concurrent.futures import ProcessPoolExecutor |
如果 chunks 很大,进程间复制和序列化可能吃掉计算收益;如果任务很小,启动 worker 的成本甚至比计算本身还大。进程池依然适合边界清晰、数据块较大、第三方扩展不适合多解释器的场景。
在 Windows 上还要记得进程启动方式和入口保护:
1 | if __name__ == "__main__": |
这不是 Python 3.14 才有的规则,却是很多“本地能跑、换机器就递归启动”的根源。
InterpreterPoolExecutor:线程的外壳,解释器的隔离
解释器池的吸引力在于,它把“多核并行”和“进程级隔离”放到了一个进程里。每个 worker 都有自己的 sys.modules 和全局变量,因此不会像普通线程那样自然共享业务对象;同样,也不能把它当成共享内存线程池。
一个更接近真实业务的例子是,把已经清洗好的数据块交给 worker 做 CPU 特征计算:
1 | from concurrent.futures import InterpreterPoolExecutor |
这里有三个必须接受的事实:
- 每个解释器需要独立导入依赖,导入成本可能增加;
- 可变对象不能直接共享,通常要复制或通过消息传递;
- 不是所有 C 扩展都已经为多解释器做好准备。
官方文档明确提醒,多解释器的扩展模块需要遵守多阶段初始化等规则;老的单阶段扩展可能无法安全地在多个解释器中使用。多解释器 C API 文档
所以解释器池不是“进程池的零成本替代品”,它更像一种隔离强、通信显式、适合纯 Python 或兼容性已验证扩展的并行模型。
free-threaded Python:真正共享内存,也真正需要同步
free-threaded 构建最容易被误解成“把 Python 换个开关,线程就自动加速”。实际情况更复杂。
没有 GIL 以后,多个线程可以同时执行 Python 代码;但共享可变对象的竞争不会消失,反而需要更认真地设计锁、队列、所有权和数据分片。某些依赖包可能还没有提供兼容的 wheel,部分代码也可能依赖过去由 GIL 偶然提供的原子性。
这段代码在普通构建里看起来很熟悉,但在 free-threaded 构建里仍然需要明确的同步策略:
1 | from threading import Lock, Thread |
锁保证了正确性,却可能让细粒度更新失去并行收益。更好的方案通常是让每个 worker 写自己的局部结果,最后再合并,而不是所有线程争抢一个全局计数器。
另外,free-threaded 构建会为线程安全付出一定的性能和内存成本。PEP 779 把这类成本列入正式支持的评估标准,说明“能并行”与“值得并行”是两件事。
asyncio 和多核不是互相排斥的
如果一个服务主要等待 HTTP、数据库或磁盘,asyncio 仍然是很自然的第一选择。真正需要 CPU 计算时,可以把计算阶段交给线程池、进程池或解释器池:
1 | import asyncio |
这里的关键是边界:事件循环负责 I/O 和取消,执行器负责一块可计算、可序列化、不会无限膨胀的 CPU 工作。执行器生命周期也要被纳入服务的关闭流程,不能在请求函数里无限创建池子。
Python 3.14 的 asyncio 文档已经把 free-threaded Python 单独列为主题,并说明单事件循环仍然是高效的 I/O 调度器;只有当每个请求包含明显的 Python 计算时,多核心才会成为瓶颈的解决方向。asyncio 与 free-threaded Python
一个实际的选择表
我会用下面这张表做第一轮决策:
| 任务特征 | 首选 | 需要特别测什么 |
|---|---|---|
| 大量网络等待 | asyncio | 超时、连接池、取消传播 |
| 阻塞库但 CPU 很轻 | ThreadPoolExecutor | 线程数、下游限流 |
| 纯 Python CPU 计算,数据块大 | ProcessPoolExecutor | 启动和序列化成本 |
| 纯 Python CPU 计算,想保留单进程 | InterpreterPoolExecutor | 多解释器兼容、对象复制 |
| 已验证依赖,确实需要共享内存并行 | free-threaded build | 数据竞争、锁开销、内存 |
最后一行最容易被滥用。free-threaded 不是“所有服务的性能模式”,而是一种需要从依赖、数据结构、线程安全到部署镜像一起验证的构建选择。
压测时不要只看一个数字
我会把压测结果写成一张表,而不是只截一张“快了 2.1 倍”的终端截图:
| 维度 | 为什么重要 |
|---|---|
| 吞吐 | 并行是否真的处理了更多任务 |
| p95 / p99 | 尾部延迟是否因为排队变坏 |
| 常驻内存 | worker 数量是否带来不可接受的复制 |
| 序列化时间 | 数据搬运是否吞掉计算收益 |
| 启动时间 | 短任务是否值得创建池子 |
| 正确性 | 并行后结果是否与串行基线一致 |
| 可取消性 | 请求超时后 worker 是否还在偷偷计算 |
尤其是最后一项。任务更快不代表生命周期更可靠;如果用户已经断开连接,后台仍然计算几分钟,系统只是把问题从延迟换成了资源泄漏。
最后的判断
Python 3.14 的变化值得关注,但不需要把它读成一场“GIL 终于被消灭”的宣传。
更准确的说法是:Python 现在把几种并行路线摆到了更清楚的位置上。默认线程适合 I/O,进程池提供成熟隔离,解释器池提供同进程的多核执行,free-threaded 构建则把共享内存并行交给愿意承担兼容性和同步成本的项目。
选择执行器之前,先回答三个问题:任务主要在等什么,数据需要怎样传递,失败和取消之后谁负责收尾。能把这三个问题写清楚,通常比把 worker 数从 4 改成 16 更接近真正的性能优化。
本文讨论的是 CPython 3.14 系列的工程边界,示例需要在目标 Python 构建和依赖版本中重新验证。性能数字会受到 CPU、操作系统、数据大小和第三方扩展影响,不应直接当作生产承诺。