CPython abi3t 迁移指南:将 C 扩展移植到支持 Free Threading 的 Stable ABI
本篇基于 CPython 官方文档 Doc/howto/abi3t-migration.rst 编写,完整讲解自 3.15 版引入的 Free-Threaded Stable ABI(abi3t)迁移流程:如何设置构建目标宏、把 PyInit_ 初始化函数改写为 PEP 793 的 PyModExport_ 导出钩子、应对 PyObject 变不透明(opaque)后的自定义类型重定义与数据访问,以及最终的 wheel 标签与分发规范。读完并结合仓库源码佐证,你可以将一个已支持 abi3 的 C/C++ 扩展模块完整移植为一份同时兼容 Free-Threaded 与非 Free-Threaded 构建的 abi3t 扩展。
为什么迁移到 abi3t
使用 Stable ABI 的典型动机,是减少每个 CPython 版本需要构建和分发的产物数量。
不使用 Stable ABI 时,你必须在每个想支持的功能版本上分别构建一个共享库、以及一个对应的 wheel 分发包。下表每个标签都代表一个独立的库/wheel:
| CPython 版本 | 非 Free-Threaded | Free-Threaded |
|---|---|---|
| 3.12 | cpython-312 |
— |
| 3.13 | cpython-313 |
cpython-313t |
| 3.14 | cpython-314 |
cpython-314t |
| 3.15 | cpython-315 |
cpython-315t |
| 3.16 | cpython-316 |
cpython-316t |
| 更后续版本 | cpython-3{XX} |
cpython-3{XX}t |
构建数量相当可观,再乘以支持的平台数就更可观了。
而 Stable ABI(abi3,CPython 3.2 引入)可以用每平台一个扩展覆盖所有非 Free-Threaded 构建:
| CPython 版本 | 非 Free-Threaded | Free-Threaded |
|---|---|---|
| 3.12 | abi3 |
— |
| 3.13 | (同一 abi3) |
cpython-313t |
| 3.14 | (同一 abi3) |
cpython-314t |
| 3.15 | (同一 abi3) |
cpython-315t |
| 3.16 | (同一 abi3) |
cpython-316t |
| 更后续版本 | (同一 abi3) |
cpython-3{XX}t |
Free-Threaded 构建的 Stable ABI(abi3t),CPython 3.15 引入,对 Free-Threaded 构建做了同样的事——并且向下兼容非 Free-Threaded 构建:
| CPython 版本 | 非 Free-Threaded | Free-Threaded |
|---|---|---|
| 3.12 | abi3 * |
— |
| 3.13 | (同一产物) | cpython-313t |
| 3.14 | (同一产物) | cpython-314t |
| 3.15 及以后 | abi3t(同时覆盖两种构建) |
(同一产物) |
*(同上,abi3 扩展兼容所有非 Free-Threaded 构建;包括本表归给 abi3t 的 3.15+ 版本。)
什么时候不该这么做
Stable ABI 有两个主要缺点:
- 扩展可能变慢,因为 Stable ABI 优先考虑兼容性而非性能。差异通常不可察觉,且可以缓解:用同一份源码既构建 Stable ABI 版本,也针对"一级"CPython 版本构建少量版本专属版本。
- 并非所有 C API 都可用。扩展需要移植才能构建为 Stable ABI,这可能很难,甚至在极少数情况下不可能。
具体而言,abi3t 需要 CPython 3.15 新增的 API。如果你希望从同一份源码构建出更老 CPython 版本的扩展,主要有两个选项:
- 使用预处理器条件编译。 按本指南操作时,每当某处修改会破坏你所关心的 CPython 版本上的构建,就用
#ifdef Py_TARGET_ABI3T代码块包裹新逻辑,把原有代码保留在#else块中。对于手写 C 扩展,得益于 PEP 697 引入的 API 补充,这种方式向下兼容到 CPython 3.12 都还合理;对代码生成器(例如 Cython)而言,保持与 3.11 及更早版本的兼容也许可行。 - 不移植到
abi3t,继续为每个 CPython 版本构建独立扩展,直到你能放弃对旧版本的支持。这是一个合理的选择,并非所有扩展都需要立刻切换。
前置条件
本指南假设你有一个直接以 C(或 C++)编写的扩展,希望将其移植到 abi3t。如果你的扩展使用代码生成器(如 Cython)或语言绑定(如 PyO3),最好等该工具支持 abi3t;如果你是这类工具的维护者,可以尝试把本指南的说明适配到你的工具中。
非 Free-Threaded 的 Stable ABI
你的扩展应当已经支持非 Free-Threaded Stable ABI(abi3)。若尚未支持,要么先完成那一步移植,要么按本指南操作但准备好修复文档未提及的问题。
Free-Threading 支持
虽然这不是硬性前置条件,但你大概率会在移植 abi3t 之前先把扩展适配 Free Threading。可参见仓库中的 Doc/howto/isolating-extensions.rst 等相关 how-to(原文档指向 freethreading-extensions-howto 一节)。
扩展模块的隔离
你的模块应当使用多阶段初始化(multi-phase initialization),并且要么已经完成隔离(isolated),要么保证每个进程最多加载一次。若不是这种情况,先按隔离扩展的指南操作(其中还有 opt-out 快捷方式一节)。
避免可变大小类型
如果你的扩展定义了可变大小的类型(使用 Py_tp_itemsize 或 PyTypeObject.tp_itemsize),则无法移植到 3.15 的 abi3t。
配置构建
如果使用构建工具(setuptools、meson-python、scikit-build-core 等),搜索其文档中如何选择 abi3t。文档撰写时尚不是所有工具都支持;如果你的工具支持,就直接用。你可以临时在 #include <Python.h> 之后加入以下代码来验证工具是否设置了正确的标志:
#if Py_TARGET_ABI3T+0 <= 0x30f0000
#error "abi3t define is not set!"
#endif
如果未设置,这里应当报出一个不同于 "abi3t define is not set" 的报错(即 Py_TARGET_ABI3T 未定义导致预处理失败)。
注意: 如果你的构建工具尚不支持
abi3t,在包含Python.h之前定义如下宏:#define Py_TARGET_ABI3T 0x30f0000或者作为编译器标志传入,例如
-DPy_TARGET_ABI3T=0x30f0000。一旦扩展在此设置下构建通过,它就与 CPython 3.15 及以后版本兼容。若手动设置该宏,之后还需要手动命名和打标签产物(见 标签与分发 一节)。
关于这个宏的底层语义,可以看仓库头文件 Include/pyabi.h:
Py_TARGET_ABI3T的合法值必须>= 0x030f0000(3.15),否则直接#error(pyabi.h 第 42–44 行);- 它也可以由
Py_LIMITED_API与Py_GIL_DISABLED同时定义隐式推导出(第 61–64 行); - 定义后会设置内部宏
_Py_OPAQUE_PYOBJECT(正是它让PyObject变不透明)、把Py_LIMITED_API收敛到较低的值,并在未定义时补上Py_GIL_DISABLED(第 65–87 行)。也就是说,abi3t从预处理层面就强制"不透明 PyObject + Limited API + Free-Threaded 假设"三件套同时生效。
本指南会要求你做一系列修改。每完成一步,都要确认扩展在原有(非 abi3t)配置下仍然能构建,理想情况下在你支持的所有 Python 版本上跑一遍测试,确保移植过程中没有东西被破坏。
模块导出钩子(Module Export Hook)
除非你已经完成这一步,你的扩展模块会定义一个名为 PyInit_<module_name> 的模块初始化函数。需要把它移植到 CPython 3.15 中由 PEP 793 新增的模块导出钩子 PyModExport_<module_name>。该 API 的完整说明可参见 Doc/c-api/extension-modules.rst 中的 "extension-export-hook" 小节。
现有的 init 函数大致长这样(用你自己的 <modname> 和 <moddef> 替换):
PyMODINIT_FUNC
PyInit_<modname>(void)
{
return PyModuleDef_Init(&<moddef>);
}
如果 return 之前有代码,把它们移到 Py_mod_create 或 Py_mod_exec 槽函数里。
该函数引用了一个 PyModuleDef 对象(上例中的 <moddef>),其定义通常形如:
static PyModuleDef <moddef> = {
PyModuleDef_HEAD_INIT,
.m_name = "my_module",
.m_doc = "my docstring",
.m_size = sizeof(my_state_struct),
.m_methods = my_methods,
.m_slots = my_slots,
.m_traverse = my_traverse,
.m_clear = my_clear,
.m_free = my_free,
};
删除这个定义和 PyInit 函数(或者放进 #ifndef Py_TARGET_ABI3T 块中以保留向后兼容),替换为:
PyABIInfo_VAR(abi_info);
static PySlot my_slot_array[] = {
PySlot_STATIC_DATA(Py_mod_abi, &abi_info),
PySlot_STATIC_DATA(Py_mod_name, "my_module"),
PySlot_STATIC_DATA(Py_mod_doc, "my docstring"),
PySlot_SIZE(Py_mod_state_size, sizeof(my_state_struct)),
PySlot_STATIC_DATA(Py_mod_methods, my_methods),
PySlot_STATIC_DATA(Py_mod_slots, my_slots),
PySlot_FUNC(Py_mod_state_traverse, my_traverse),
PySlot_FUNC(Py_mod_state_clear, my_clear),
PySlot_FUNC(Py_mod_state_free, my_free),
PySlot_END
};
PyMODEXPORT_FUNC
PyModExport_<modname>(void)
{
return my_slot_array;
}
原本缺失的字段都可以省略(唯独新增的 Py_mod_abi 不能省),其余替换为你自己的值。
PySlot 类型与相关宏可在 Include/slots.h 中找到:PySlot 是一个紧凑的静态描述结构(sl_id + sl_flags + 联合体值),宏 PySlot_DATA / PySlot_FUNC / PySlot_SIZE / PySlot_STATIC_DATA / PySlot_END 分别对应"整型/指针值"、"函数指针"、"尺寸"、"静态指针"与数组终止标记。而 PyABIInfo_VAR 宏在 Include/modsupport.h 中展开为一个静态的 PyABIInfo 变量(由 _PyABIInfo_DEFAULT 填上 PyABIInfo_STABLE | PyABIInfo_FREETHREADING_AGNOSTIC 等标志),这正是 abi3t 模块"既支持 GIL 也支持 Free-Threaded"的身份声明。
PyMODEXPORT_FUNC 在 Include/exports.h 中定义为 _PyINIT_FUNC_DECLSPEC PySlot*——与 PyMODINIT_FUNC 相同的导出/链接属性,但返回类型换成了槽数组指针。
和示例一致:你的 PyModExport_ 函数只能返回指向静态数据的指针。如果实在无法避免额外代码,参见 PyModExport 文档中的注意事项(caveats)一节。
处理已有的 slots 数组
如果你有 Py_mod_slots 槽,检查它引用的数组。它应当形如一个 PyModuleDef_Slot 数组:
static PyObject *create_module(PyObject *spec, PyModuleDef *def) { ... }
static int my_first_module_exec(PyObject *module) { ... }
static int my_second_module_exec(PyObject *module) { ... }
static PyModuleDef_Slot my_slots[] = {
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
{Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},
{Py_mod_create, my_module_create},
{Py_mod_exec, my_first_module_exec},
{Py_mod_exec, my_second_module_exec},
{0, NULL}
};
Py_mod_create
如果你有 Py_mod_create 条目,确认该函数能以 NULL 作为第二个参数调用(替代你正在移除的 PyModuleDef)。这个参数通常根本用不到——改个名就能验证:
static PyObject *create_module(PyObject *spec, PyModuleDef *_unused) { ... }
如果参数被使用了,找别的方式传递数据。通常这些信息是静态的,可以直接引用。(如果你用一个函数服务多个不同模块,考虑为它们分别定义函数。)
多个 Py_mod_exec
如果你有多个 Py_mod_exec 条目,需要合并它们:新建一个函数依次调用其余函数,并替换掉原有槽位:
static int my_module_exec(PyObject *module) {
if (my_first_module_exec(module) < 0) return -1;
if (my_second_module_exec(module) < 0) return -1;
}
static PyModuleDef_Slot my_slots[] = {
...
/* (移除其他 Py_mod_exec 槽) */
...
{Py_mod_exec, my_module_exec},
{0, NULL}
};
如果这些函数没有在别处使用,也可以直接合并函数体。
合并槽数组(可选)
当你准备放弃与 Python 3.14 的兼容时,可以把内层槽移入 PySlot 数组、把定义改写为 PySlot_DATA 和 PySlot_FUNC,以清理代码:
static PySlot my_slot_array[] = {
...
PySlot_DATA(Py_mod_gil, Py_MOD_GIL_NOT_USED),
PySlot_DATA(Py_mod_multiple_interpreters,
Py_MOD_PER_INTERPRETER_GIL_SUPPORTED)
PySlot_FUNC(Py_mod_create, my_module_create),
PySlot_FUNC(Py_mod_exec, my_module_exec),
PySlot_END
};
这样做后,删除原来的 PyModuleDef_Slot 数组及其 Py_mod_slots 条目。
与模块相关联的 PyModuleDef
由于新 API 不再使用 PyModuleDef 结构,不会有任何定义(definition)与最终创建的模块相关联。这会改变以下函数的行为:
PyModule_GetDefPyType_GetModuleByDef
检查你的代码是否使用了它们;如果没有,可跳过本节。
这些函数通常用于两个目的:
-
获取模块创建时使用的定义。 使用新 API 后这不再可能。模块不再持有对定义的引用,你需要想别的办法传递相关数据。
-
判断某个模块对象"是否是你的"。 这个用例现在由模块 token(module token)承担——一个标识模块的不透明指针。使用 token 时,声明(或复用)一个唯一的静态变量,例如:
static char my_token;并在模块的
PySlot数组里加一条指向它的条目:static PySlot my_slot_array[] = { ... PySlot_STATIC_DATA(Py_mod_token, &my_token), PySlot_END }然后把
PyModule_GetDef的调用:PyModuleDef *def = PyModule_GetDef(module);换成
PyModule_GetToken(带输出参数、可能以异常失败;声明见 Include/moduleobject.h):void *token; if (PyModule_GetToken(module, &token) < 0) { /* 处理错误 */ }把
PyType_GetModuleByDef的调用:PyObject *module = PyType_GetModuleByDef(type, my_def); /* 处理错误;使用 module */换成
PyType_GetModuleByToken(返回强引用;声明见 Include/object.h):PyObject *module = PyType_GetModuleByToken(type, my_token); /* 处理错误;使用 module */ Py_XDECREF(module);
PyObject 的不透明化(Opaqueness)
在 abi3t 中,PyObject 与 PyVarObject 结构变为不透明(opaque)——这正是 Include/pyabi.h 中 _Py_OPAQUE_PYOBJECT 的作用。
访问它们的成员是被禁止的。如果你正在这样做,请改用其文档中提到的 getter/setter 函数来访问:
PyObject.ob_typePyObject.ob_refcntPyVarObject.ob_size
此外,PyObject 结构体对编译器来说大小未知——它确实会在不同 CPython 构建之间变化。
注意: 虽然大小在运行时可知(例如 Python 代码中的
sys.getsizeof(object())),你应当克制住从它推算指针偏移的冲动。对象的内存布局在将来abi3t实现中可能改变。
自定义类型定义
由于 PyObject 不透明,传统的自定义类型定义方式失效了:
typedef struct {
PyObject_HEAD // 展开为 `PyObject ob_base;`,其大小未知
int my_data;
} CustomObject;
static PyType_Spec CustomType_spec = {
...
.basicsize = sizeof(CustomObject),
...
};
最可能的情形是:你所有的类定义以及所有访问这些数据类的代码都需要重写。这大概是你为支持 abi3t 所做的最大改动。
对每个这样的类型,不要再为整个实例定义 struct,只定义"额外"字段——专属于你的类、而非其父类的那些字段:
typedef struct {
int my_data;
} CustomObjectData;
把类型名改掉。 几乎所有使用该结构体的代码都要变(尤其是指针不能再在 PyObject* 与新结构体间强转),改名会把所有使用点暴露为编译错误。(如果你用 typeof、C++ auto 之类手段避免写类型名,这招就不灵了——要格外小心,并考虑运行未定义行为检测工具。)
然后,创建类时使用负数 basicsize 来表示"额外"存储空间而非整个实例大小:
static PyType_Spec CustomType_spec = {
...
.basicsize = -sizeof(CustomObjectData), /* 注意负号 */
...
};
如果你使用 Py_tp_members,给每个成员设置 Py_RELATIVE_OFFSET 标志,并把 PyMemberDef.offset 指定为相对于新结构体的偏移。
访问自定义类型数据
接着是难的部分:所有需要访问这个结构体的代码,都要额外调用 PyObject_GetTypeData(声明见 Include/object.h),从 PyObject * 取回 CustomObjectData * 指针:
PyObject *obj = ...;
CustomObjectData *data = PyObject_GetTypeData(obj, cls);
注意:这个调用需要你的类的类型对象(cls)。
如果你的类不可被继承(即未使用 Py_TPFLAGS_BASETYPE 标志),cls 就是 Py_TYPE(obj)。否则切勿把 Py_TYPE 的结果传给 PyObject_GetTypeData:它可能返回的是分配给某个不相关子类的内存!例如,如果用户写出这样的子类:
class Sub(YourCustomClass):
__slots__ = ('a', 'b')
那么 Py_TYPE(obj) 是 Sub,而底层内存可能长这样:
╭─ PyObject *obj
│ ╭─ 你想要的指针
│ │ ╭─ PyObject_GetTypeData(obj, Py_TYPE(obj))
▼ ▼ ▼
┌──────────┬───┬────────────────┬───┬─────────────┬───┬─────────────┐
│ PyObject │...│ CustomTypeData │...│ PyObject *a │...│ PyObject *b │
└──────────┴───┴────────────────┴───┴─────────────┴───┴─────────────┘
(省略号表示可能存在填充。注意此内存布局不作保证:未来版本可能加入不同的填充,甚至改变结构的排列顺序。)
获取正确类对象有两种主要方式:
-
在实例方法中:你的实现可以使用
PyCMethod签名(配合PyMethodDef.ml_flags中的METH_METHOD位),并从defining_class参数得到类对象。 -
其他情况:用
Py_tp_token槽给你的类设置一个唯一静态 token,然后使用PyType_GetBaseByToken:PyTypeObject cls; if (PyType_GetBaseByToken(Py_TYPE(obj), my_tp_token, &cls) < 0) { /* 处理错误 */ } CustomObjectData *data = PyObject_GetTypeData(obj, cls);类型 token 的用法与本指南前面介绍的模块 token 类似。
避免构建期条件判断
检查代码中所有用构建扩展时的 Python 版本来做判断的 API。在 abi3t 下,构建版本不再等于扩展运行时的 Python 版本,依赖该信息的代码通常都要改。需要检查的宏:
PY_VERSION_HEX、PY_MAJOR_VERSION、PY_MINOR_VERSION:- 获取运行时版本,用
Py_Version; - 判断可用哪些 C API,用
Py_TARGET_ABI3T。该宏被设置为你支持的最小版本。
- 获取运行时版本,用
Py_GIL_DISABLED:在abi3t下该宏恒被定义。能配合 Free-Threaded Python 工作的代码应当也配合 GIL 启用的构建工作(因为 GIL 可以在运行时启用),实际上通常确实如此(除非代码因某种原因需要同时持有多个 attached thread state)。
一个值得注意的实现细节:仓库的测试扩展(test_cext)在 abi3t 编译时,会借助 GCC 的 pragma GCC poison 把 Py_GIL_DISABLED "毒化",使任何对其做 #if 判断的代码在预处理阶段直接报错——见 Include/pyabi.h。这从机制上强制了"abi3t 模块的 Python.h 内容不得依赖 Py_GIL_DISABLED"这一约束。
其余代码修改
如果仍然有编译错误或警告,想办法修复它们。遗憾的是本指南篇幅有限,无法覆盖扩展可能需要的一切代码变更。
如果你发现其他扩展作者也可能遇到的问题,考虑为这份指南提交 issue(或 PR)。你的问题可能无法在当前 abi3t 版本下修复;即便如此,报告它也有助于在 CPython 的下一个版本中优先处理。
标签与分发
如果使用支持 abi3t 的构建工具,你的扩展已经就绪,但建议确认构建正确。abi3t 构建的扩展应当具有以下扩展名:
- Windows:
.pyd(与任何其他扩展一样); - Linux、macOS 及其他使用
.so后缀的系统:.abi3t.so(不是.cpython-315t.so,也不是.abi3.so)。注意 Free-Threaded 与非 Free-Threaded 构建都会加载.abi3t.so扩展; - 其他系统:请咨询你的发行方,并考虑更新这份指南。
如果以 wheel 分发扩展,使用以下标签:
- Python 标签:
cp3{XX},其中 XX 是扩展所构建的最小 Python 版本(例如设置了Py_TARGET_ABI3T为0x30f0000时就是cp315)。 - ABI 标签:
abi3.abi3t。这是一个压缩标签集(compressed tag set),表示同时支持非 Free-Threaded 与 Free-Threaded 两种构建。
例如,wheel 文件名可能是:
myproject-1.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl
如果文件名或标签不正确,修正它们。
测试
注意:当你构建兼容多个 CPython 版本的扩展时,务必在每个支持的版本上(例如 3.15、3.16 等等)都进行测试。Stable ABI 只保证 ABI 兼容性;行为也可能变化——既包括有意的变化(由相关 PEP 覆盖),也包括 bug。
一定要在 Free-Threaded 与非 Free-Threaded 两种 CPython 构建上都跑测试。
如果测试通过,恭喜——你拥有了一个 abi3t 扩展。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00