隔离扩展模块

谁应该阅读此内容

本指南是为 C-API 扩展的维护者编写的,他们希望使该扩展在将 Python 本身用作库的应用程序中更安全地使用。

背景

解释器是 Python 代码运行的上下文。它包含配置(例如导入路径)和运行时状态(例如导入模块集)。

Python 支持在一个进程中运行多个解释器。有两种情况需要考虑——用户可以运行解释器

当将 Python 嵌入到库中时,这两种情况(以及它们的组合)将是最有用的。库通常不应假定使用它们的应用程序,这包括假定进程范围的“主 Python 解释器”。

历史上,Python 扩展模块不能很好地处理这个用例。许多扩展模块(甚至一些标准库模块)使用进程范围的全局状态,因为 C static 变量非常容易使用。因此,应该特定于解释器的数据最终会在解释器之间共享。除非扩展开发人员小心,否则在同一个进程中将模块加载到多个解释器中时,很容易引入导致崩溃的边界情况。

不幸的是,每个解释器状态并不容易实现。扩展作者在开发时往往不考虑多个解释器,并且当前测试行为很麻烦。

引入 Per-Module 状态

Python 的 C API 正在演进,以更好地支持更细粒度的每个模块状态,而不是专注于每个解释器状态。这意味着 C 级别的数据应该附加到模块对象。每个解释器创建自己的模块对象,使数据分离。为了测试隔离,甚至可以在单个解释器中加载对应于单个扩展的多个模块对象。

每个模块状态提供了一种简单的方法来思考生命周期和资源所有权:扩展模块将在创建模块对象时初始化,并在释放时清理。在这方面,模块就像任何其他 PyObject* 一样;没有“解释器关闭时”的钩子需要考虑——或忘记。

请注意,存在针对不同类型“全局变量”的用例:进程、解释器、线程或任务状态。默认情况下,每个模块状态仍然是可能的,但您应该将它们视为特殊情况:如果您需要它们,您应该给予它们额外的关注和测试。(请注意,本指南不涵盖它们。)

隔离的模块对象

开发扩展模块时要记住的关键点是,可以从单个共享库创建多个模块对象。例如

>>> 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 级别使扩展模块安全,而不是使黑客行为直观。手动修改 sys.modules 算作黑客行为。

使模块在多个解释器中安全

管理全局状态

有时,与 Python 模块关联的状态并非特定于该模块,而是特定于整个进程(或比模块“更全局”的某个其他事物)。例如

  • readline 模块管理终端

  • 运行在电路板上的模块想要控制板载 LED

在这些情况下,Python 模块应该提供对全局状态的访问,而不是拥有它。如果可能,编写模块,使其多个副本可以独立访问状态(以及其他库,无论是 Python 还是其他语言)。如果不可能,请考虑显式锁定。

如果必须使用进程全局状态,避免多解释器问题的最简单方法是明确防止模块在每个进程中加载一次以上——参见选择退出:限制每个进程一个模块对象

管理 Per-Module 状态

要使用每个模块状态,请使用多阶段扩展模块初始化。这表示您的模块正确支持多个解释器。

PyModuleDef.m_size 设置为正数,以请求模块本地的字节存储空间。通常,这将被设置为某个模块特定 struct 的大小,该结构可以存储模块的所有 C 级状态。特别是,您应该将指向类(包括异常,但不包括静态类型)和设置(例如 csvfield_size_limit)的指针放在此处,这些是 C 代码正常运行所必需的。

备注

另一个选项是将状态存储在模块的 __dict__ 中,但您必须避免在用户从 Python 代码修改 __dict__ 时崩溃。这通常意味着在 C 级别进行错误和类型检查,这很容易出错并且难以充分测试。

然而,如果 C 代码中不需要模块状态,则仅将其存储在 __dict__ 中是一个好主意。

如果模块状态包含 PyObject 指针,则模块对象必须持有对这些对象的引用,并实现模块级钩子 m_traversem_clearm_free。它们的作用类似于类的 tp_traversetp_cleartp_free。添加它们将需要一些工作并使代码更长;这是模块可以干净卸载的代价。

一个具有每个模块状态的模块示例目前可在 xxlimited 中找到;文件底部显示了模块初始化示例。

选择退出:限制每个进程一个模块对象

非负的 PyModuleDef.m_size 表示模块正确支持多个解释器。如果您的模块尚未如此,您可以明确地使您的模块每个进程只能加载一次。例如

// 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
}

如果您的模块的 PyModuleDef.m_clear 函数能够为将来的重新初始化做准备,它应该清除 loaded 标志。在这种情况下,您的模块将不支持多个实例并发存在,但它将支持,例如,在 Python 运行时关闭 (Py_FinalizeEx()) 和重新初始化 (Py_Initialize()) 之后加载。

函数中访问模块状态

从模块级函数访问状态很简单。函数将模块对象作为它们的第一个参数;为了提取状态,您可以使用 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
}

备注

如果没有模块状态,即 PyModuleDef.m_size 为零,PyModule_GetState 可能会返回 NULL 而不设置异常。在您自己的模块中,您可以控制 m_size,因此这很容易避免。

堆类型

传统上,C 代码中定义的类型是静态的;也就是说,直接在代码中定义并使用 PyType_Ready() 初始化的 static PyTypeObject 结构。

这些类型必然在进程之间共享。在模块对象之间共享它们需要注意它们拥有或访问的任何状态。为了限制可能的问题,静态类型在 Python 级别是不可变的:例如,您不能设置 str.myattribute = 123

CPython 实现细节: 在解释器之间共享真正不可变的对象是没问题的,只要它们不提供对可变对象的访问。然而,在 CPython 中,每个 Python 对象都有一个可变的实现细节:引用计数。引用计数的更改由 GIL 保护。因此,在解释器之间共享任何 Python 对象的代码隐含地依赖于 CPython 当前的进程范围 GIL。

由于它们是不可变且进程全局的,静态类型无法访问“它们”的模块状态。如果此类类型的任何方法需要访问模块状态,则该类型必须转换为堆分配类型,或简称堆类型。这些更接近于 Python 的 class 语句创建的类。

对于新模块,默认使用堆类型是一个很好的经验法则。

将静态类型更改为堆类型

静态类型可以转换为堆类型,但请注意,堆类型 API 并非为静态类型的“无损”转换而设计——即,创建与给定静态类型完全相同的类型。因此,在新的 API 中重写类定义时,您可能会无意中更改一些细节(例如,可pickle性或继承的槽)。请始终测试对您重要的细节。

特别注意以下两点(但请注意,这并非详尽列表)

定义堆类型

堆类型可以通过填充 PyType_Spec 结构(一个类的描述或“蓝图”)并调用 PyType_FromModuleAndSpec() 来构造新的类对象。

备注

其他函数,如 PyType_FromSpec(),也可以创建堆类型,但 PyType_FromModuleAndSpec() 将模块与类关联起来,允许从方法访问模块状态。

类通常应该同时存储在模块状态(为了从 C 安全访问)和模块的 __dict__ 中(为了从 Python 代码访问)。

垃圾回收协议

堆类型的实例持有对其类型的引用。这确保了类型不会在其所有实例之前被销毁,但也可能导致需要由垃圾收集器打破的引用循环。

为了避免内存泄漏,堆类型的实例必须实现垃圾收集协议。也就是说,堆类型应该

  • 具有 Py_TPFLAGS_HAVE_GC 标志。

  • 使用 Py_tp_traverse 定义一个遍历函数,该函数访问类型(例如,使用 Py_VISIT(Py_TYPE(self)))。

请参考 Py_TPFLAGS_HAVE_GCtp_traverse 的文档以获取额外考虑。

定义堆类型的 API 是有机发展的,导致其在当前状态下使用起来有些笨拙。以下部分将指导您解决常见问题。

Python 3.8 及更低版本中的 tp_traverse

Python 3.9 中添加了从 tp_traverse 访问类型的要求。如果您支持 Python 3.8 及更低版本,遍历函数不能访问类型,因此它必须更复杂

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 中添加。作为替代,请使用

委托 tp_traverse

如果您的遍历函数委托给其基类的 tp_traverse(或另一个类型),请确保 Py_TYPE(self) 只访问一次。请注意,只有堆类型才期望在 tp_traverse 中访问类型。

例如,如果你的遍历函数包含

base->tp_traverse(self, visit, arg)

……并且 base 可能是一个静态类型,那么它也应该包含

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 函数,它需要

为了在调用 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

堆类型的 tp_free 槽必须设置为 PyObject_GC_Del()。这是默认值;不要覆盖它。

避免使用 PyObject_New

GC 跟踪的对象需要使用 GC 感知函数分配。

如果您使用 PyObject_New()PyObject_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_New()PyObject_GC_NewVar()

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

类中访问模块状态

如果您有一个使用 PyType_FromModuleAndSpec() 定义的类型对象,您可以调用 PyType_GetModule() 来获取关联的模块,然后调用 PyModule_GetState() 来获取模块的状态。

为了省去一些繁琐的错误处理样板代码,您可以使用 PyType_GetModuleState() 将这两个步骤结合起来,结果是

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

常规方法中访问模块状态

从类的方法访问模块级状态稍微复杂一些,但由于 Python 3.9 中引入的 API,这是可能的。要获取状态,您需要首先获取定义类,然后从它获取模块状态。

最大的障碍是获取定义方法时所在的类,或简称该方法的“定义类”。定义类可以引用它所属的模块。

不要将定义类与 Py_TYPE(self) 混淆。如果方法在您的类型的子类上调用,Py_TYPE(self) 将引用该子类,该子类可能在与您的模块不同的模块中定义。

备注

以下 Python 代码可以说明这个概念。Base.get_defining_class 返回 Base,即使 type(self) == Sub

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},
}

槽方法、getter 和 setter 中访问模块状态

备注

这是 Python 3.11 中的新功能。

槽方法——特殊方法的快速 C 等价物,例如 nb_add 用于 __add__tp_new 用于初始化——具有非常简单的 API,不允许传入定义类,与 PyCMethod 不同。对于使用 PyGetSetDef 定义的 getter 和 setter 也是如此。

在这些情况下,要访问模块状态,请使用 PyType_GetModuleByDef() 函数,并传入模块定义。一旦您获得了模块,调用 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() 通过搜索方法解析顺序(即所有超类)来查找第一个具有相应模块的超类。

备注

在非常特殊的情况下(继承链跨越由同一定义创建的多个模块),PyType_GetModuleByDef() 可能不会返回真正定义类的模块。但是,它始终会返回具有相同定义的模块,从而确保兼容的 C 内存布局。

模块状态的生命周期

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

通常这并不是一个问题,因为使用 PyType_FromModuleAndSpec() 创建的类型及其实例,都持有对模块的引用。但是,当您从其他地方(例如外部库的回调)引用模块状态时,必须小心引用计数。

未解决的问题

围绕每个模块状态和堆类型的一些问题仍然悬而未决。

关于改进现状的讨论最好在c-api 标签下的讨论论坛上进行。

Per-Class 范围

目前(截至 Python 3.11),不可能将状态附加到单个类型,而不依赖于 CPython 实现细节(这些细节将来可能会改变——也许,讽刺地,是为了允许每个类范围的正确解决方案)。

无损转换为堆类型

堆类型 API 不是为静态类型的“无损”转换而设计的;也就是说,创建与给定静态类型完全相同的类型。