从 GIL 到多核:Python 3.14 的并行选择与边界

Chen Xi
Chen Xi

写完 asyncio 那篇文章以后,我一直想补一个容易被混在一起的问题:异步、并发和并行,到底是不是一回事?

它们当然不是。

asyncio 很擅长让一个线程同时照看许多 I/O 任务;线程池适合把等待交给多个操作系统线程;进程池可以绕开传统 GIL;而 Python 3.14 又把解释器池正式带到了标准库里。与此同时,free-threaded Python 让关闭 GIL 的构建从实验功能走向了更正式的支持阶段。

选项变多以后,真正困难的反而不是“怎么开几个 worker”,而是判断:我的任务究竟需要并发、并行,还是只需要把阻塞的 I/O 挪开?

image

先把几个词说清楚

我会用下面的方式区分它们:

概念 关注点 一个直观例子
并发 多件事在同一段时间内推进 一个事件循环轮流等待多个请求
并行 多件事在同一时刻使用多个 CPU 核心 四个 CPU 密集任务同时计算
异步 通过主动让出控制权避免阻塞 await 网络响应时处理别的任务
多线程 多个操作系统线程共享一个进程 同一进程里并发请求外部 API
多进程 多个进程各自拥有解释器和内存 用进程池计算图像或特征

一个异步程序可以有很高的并发,却仍然只在一个核心上执行 Python 字节码。一个多线程程序也可以有很多线程,却因为 GIL 不能让纯 Python 的 CPU 计算真正同时运行。不要从 API 名称猜性能,先从任务的等待和计算比例开始。

Python 3.14 这次增加了什么

本文按 Python 3.14 系列的行为来写。最值得关注的是 concurrent.futures.InterpreterPoolExecutor:它是 ThreadPoolExecutor 的子类,每个 worker 在线程中运行,但拥有自己的解释器和 GIL,因此可以在同一进程里使用多个 CPU 核心。官方文档

它和普通线程池的差异,不是把同一份 Python 对象锁得更聪明,而是让 worker 之间默认隔离:模块、sysbuiltins 和全局变量都各有一份,参数和返回值需要通过序列化传递。

另一条路线是 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from __future__ import annotations

from time import perf_counter


def cpu_task(size: int) -> int:
total = 0
for value in range(size):
total += (value * value) % 97
return total


def measure(executor_type, workers: int, jobs: int, size: int) -> float:
started = perf_counter()
with executor_type(max_workers=workers) as executor:
list(executor.map(cpu_task, [size] * jobs))
return perf_counter() - started

在 Python 3.14 上可以把同一组参数交给线程池、进程池和解释器池:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from concurrent.futures import (
InterpreterPoolExecutor,
ProcessPoolExecutor,
ThreadPoolExecutor,
)


for executor_type in (
ThreadPoolExecutor,
ProcessPoolExecutor,
InterpreterPoolExecutor,
):
elapsed = measure(
executor_type,
workers=4,
jobs=4,
size=4_000_000,
)
print(executor_type.__name__, f"{elapsed:.2f}s")

这个实验只回答“这类函数在这台机器上的运行代价”,不回答“生产服务一定会快多少”。至少还应该记录:

  • 单 worker 的基线时间;
  • worker 数量变化后的吞吐和 p95;
  • 进程或解释器启动时间;
  • 参数和返回值的序列化时间;
  • 常驻内存和峰值内存;
  • 第三方扩展包是否能在目标构建中工作。

如果只看总耗时,很容易把一次偶然的缓存命中误认为并行收益。

普通线程池:别让 GIL 把它背成坏名声

在默认带 GIL 的 CPython 中,纯 Python 的 CPU 密集任务通常无法靠线程池获得线性加速。可是线程池并没有失去价值:网络请求、文件读写、压缩库和部分数值库会释放 GIL,线程也能让阻塞调用不堵住事件循环。

1
2
3
4
5
6
7
8
9
10
11
12
import asyncio
from concurrent.futures import ThreadPoolExecutor


async def run_blocking_io(items: list[str]) -> list[bytes]:
loop = asyncio.get_running_loop()
with ThreadPoolExecutor(max_workers=8) as pool:
tasks = [
loop.run_in_executor(pool, download_one, item)
for item in items
]
return await asyncio.gather(*tasks)

这里的收益来自“等待时可以做别的事”,不是因为 Python 代码突然获得了四个核心。线程池的工程问题也很实际:共享可变状态需要锁,线程数过多会增加上下文切换,第三方库还可能有自己的连接池和线程限制。

进程池:隔离清楚,但代价也清楚

进程池是处理 CPU 密集任务的传统方案。每个进程有自己的解释器和地址空间,通信通常通过 pickle 或管道完成。

1
2
3
4
5
6
7
8
9
10
from concurrent.futures import ProcessPoolExecutor


def calculate_features(rows: list[float]) -> list[float]:
return [value * value for value in rows]


with ProcessPoolExecutor(max_workers=4) as pool:
chunks = [chunk_a, chunk_b, chunk_c, chunk_d]
results = list(pool.map(calculate_features, chunks))

如果 chunks 很大,进程间复制和序列化可能吃掉计算收益;如果任务很小,启动 worker 的成本甚至比计算本身还大。进程池依然适合边界清晰、数据块较大、第三方扩展不适合多解释器的场景。

在 Windows 上还要记得进程启动方式和入口保护:

1
2
if __name__ == "__main__":
main()

这不是 Python 3.14 才有的规则,却是很多“本地能跑、换机器就递归启动”的根源。

InterpreterPoolExecutor:线程的外壳,解释器的隔离

解释器池的吸引力在于,它把“多核并行”和“进程级隔离”放到了一个进程里。每个 worker 都有自己的 sys.modules 和全局变量,因此不会像普通线程那样自然共享业务对象;同样,也不能把它当成共享内存线程池。

一个更接近真实业务的例子是,把已经清洗好的数据块交给 worker 做 CPU 特征计算:

1
2
3
4
5
6
7
8
9
10
11
12
from concurrent.futures import InterpreterPoolExecutor


def score_chunk(rows: list[dict[str, float]]) -> list[float]:
# 函数和参数都要能被序列化;不要依赖主解释器里的全局缓存。
return [row["close"] * row["volume"] for row in rows]


def score_all(chunks: list[list[dict[str, float]]]) -> list[float]:
with InterpreterPoolExecutor(max_workers=4) as pool:
parts = pool.map(score_chunk, chunks)
return [score for part in parts for score in part]

这里有三个必须接受的事实:

  1. 每个解释器需要独立导入依赖,导入成本可能增加;
  2. 可变对象不能直接共享,通常要复制或通过消息传递;
  3. 不是所有 C 扩展都已经为多解释器做好准备。

官方文档明确提醒,多解释器的扩展模块需要遵守多阶段初始化等规则;老的单阶段扩展可能无法安全地在多个解释器中使用。多解释器 C API 文档

所以解释器池不是“进程池的零成本替代品”,它更像一种隔离强、通信显式、适合纯 Python 或兼容性已验证扩展的并行模型。

free-threaded Python:真正共享内存,也真正需要同步

free-threaded 构建最容易被误解成“把 Python 换个开关,线程就自动加速”。实际情况更复杂。

没有 GIL 以后,多个线程可以同时执行 Python 代码;但共享可变对象的竞争不会消失,反而需要更认真地设计锁、队列、所有权和数据分片。某些依赖包可能还没有提供兼容的 wheel,部分代码也可能依赖过去由 GIL 偶然提供的原子性。

这段代码在普通构建里看起来很熟悉,但在 free-threaded 构建里仍然需要明确的同步策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from threading import Lock, Thread


counter = 0
counter_lock = Lock()


def add_many(times: int) -> None:
global counter
for _ in range(times):
with counter_lock:
counter += 1


threads = [Thread(target=add_many, args=(100_000,)) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()

锁保证了正确性,却可能让细粒度更新失去并行收益。更好的方案通常是让每个 worker 写自己的局部结果,最后再合并,而不是所有线程争抢一个全局计数器。

另外,free-threaded 构建会为线程安全付出一定的性能和内存成本。PEP 779 把这类成本列入正式支持的评估标准,说明“能并行”与“值得并行”是两件事。

asyncio 和多核不是互相排斥的

如果一个服务主要等待 HTTP、数据库或磁盘,asyncio 仍然是很自然的第一选择。真正需要 CPU 计算时,可以把计算阶段交给线程池、进程池或解释器池:

1
2
3
4
5
6
7
8
9
10
11
12
13
import asyncio
from concurrent.futures import InterpreterPoolExecutor


async def handle_request(chunks: list[list[dict[str, float]]]) -> list[float]:
loop = asyncio.get_running_loop()
with InterpreterPoolExecutor(max_workers=4) as pool:
futures = [
loop.run_in_executor(pool, score_chunk, chunk)
for chunk in chunks
]
parts = await asyncio.gather(*futures)
return [score for part in parts for score in part]

这里的关键是边界:事件循环负责 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、操作系统、数据大小和第三方扩展影响,不应直接当作生产承诺。

参考资料