Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 630 – 隔离扩展模块

作者:
Petr Viktorin <encukou at gmail.com>
讨论至:
Capi-SIG 邮件列表
状态:
最终版
类型:
信息性
创建日期:
2020 年 8 月 25 日
发布历史:
2020年7月16日

目录

重要

此 PEP 是一份历史文档。最新的规范文档现在可以在隔离扩展模块 HOWTO中找到。

×

有关如何提出更改,请参阅 PEP 1

摘要

传统上,属于 Python 扩展模块的状态保存在具有进程范围的 C static 变量中。本文档描述了此类每个进程状态的问题,以及使每个模块状态(一个更好的默认值)成为可能且易于使用的努力。

本文档还描述了如何在可能的情况下切换到每个模块状态。此转换涉及为该状态分配空间,可能从静态类型切换到堆类型,并且——也许最重要的是——从代码中访问每个模块状态。

关于此文档

作为一份信息性 PEP,本文档不引入任何更改;这些更改应在各自的 PEP(或问题,如果足够小)中完成。相反,它涵盖了跨多个版本的工作背后的动机,并指导早期采用者如何使用已完成的功能。

一旦支持合理完成,此内容可以作为HOWTO移至 Python 的文档中。同时,本着文档驱动开发的精神,此 PEP 中发现的空白可以指出工作的重点,并且可以随着新功能的实现进行更新。

无论此 PEP 何时提及扩展模块,该建议也适用于内置模块。

注意

此 PEP 包含通用建议。在遵循时,请始终考虑您项目的具体情况。

例如,虽然大部分建议适用于 Python 标准库的 C 部分,但 PEP 并未考虑标准库的具体情况(不寻常的向后兼容性问题、对私有 API 的访问等)。

与此工作相关的 PEP 是

  • PEP 384定义稳定的 ABI,它添加了用于创建堆类型的 C API
  • PEP 489多阶段扩展模块初始化
  • PEP 573从 C 扩展方法访问模块状态

本文档关注 Python 的公共 C API,并非所有 Python 实现都提供此 API。但是,此 PEP 中的任何内容都不是 CPython 特有的。

与任何信息性 PEP 一样,本文不一定代表 Python 社区的共识或建议。

动机

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

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

  • 按顺序,通过多个 Py_InitializeEx/Py_FinalizeEx 循环,以及
  • 并行,使用 Py_NewInterpreter/Py_EndInterpreter 管理“子解释器”。

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

目前,CPython 无法很好地处理这种用例。许多扩展模块(甚至一些标准库模块)使用每个进程的全局状态,因为 C static 变量非常易于使用。因此,应该特定于解释器的数据最终在解释器之间共享。除非扩展开发人员小心,否则当模块在同一进程中的多个解释器中加载时,很容易引入导致崩溃的极端情况。

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

每个模块状态的原理

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

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

目标:易于使用的模块状态

目前,在保持模块隔离的同时,执行 C API 提供的一切是繁琐或不可能的。在PEP 384的推动下,PEP 489PEP 573(以及未来计划的)中的更改旨在首先使其可能以这种方式构建模块,然后使其容易以这种方式编写新模块并转换旧模块,以便它可以成为自然的默认值。

即使每个模块状态成为默认值,也会存在不同封装级别的用例:每个进程、每个解释器、每个线程或每个任务状态。目标是将这些视为特殊情况:它们应该可能,但扩展作者需要更仔细地考虑它们。

非目标:加速和 GIL

目前正在努力通过使 GIL 成为每个解释器来加速多核 CPU 上的 CPython。虽然隔离解释器有助于这项工作,但即使没有实现加速,默认情况下使用每个模块状态也将是有益的,因为它使支持多个解释器在默认情况下更安全。

使用多个解释器确保模块安全

有许多方法可以正确支持扩展模块中的多个解释器。本文的其余部分描述了编写此类模块或转换现有模块的首选方法。

请注意,支持正在进行中;您的模块所需某些功能的 API 可能尚未准备好。

一个完整的示例模块可在xxlimited中找到。

本节假定“”是扩展模块作者。

隔离的模块对象

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

>>> 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 还是其他语言)。

如果不可能,请考虑显式加锁。

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

管理每个模块状态

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

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

注意

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

如果模块状态包含 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
}

注意

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

堆类型

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

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

注意

在解释器之间共享真正不可变的对象是没问题的,只要它们不提供对可变对象的访问。然而,在 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 <https://docs.pythonlang.cn/3/c-api/typeobj.html#c.PyTypeObject.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 {
    Py_VISIT(Py_TYPE(self));
}

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

从类访问模块状态

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

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

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

从常规方法访问模块状态

从类的访问模块级状态稍微复杂一些,但这要归功于PEP 573中引入的更改。要获取状态,您需要首先获取定义类,然后从它获取模块状态。

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

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

注意

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

class Base:
    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 等效项,例如 __add__nb_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 通过搜索MRO(即所有超类)来查找第一个具有相应模块的超类。

注意

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

模块状态的生命周期

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

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

未解决的问题

关于每个模块状态和堆类型的几个问题仍然悬而未决。

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

类型检查

目前(截至 Python 3.10),堆类型没有好的 API 来编写 Py*_Check 函数(例如 strPyUnicode_Check,一个静态类型),因此不容易确保实例具有特定的 C 布局。

元类

目前(截至 Python 3.10),没有好的 API 可以指定堆类型的元类;也就是说,类型对象的 ob_type 字段。

每个类范围

也无法将状态附加到类型。虽然 PyHeapTypeObject 是一个可变大小的对象 (PyVarObject),但其可变大小的存储目前被槽占用。解决这个问题很复杂,因为继承层次结构中的几个类可能需要保留一些状态。

无损转换为堆类型

堆类型 API 并非设计用于从静态类型进行“无损”转换;也就是说,创建与给定静态类型完全相同的类型。解决此问题的最佳方法可能是编写一份涵盖已知“陷阱”的指南。


来源:https://github.com/python/peps/blob/main/peps/pep-0630.rst

最后修改:2025-02-01 08:55:40 GMT