首页
/ CPython C 扩展模块隔离指南:per-module state 与 heap types 的完整实践

CPython C 扩展模块隔离指南:per-module state 与 heap types 的完整实践

2026-09-06 17:31:56作者:胡易黎Nicole

本篇指南基于 CPython 官方文档 Doc/howto/isolating-extensions.rst 展开,主题是如何把 C 扩展模块从“进程级全局状态”改造为“模块级状态(per-module state)”,使其在 Python 被当作库嵌入(多次初始化/反初始化、子解释器并行)的场景下安全运行。读完本文,你将掌握:如何用 Modules/xxlimited.c 这样的参考实现来设计模块状态、如何通过 m_size 请求模块私有存储、如何把静态类型(static type)改造为堆类型(heap type),以及模块函数、类方法、slot 方法三种场景下访问模块状态的完整 API 调用链,并能在源码级别(Include/moduleobject.hObjects/moduleobject.cObjects/typeobject.c)验证这些机制。

适用读者与背景:为什么进程级状态是隐患

谁来读这份指南

本指南面向 C-API 扩展的维护者,目标是让你的扩展在“Python 自身被用作库”的应用中更安全。典型场景是宿主程序在一个进程中反复执行 Py_InitializeEx/Py_FinalizeEx 循环,或用 Py_NewInterpreter/Py_EndInterpreter 管理并行的子解释器。库代码通常不应假设宿主存在一个进程级的“主解释器”,但历史上大量扩展模块(甚至部分标准库模块)因为 C static 变量用起来实在太方便,而把本应属于某个解释器的数据放在了进程级全局状态里。

核心问题:数据被多个解释器共享

当一个模块同时加载进同一进程中的多个解释器时,进程级 static 状态会被共享,极易引入导致崩溃的边界情况。更麻烦的是 per-interpreter 状态本身难以实现——扩展作者开发时往往根本没考虑多解释器,而且测试这种行为的成本很高。

CPython 的应对方向是让 C-API 向更细粒度的 per-module state 演进:C 层数据挂到“模块对象”上。每个解释器创建自己的模块对象,数据天然隔离;为了测试隔离性,甚至可以在同一解释器中加载同一扩展的多个模块对象。per-module state 还提供了直观的生命周期与资源归属模型:模块对象创建时初始化、释放时清理。就像任何其他 PyObject * 一样,不需要(也无需忘记)“解释器关闭钩子”。

需要注意的是,per-process、per-interpreter、per-thread 或 per-task 状态仍有其用途。以 per-module 为默认之后,这些需求仍然可以实现,但应视为例外情况,需要额外的设计与测试(本文不展开)。

隔离的模块对象:同一共享库可以产生多个模块对象

开发扩展模块时牢记一点:同一个共享库可以创建出多个模块对象。文档给出的示例如下:

>>> import sys
>>> import binascii
>>> old_binascii = binascii
>>> del sys.modules['binascii']
>>> import binascii  # create a new module object
>>> old_binascii == binascii
False

经验法则是:两个模块应当完全独立。模块私有的对象和状态都应封装在模块对象内部,不与其他模块对象共享,并在模块对象被回收时清理。由于只是经验法则,例外是允许的(见下文“管理全局状态”),但例外需要更多对边界情况的思考。

反直觉的边界情况:异常类不共享

隔离模块会带来一些出乎意料的边界情况。最典型的是:每个模块对象通常不会与其他同名模块共享其类和异常。接上例,old_binascii.Errorbinascii.Error 是两个不同对象,下面的异常捕获不到

>>> old_binascii.Error == binascii.Error
False
>>> try:
...     old_binascii.unhexlify(b'qwertyuiop')
... except binascii.Error:
...     print('boo')
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
binascii.Error: Non-hexadecimal digit found

这是预期行为——纯 Python 模块也是同样的表现,属于 Python 工作机制的一部分。目标是让扩展在 C 层面安全,而不是让 hack 行为直观;手动改 sys.modules 就属于 hack。

管理全局状态:模块是“访问者”而非“所有者”

有些模块的状态不属于模块本身,而是属于整个进程。典型例子:

  • readline 模块管理的是 那一个 终端;
  • 运行在电路板上的模块要控制 那块板上的 LED。

此时 Python 模块应该提供对全局状态的访问,而不是拥有它。如果可能,应写成允许多个副本独立访问该状态(与其他语言的库并存);做不到时,考虑显式加锁。

如果确有必要使用进程级全局状态,最简单的避免多解释器问题的方式是:显式禁止模块在一个进程中加载超过一次

Opt-Out:用进程级标志限制每进程一个模块对象

PyModuleDef.m_size >= 0 表示模块正确支持多解释器;如果你的模块还没做到,可以用下面的方式让它每进程只能加载一次:

// A process-wide flag
static int loaded = 0;

// Mutex to provide thread safety (only needed for free-threaded Python)
static PyMutex modinit_mutex = {0};

static int
exec_module(PyObject* module)
{
    PyMutex_Lock(&modinit_mutex);
    if (loaded) {
        PyMutex_Unlock(&modinit_mutex);
        PyErr_SetString(PyExc_ImportError,
                        "cannot load module more than once per process");
        return -1;
    }
    loaded = 1;
    PyMutex_Unlock(&modinit_mutex);
    // ... rest of initialization
}

Include/moduleobject.h 可以看到 m_sizestruct PyModuleDef 中的位置(该结构还包含 m_methodsm_slots 以及 m_traversem_clearm_free 三个状态钩子),而 PyModuleDef_Base 中的注释明确写着 m_init 只在 legacy 模块且 m_size >= 0 时由 _PyImport_LoadDynamicModuleWithSpec() 等路径设置——也就是说,m_size 的符号位是解释器判断“是否支持多次初始化”的开关。

如果模块的 PyModuleDef.m_clear 能够为将来重新初始化做好准备,就应当在那里清除 loaded 标志。此时模块虽然不支持多个实例并发存在,但支持在 Python 运行时关闭(Py_FinalizeEx)后重新初始化(Py_Initialize)再加载。

管理模块状态:m_size 与状态钩子

用 m_size 请求模块私有存储

使用 per-module state 的前提是采用多阶段(multi-phase)扩展模块初始化。这同时向解释器宣告你的模块正确支持多解释器。

PyModuleDef.m_size 设为正数,即请求相应字节的模块本地存储。惯例是把它设为某个模块专用 struct 的大小,用来保存模块的全部 C 层状态,尤其是:类指针(含异常类,但不含 static types)和 C 代码运行所需的设置(例如 csvfield_size_limit)。

参考实现 Modules/xxlimited.c 的状态结构长这样(该文件头部注释也自述它是 Limited API 模板,建议改名为你的模块名后使用):

// Module state
typedef struct {
    PyTypeObject *Xxo_Type;    // Xxo class
    PyObject *Error_Type;       // Error class
} xx_state;

在 3.15 新版 API 下,该模块用 slots 数组声明状态大小与钩子(见 Modules/xxlimited.cPySlot_SIZE(Py_mod_state_size, sizeof(xx_state))PySlot_FUNC(Py_mod_state_traverse, xx_traverse) 等);而在更早的写法中,对应的 Modules/xxlimited_35.c 则直接使用 struct PyModuleDef 字面量,其中 m_size0(无状态模块,钩子均为 NULL):

static struct PyModuleDef xxmodule = {
    PyModuleDef_HEAD_INIT,
    "xxlimited_35",
    module_doc,
    0,          // m_size
    xx_methods,
    xx_slots,
    NULL,       // m_traverse
    NULL,       // m_clear
    NULL        // m_free
};

存进 dict 的风险与适用场景

另一个选择是把状态放进模块的 __dict__,但前提是你能保证用户从 Python 侧改坏 __dict__ 时不崩溃——这通常意味着 C 层的错误处理与类型检查,容易写错且难以充分测试。如果 C 代码不需要直接访问模块状态,那么只存 __dict__ 是合理选择。

状态里含 PyObject 指针时必须实现三个钩子

若模块状态包含 PyObject 指针,模块对象必须持有这些对象的引用,并实现模块级钩子 m_traversem_clearm_free。它们的角色与类的 tp_traversetp_cleartp_free 相同。添加这些钩子需要额外工作、代码变长——这就是“模块可干净卸载”的代价。

Modules/xxlimited.c 给出了完整的三件套写法:

static int
xx_traverse(PyObject *module, visitproc visit, void *arg)
{
    xx_state *state = PyModule_GetState_DuringGC(module);
    if (state == NULL) {
        return 0;
    }
    Py_VISIT(state->Xxo_Type);
    Py_VISIT(state->Error_Type);
    return 0;
}

static int
xx_clear(PyObject *module)
{
    xx_state *state = PyModule_GetState(module);
    if (state == NULL) {
        return 0;
    }
    Py_CLEAR(state->Xxo_Type);
    Py_CLEAR(state->Error_Type);
    return 0;
}

static void
xx_free(void *module)
{
    // allow xx_modexec to omit calling xx_clear on error
    (void)xx_clear((PyObject *)module);

    xx_state *state = PyModule_GetState(module);
    if (state == NULL) {
        return;
    }
}

注意两个细节:m_traverse 中要用 PyModule_GetState_DuringGC 而非普通 PyModule_GetState(后者不允许在 GC 遍历期间调用),m_free 中先兜底执行一次 xx_clear,保证初始化中途失败也不会泄漏引用。

在源码层面,PyModule_GetState 的实现非常薄(见 Objects/moduleobject.c):校验 PyModule_Check(m) 后直接返回 _PyModule_GetState(m)。这也印证了文档的提醒:m_size 为 0(无模块状态)时,PyModule_GetState 可能在未设置异常的情况下返回 NULL——在你自己的模块里 m_size 由你控制,因此可以轻松规避。

Heap Types:从静态类型到堆类型

静态类型为什么不够

传统上 C 代码中定义的类型是静态的:static PyTypeObject 结构体直接写死在代码里,用 PyType_Ready() 初始化。这种类型必然被整个进程共享;在不同模块对象之间共享它们,就必须留意它们拥有或访问的任何状态。为了限制问题范围,静态类型在 Python 层面是不可变的——例如你无法执行 str.myattribute = 123

实现细节上(文档以 impl-detail 标注):共享真正不可变的对象本身没问题,只要它们不暴露可变对象。但 CPython 中每个 Python 对象都有一个可变的实现细节——引用计数。引用计数的修改受 GIL 保护,因此跨解释器共享任何 Python 对象,都隐式依赖 CPython 当前进程级 GIL 的实现

由于静态类型不可变、进程全局,它们无法访问“自己那个”模块状态。只要某个方法需要访问模块状态,该类型就必须改造为堆分配类型(heap type)——它更接近 Python class 语句创建的类。对新模块,默认使用 heap types 是好习惯。

静态类型转堆类型:两个必须注意的差异

静态类型可以转换为堆类型,但注意:heap type API 并非为“无损”转换而设计——即无法保证造出一个与给定静态类型行为完全一致的类型。改用新 API 重写类定义时,很容易无意中改变一些细节(如可 pickle 性、继承的 slots),务必测试你在意的每个细节。特别留意(非穷尽)以下两点:

  • 与静态类型不同,heap type 对象默认可变。用 Py_TPFLAGS_IMMUTABLETYPE 标志禁止可变性。
  • heap types 默认继承 tp_new,因此可能可以从 Python 代码实例化。用 Py_TPFLAGS_DISALLOW_INSTANTIATION 标志阻止这一点。

用 PyType_Spec 与 PyType_FromModuleAndSpec 定义堆类型

堆类型通过填充 PyType_Spec 结构(类的“蓝图”)并调用 PyType_FromModuleAndSpec 来创建。注意:PyType_FromSpec 等函数也能创建 heap type,但 PyType_FromModuleAndSpec 会把模块与类关联起来,从而允许从方法中访问模块状态。

类对象应当同时存放在两处:模块状态中(供 C 代码安全访问)和模块的 __dict__ 中(供 Python 代码访问)。Modules/xxlimited.c 的初始化代码示范了这一点:

state->Error_Type = PyErr_NewException("xxlimited.Error", NULL, NULL);
if (state->Error_Type == NULL) {
    return -1;
}
if (PyModule_AddType(m, (PyTypeObject*)state->Error_Type) < 0) {
    return -1;
}

state->Xxo_Type = (PyTypeObject*)PyType_FromModuleAndSpec(
    m, &Xxo_Type_spec, NULL);
if (state->Xxo_Type == NULL) {
    return -1;
}
if (PyModule_AddType(m, state->Xxo_Type) < 0) {
    return -1;
}

PyModule_AddType 会把类同时放进模块 __dict__ 并持有引用——类既保存在 xx_state 里,也暴露在模块命名空间中。该文件还展示了另一种用法:Str_Type 只需从 Python 访问,因此只加入模块字典(基类指定为 PyUnicode_Type)。在 C 侧,由于 Xxo 类型是动态分配的、一个进程中可能存在多份(不同子解释器或重复加载),文件里通过 Xxo_state_from_type() 基于静态不变的 Xxo_Type_spec 做基类搜索来回溯模块状态,而非保存任何“全局”类型指针——这正是多模块对象场景下的正确姿势。

GC 协议:heap type 实例必须能参与回收

heap type 的实例持有对其类型的引用(保证类型晚于实例存活),但这可能形成引用环,需要垃圾回收器来打破。为避免内存泄漏,heap type 实例必须实现 GC 协议:

  • 带上 Py_TPFLAGS_HAVE_GC 标志;
  • Py_tp_traverse 定义 traverse 函数,访问其类型(例如 Py_VISIT(Py_TYPE(self)))。

heap type 定义 API 是自然生长出来的,目前用起来略别扭。以下逐条给出常见问题的官方建议。

tp_traverse 在 Python 3.8 及更早版本的处理

“从 tp_traverse 访问类型”这一要求是 Python 3.9 加入的。若还要支持 3.8 及更早版本,traverse 函数不能访问类型,必须写得更复杂:

static int my_traverse(PyObject *self, visitproc visit, void *arg)
{
    if (Py_Version >= 0x03090000) {
        Py_VISIT(Py_TYPE(self));
    }
    return 0;
}

可惜 Py_Version 是 Python 3.11 才有的宏。替代方案:

  • 不使用 Stable ABI 时用 PY_VERSION_HEX
  • 或通过 PySys_GetObjectPyArg_ParseTuple 读取 sys.version_info

委托 tp_traverse 时避免重复访问

若 traverse 函数委托给基类(或其他类型)的 tp_traverse,确保 Py_TYPE(self) 只被访问一次。注意只有 heap type 才被期望在 tp_traverse 中访问类型。例如若你的 traverse 包含:

base->tp_traverse(self, visit, arg)

base 可能是 static type,则还应写:

if (base->tp_flags & Py_TPFLAGS_HEAPTYPE) {
    // a heap type's tp_traverse already visited Py_TYPE(self)
} else {
    if (Py_Version >= 0x03090000) {
        Py_VISIT(Py_TYPE(self));
    }
}

无需在 tp_newtp_clear 中处理类型的引用计数。

自定义 tp_dealloc 的两件事

若类型有自定义 tp_dealloc,必须:

  • 在使任何字段失效之前调用 PyObject_GC_UnTrack
  • 递减类型的引用计数。

为了让类型在 tp_free 被调用期间保持有效,类型引用计数必须在实例被释放之后再递减。例如:

static void my_dealloc(PyObject *self)
{
    PyObject_GC_UnTrack(self);
    ...
    PyTypeObject *type = Py_TYPE(self);
    type->tp_free(self);
    Py_DECREF(type);
}

默认 tp_dealloc 已经做了这件事,所以如果你的类型没有覆写 tp_dealloc,就不需要自己加。

不要覆写 tp_free

heap type 的 tp_free 槽必须是 PyObject_GC_Del。这是默认值,不要覆盖。

避免 PyObject_New:GC 对象要用 GC 感知的分配函数

若你使用 PyObject_NewPyObject_NewVar

  • 在可能的情况下,取类型的 tp_alloc 槽并调用它。即把 TYPE *o = PyObject_New(TYPE, typeobj) 替换为:
TYPE *o = typeobj->tp_alloc(typeobj, 0);

o = PyObject_NewVar(TYPE, typeobj, size) 同理替换,只是用 size 代替 0

  • 若上面做不到(例如在自定义 tp_alloc 内部),改调 PyObject_GC_NewPyObject_GC_NewVar
TYPE *o = PyObject_GC_New(TYPE, typeobj);

TYPE *o = PyObject_GC_NewVar(TYPE, typeobj, size);

三种场景下访问模块状态

场景一:模块级函数

模块级函数访问状态很直接:函数拿到模块对象作为第一个参数,用 PyModule_GetState 提取状态:

static PyObject *
func(PyObject *module, PyObject *args)
{
    my_struct *state = (my_struct*)PyModule_GetState(module);
    if (state == NULL) {
        return NULL;
    }
    // ... rest of logic
}

再次注意:m_size 为 0 时 PyModule_GetState 可能在未设置异常的情况下返回 NULL;你自己的模块掌控 m_size,因此容易规避。

场景二:类方法(3.9+ 的 PyCMethod 协议)

从类方法访问模块状态稍复杂,但得益于 Python 3.9 引入的 API 可以实现。关键第一步是拿到定义类(defining class),再从它获取模块状态。最大的障碍是如何拿到“方法定义于哪个类”。

不要把定义类与 Py_TYPE(self) 混淆:当方法在类型的子类上被调用时,Py_TYPE(self) 指向那个子类,它可能定义在你的模块之外。文档用这段 Python 代码解释概念——即使 type(self) == SubBase.get_defining_class 仍返回 Base

class Base:
    def get_type_of_self(self):
        return type(self)

    def get_defining_class(self):
        return __class__

class Sub(Base):
    pass

要拿到定义类,方法必须采用 METH_METHOD | METH_FASTCALL | METH_KEYWORDS 调用约定和对应的 PyCMethod 签名:

PyObject *PyCMethod(
    PyObject *self,               // object the method was called on
    PyTypeObject *defining_class, // defining class
    PyObject *const *args,        // C array of arguments
    Py_ssize_t nargs,             // length of "args"
    PyObject *kwnames)            // NULL, or dict of keyword arguments

拿到定义类后调用 PyType_GetModuleState 即可:

static PyObject *
example_method(PyObject *self,
        PyTypeObject *defining_class,
        PyObject *const *args,
        Py_ssize_t nargs,
        PyObject *kwnames)
{
    my_struct *state = (my_struct*)PyType_GetModuleState(defining_class);
    if (state == NULL) {
        return NULL;
    }
    ... // rest of logic
}

PyDoc_STRVAR(example_method_doc, "...");

static PyMethodDef my_methods[] = {
    {"example_method",
      (PyCFunction)(void(*)(void))example_method,
      METH_METHOD|METH_FASTCALL|METH_KEYWORDS,
      example_method_doc}
    {NULL},
}

PyType_GetModuleState 本身就是 PyType_GetModule + PyModule_GetState 的组合,省去样板错误处理:

my_struct *state = (my_struct*)PyType_GetModuleState(type);
if (state == NULL) {
    return NULL;
}

从源码看(Objects/typeobject.c),PyType_GetModuleState 内部就是先取类型关联的模块、再调 _PyModule_GetState,与文档描述一致;Lib/test/test_stable_abi_ctypes.py 中也列有 PyType_GetModuleStatePyType_GetModuleByDef 等符号,佐证这些函数属于受限 API(limited API)的测试覆盖范围。

场景三:slot 方法与 getter/setter(3.11+)

该能力为 Python 3.11 新增。若使用 limited API,必须把 Py_LIMITED_API 升到 0x030b0000,将失去与更早版本的 ABI 兼容。

slot 方法(特殊方法的快速 C 实现,如 nb_add 对应 __add__tp_new 对应初始化)的 API 非常简单,不像 PyCMethod 那样能传入定义类;用 PyGetSetDef 定义的 getter/setter 亦然。

这种情况用 PyType_GetModuleByDef,把模块定义(PyModuleDef)作为参数传入;拿到模块后再调 PyModule_GetState

PyObject *module = PyType_GetModuleByDef(Py_TYPE(self), &module_def);
my_struct *state = (my_struct*)PyModule_GetState(module);
if (state == NULL) {
    return NULL;
}

PyType_GetModuleByDef 的工作方式是搜索 MRO(即所有超类),找到第一个带有对应模块的超类。实现见 Objects/typeobject.c——找不到时会抛出 "No superclass of '%s' has the given module" 错误。文档还特别指出:在极特殊的场景(跨多个由同一定义创建的模块的继承链)下,它可能不返回真正定义类的模块,但总会返回一个定义相同的模块,从而保证兼容的 C 内存布局。

模块状态的生命周期与未决问题

生命周期:状态指针的存活依赖模块引用

模块对象被垃圾回收时,其模块状态随之释放。因此对于每一个指向(状态的一部分的)指针,你必须持有模块对象的引用。

通常这不是问题:用 PyType_FromModuleAndSpec 创建的类型(以及它们的实例)都持有对模块的引用。但当你从别处引用模块状态时(例如外部库的回调)必须格外小心引用计数。

未决问题:per-class scope 与无损转换

文档指出,围绕 per-module state 与 heap types 仍有若干未决问题(相关讨论建议在 C-API 社区论坛进行):

  • Per-class scope:截至 Python 3.11,无法在不依赖 CPython 实现细节(未来可能变化——也许 ironically 正是为了允许 per-class scope 的正解)的前提下,把状态挂到单独的类型上。
  • 无损转换 heap types:heap type API 并非为静态类型的“无损”转换而设计——无法保证造出一个与给定静态类型行为完全一致的类型。

小结:一张可执行清单

结合 Doc/howto/isolating-extensions.rst 与仓库实现,改造一个 C 扩展模块的推荐路径是:

  1. 确认初始化方式:采用 multi-phase 初始化,把 PyModuleDef.m_size 设为模块状态 struct 的大小(参考 Include/moduleobject.hstruct PyModuleDef 的字段定义);无法做到时,用 loaded 标志 + 互斥量做每进程一次的 opt-out。
  2. 状态入模块,类与设置入状态:类指针(含异常类)与 C 侧需要的配置全部放进模块状态;C 代码不需要读的状态只放 __dict__ 即可。
  3. 状态含 PyObject 指针时:实现 m_traverse(GC 期间用 PyModule_GetState_DuringGC)、m_clearm_free,参考 Modules/xxlimited.c 的完整三件套。
  4. 类型全部改为 heap typePyType_Spec + PyType_FromModuleAndSpec,类同时存入状态与模块字典;按需加 Py_TPFLAGS_IMMUTABLETYPEPy_TPFLAGS_DISALLOW_INSTANTIATION,遵循 GC 协议(Py_TPFLAGS_HAVE_GC + 访问 Py_TYPE(self) 的 traverse;tp_free 保持 PyObject_GC_Del;分配用 tp_alloc/PyObject_GC_New)。
  5. 按场景取状态:模块函数用 PyModule_GetState;类方法用 PyCMethoddefining_class + PyType_GetModuleState(3.9+);slot 方法与 getter/setter 用 PyType_GetModuleByDef(3.11+)。
  6. 管理好引用:任何持有状态指针的长寿命对象(回调等)必须持有模块引用。

这样改造后,你的扩展模块即使在同一进程中被加载为多个模块对象、运行于多个子解释器,其行为也能与纯 Python 模块一样边界清晰、可测试、可卸载。

登录后查看全文
热门项目推荐
相关项目推荐