隔离扩展模块

谁应该阅读本文

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

背景

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

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

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

历史上,Python 扩展模块在处理这种用例方面表现不佳。许多扩展模块(甚至一些标准库模块)使用每个进程的全局状态,因为 C static 变量非常易于使用。因此,应该特定于解释器的數據最终会在解释器之间共享。除非扩展开发人员小心谨慎,否则当模块在同一进程中的多个解释器中加载时,很容易引入导致崩溃的边缘情况。

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

进入每个模块状态

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 还是其他语言)。如果不可能,请考虑显式锁定。

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

管理每个模块的状态

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

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 表示模块正确地支持多个解释器。如果您的模块还没有做到这一点,您可以明确地使您的模块只能在每个进程中加载一次。例如

static int loaded = 0;

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

从函数访问模块状态

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

注意

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

堆类型

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

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

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

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

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

将静态类型更改为堆类型

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

特别注意以下两点(但请注意,这不是一个完整的列表)

定义堆类型

可以通过填充 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 是有机增长的,使其在当前状态下使用起来有些笨拙。以下部分将指导您解决常见问题。

tp_traverse 在 Python 3.8 及更低版本中

tp_traverse 访问类型的要求是在 Python 3.9 中添加的。如果您支持 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},
}

从槽方法、获取器和设置器访问模块状态

注意

这是 Python 3.11 中的新功能。

槽方法——特殊方法的快速 C 等效项,例如 nb_add 用于 __add__tp_new 用于初始化——具有非常简单的 API,不允许传入定义类,与 PyCMethod 不同。使用 PyGetSetDef 定义的获取器和设置器也是如此。

在这些情况下,要访问模块状态,请使用 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() 创建的类型及其实例会持有对模块的引用。但是,当您从其他地方(例如外部库的回调)引用模块状态时,您必须注意引用计数。

未解决的问题

围绕每个模块状态和堆类型的几个问题仍然存在。

关于改进这种情况的讨论最好在 capi-sig 邮件列表 上进行。

每个类范围

目前(截至 Python 3.11)无法将状态附加到单个类型,而无需依赖 CPython 实现细节(这些细节将来可能会改变,也许,具有讽刺意味的是,为了允许对每个类范围的适当解决方案)。

无损转换为堆类型

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