PEP 630 – 扩展模块隔离
- 作者:
- Petr Viktorin <encukou at gmail.com>
- 讨论对象:
- Capi-SIG 列表
- 状态:
- 最终
- 类型:
- 信息
- 创建:
- 2020-08-25
- 发布历史:
- 2020-07-16
摘要
传统上,属于 Python 扩展模块的状态保存在 C static
变量中,这些变量具有进程范围。本文档描述了这种每进程状态的问题以及使每个模块状态成为更好的默认状态的努力 - 它是可能的且易于使用的。
本文档还描述了如何在可能的情况下切换到每个模块状态。此转换涉及为该状态分配空间,可能从静态类型切换到堆类型,以及 - 也许最重要的是 - 从代码访问每个模块状态。
关于本文档
作为一个 信息性 PEP,本文档不引入任何更改;这些更改应该在它们自己的 PEP(或问题,如果足够小)中完成。相反,它涵盖了跨越多个版本的努力背后的动机,并指导早期采用者如何使用已完成的功能。
一旦支持基本完成,此内容可以作为 HOWTO 移动到 Python 的文档中。同时,本着文档驱动开发的精神,在此 PEP 中发现的差距可以显示出将精力集中在何处,并且可以随着新功能的实现而更新。
无论何时此 PEP 提到 *扩展模块*,该建议也适用于 *内置* 模块。
注意
此 PEP 包含通用建议。在遵循它时,始终要考虑项目的具体情况。
例如,虽然这些建议中的大部分适用于 Python 标准库的 C 部分,但 PEP 并未考虑 stdlib 的具体情况(不寻常的向后兼容性问题、对私有 API 的访问等)。
与该努力相关的 PEP 为
本文档涉及 Python 的公共 C API,并非所有 Python 实现都提供该 API。但是,此 PEP 中没有任何内容特定于 CPython。
与任何信息性 PEP 一样,本文不一定会代表 Python 社区共识或建议。
动机
*解释器* 是 Python 代码运行的上下文。它包含配置(例如导入路径)和运行时状态(例如导入的模块集)。
Python 支持在一个进程中运行多个解释器。有两个情况需要考虑 - 用户可以运行解释器
- 依次,具有多个
Py_InitializeEx
/Py_FinalizeEx
循环,以及 - 并行,使用
Py_NewInterpreter
/Py_EndInterpreter
管理“子解释器”。
这两种情况(及其组合)在将 Python 嵌入库中时最有用。库通常不应该对使用它们的应用程序做出假设,包括假设进程范围内的“主 Python 解释器”。
目前,CPython 无法很好地处理此用例。许多扩展模块(甚至一些 stdlib 模块)使用 *每个进程* 全局状态,因为 C static
变量非常易于使用。因此,应该特定于解释器的数据最终会在解释器之间共享。除非扩展开发人员小心,否则在同一个进程中将模块加载到多个解释器时,很容易引入导致崩溃的边缘情况。
不幸的是,*每个解释器* 状态并不容易实现 - 扩展作者在开发时往往不会考虑多个解释器,并且目前难以测试其行为。
每个模块状态的理由
Python 的 C API 并没有关注每个解释器状态,而是不断发展以更好地支持更细粒度的 *每个模块* 状态。默认情况下,C 级数据将附加到 *模块对象*。然后,每个解释器将创建自己的模块对象,使数据保持分离。为了测试隔离,甚至可以在单个解释器中加载与单个扩展相对应的多个模块对象。
每个模块状态提供了一种简单的方法来思考生命周期和资源所有权:扩展模块将在创建模块对象时初始化,并在释放时清理。在这方面,模块就像任何其他 PyObject *
;没有需要考虑或忘记的“在解释器关闭时”挂钩。
目标:易于使用的模块状态
目前,在保持模块隔离的情况下,无法完成 C API 提供的所有功能。在 PEP 384 的支持下,PEP 489 和 PEP 573(以及未来计划的更改)旨在首先使其 *能够* 以这种方式构建模块,然后使其 *易于* 以这种方式编写新模块并转换旧模块,以便它可以成为自然的默认值。
即使每个模块状态成为默认值,也有一些用例需要不同级别的封装:每个进程、每个解释器、每个线程或每个任务状态。目标是将这些视为特殊情况:它们应该是可能的,但扩展作者需要更加仔细地考虑它们。
非目标:加速和 GIL
目前正在努力通过使 GIL 成为每个解释器来加速 CPython 在多核 CPU 上的速度。虽然隔离解释器有助于该努力,但默认使用每个模块状态即使没有实现任何加速也将是有益的,因为它默认情况下使支持多个解释器更加安全。
使用多个解释器使模块安全
在扩展模块中正确支持多个解释器的方法有很多。本文的其余部分描述了编写此类模块或转换现有模块的首选方法。
请注意,支持正在进行中;您的模块需要的某些功能的 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.Error
和 binascii.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 级别状态。特别是,您应该将指向类(包括异常,但不包括静态类型)和设置(例如 csv
的 field_size_limit)的指针放在这里,C 代码需要这些指针才能正常工作。
注意
另一种选择是在模块的 __dict__
中存储状态,但您必须避免在用户从 Python 代码修改 __dict__
时崩溃。这意味着在 C 级进行错误和类型检查,这很容易出错,也很难进行充分测试。
如果模块状态包含 PyObject
指针,则模块对象必须持有对这些对象的引用并实现模块级挂钩 m_traverse
、m_clear
和 m_free
。这些类似于类的 tp_traverse
、tp_clear
和 tp_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 中,每个 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_GC 和 tp_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_new
和 tp_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 等效项,例如 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
通过搜索 MRO(即所有超类)来查找第一个具有对应模块的超类。
注意
在非常奇特的案例中(跨越从相同定义创建的多个模块的继承链),PyType_GetModuleByDef
可能不会返回真实定义类的模块。但是,它将始终返回具有相同定义的模块,确保兼容的 C 内存布局。
模块状态的生命周期
当模块对象被垃圾回收时,它的模块状态会被释放。对于指向(模块状态的一部分)的每个指针,您必须持有对模块对象的引用。
通常这不是问题,因为使用 PyType_FromModuleAndSpec
创建的类型及其实例都持有对模块的引用。但是,当您从其他地方(例如外部库的回调)引用模块状态时,您必须注意引用计数。
未解决的问题
围绕每个模块状态和堆类型的几个问题仍然存在。
关于改进这种情况的讨论最好在 capi-sig 邮件列表 上进行。
类型检查
当前(截至 Python 3.10),堆类型没有很好的 API 来编写 Py*_Check
函数(就像 PyUnicode_Check
存在于 str
(静态类型)中一样),因此很难确保实例具有特定的 C 布局。
元类
当前(截至 Python 3.10),没有很好的 API 来指定堆类型的元类;也就是说,类型对象的 ob_type
字段。
每个类范围
也不可能将状态附加到类型。虽然 PyHeapTypeObject
是一个可变大小的对象 (PyVarObject
),但它的可变大小存储目前被插槽占用。修复这一点很复杂,因为继承层次结构中的多个类可能需要保留一些状态。
无损转换为堆类型
堆类型 API 不是为从静态类型进行“无损”转换而设计的 - 也就是说,创建与给定静态类型完全相同的类型。解决这个问题的最佳方法可能是编写一个涵盖已知“陷阱”的指南。
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以更具许可性的方式。
来源:https://github.com/python/peps/blob/main/peps/pep-0630.rst
最后修改时间:2023-09-09 17:39:29 GMT