16 核只跑满 1 个核:Python 3.14 free-threaded 构建的生产迁移评估
一台 16 核机器上跑图片哈希服务,代码里开了 16 个 threading.Thread,CPU 利用率长期拉不满,大多数时候只有一个核心跑到满负载。这个现场几乎可以直接贴上“GIL 受限”的标签:线程确实在跑,但同一时刻只有一个线程能执行 Python 字节码,其余线程排队等锁,多核资源闲置。
第一反应是把锅扣给 GIL,方向没错。但“换 free-threaded 构建”和“把线程数从 1 变成 16”之间,还隔着构建可用性、依赖适配、性能回退和线程安全几道关卡。
free-threaded 是什么,为什么进了生产评估清单
Python 3.14(2025-10-07 发布)把 free-threaded build 从“实验性”转为官方维护的受支持构建(PEP 779)。free-threaded 允许线程在可用 CPU 核心上真正并行,直接对应上面那个单核满载的场景。
但它始终是可选构建分支,不是默认解释器行为的直接替换。3.13 已经提供了关闭 GIL 的实验性构建选项,3.14 才成为官方维护的受支持构建。所以“升级到 3.14”不等于“拿到 free-threading”,需要显式选择 free-threaded 构建。
怎么确认 GIL 到底关没关
要分两层回答:这个解释器能不能关闭 GIL,以及这一次运行有没有真的关闭。
先看解释器本身:
python -VV
输出中包含 free-threading build 才是自由线程构建,sys.version 中同样会带这个标识。
再在进程内确认:
import sys, sysconfig
sysconfig.get_config_var("Py_GIL_DISABLED") # 返回 1 表示构建支持 free threading
sys._is_gil_enabled() # 用于确认运行进程中 GIL 是否真的被禁用
sysconfig.get_config_var("Py_GIL_DISABLED") 回答的是“能不能”,sys._is_gil_enabled() 回答的是“有没有”。只查前者不够——构建支持 free threading,不代表这次运行真的跑在无 GIL 状态。
最坑的一点:依赖在 import 时把 GIL 悄悄打开
free-threaded build 支持用环境变量 PYTHON_GIL 或命令行 -X gil 在运行时重新开启 GIL。这类显式开关容易排查,真正麻烦的是隐式触发。
导入未明确标记支持 free threading 的 C-API 扩展模块时,GIL 会被自动重新启用,并打印警告。这条会把整个进程的并行能力打回原样。警告不一定出现在日志的显眼位置,也不一定伴随性能断崖——不要看到性能没明显下降就认为验证通过,必须主动确认进程实际 GIL 状态。
C 扩展需要显式声明支持:
- 多阶段初始化(
PyModuleDef_Init()):在模块定义中加Py_mod_gil槽。 - 单阶段初始化(
PyModule_Create()):调用PyUnstable_Module_SetGIL()。
两者都要用 PY_VERSION_HEX 或 #ifdef Py_GIL_DISABLED 保护。
轮子与构建这一层同样要注意:
- C-API 扩展需要专门为自由线程构建编译,轮子/共享库带
t后缀(如python3.13t)。 - manylinux 支持。
- cibuildwheel 需要设置
CIBW_ENABLE=cpython-freethreading。 - free-threaded 目前不支持受限 C API 或 Stable ABI;用 setuptools 时应写
py_limited_api=not sysconfig.get_config_var("Py_GIL_DISABLED")。 - Windows 上必须由构建后端显式定义
Py_GIL_DISABLED=1,不再由 C 编译器自动推断。
依赖树里只要有一个扩展没适配,import 阶段的 GIL 重启用就会把收益吃掉。
性能与内存的代价
free-threaded 构建执行 Python 代码有额外开销。pyperformance 套件上,从 macOS aarch64 的约 1% 到 x86-64 Linux 的约 8%;内存开销约增加 10%~20%。单线程性能与内存开销要分开单独评估,不能只盯着多核吞吐。
3.14 中不朽对象(immortal)只限于代码常量(数字/字符串字面量和常量元组)与 sys.intern() 字符串。
C API 的线程安全边界
free-threaded 下 PyListObject / PyDictObject / PySetObject 等容器会内部加锁(PyList_Append() 先锁列表)。有个显著例外:PyDict_Next() 不锁字典,若可能并发修改要用 Py_BEGIN_CRITICAL_SECTION 保护迭代。PyList_GET_ITEM / PyList_SET_ITEM 等访问器宏与 PySequence_Fast() 返回对象的宏不做检查与锁定,容器可能被并发修改时不是线程安全的。
内存分配要求:只有 Python 对象使用 object 域分配,且所有 Python 对象都必须用该域分配。此前只是最佳实践,free-threaded 下是硬要求。
与 multiprocessing 对比:MultiProcessing 内存占用高、单线程性能无损、进程隔离简单;Free-threading 内存占用低、指针传递交换数据,但复杂度极高、需处理 Race Condition、单线程延迟下降。
其他改进
asyncio 为原生任务实现每线程双向链表,标准基准提升 10-20% 并减少内存;python -m asyncio pstree 可内省所有线程中的任务调用图;concurrent.futures 新增 InterpreterPoolExecutor(解释器池并行)。
灰度验收清单
- 解释器构建属性
Py_GIL_DISABLED是否为 1。不通过就继续用默认构建。 - 运行时
sys._is_gil_enabled()结果。不通过就反向定位哪个扩展触发 GIL 重启用。 - 第三方依赖 C 扩展的 wheel 适配说明:升级适配版本、替换库或隔离线程。
- 吞吐与延迟指标在同一数据集下的 P95 耗时、CPU 使用率、内存占用。异常就暂停扩大灰度、回退稳定版本。
测试记录至少保留解释器版本、构建标识、GIL 状态、CPU 核数、线程数、任务总量、重复次数、中位耗时、依赖版本。测试矩阵至少含 1、2、4 线程,普通构建与 free-threaded 构建用同一份代码、同一批输入、同样线程数,用中位数而非预设加速数字。
回退边界上,free-threaded 是可切换的构建分支,出问题时把部署切回默认构建即可;真正的成本在依赖侧——如果某个关键扩展短期内拿不到带 t 后缀的轮子,灰度就只能停在评估阶段。