PEP 573 – 从 C 扩展方法访问模块状态
- 作者:
- Petr Viktorin <encukou at gmail.com>, Alyssa Coghlan <ncoghlan at gmail.com>, Eric Snow <ericsnowcurrently at gmail.com>, Marcel Plch <gmarcel.plch at gmail.com>
- BDFL 委托:
- Stefan Behnel
- 讨论至:
- Import-SIG 邮件列表
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2016年6月2日
- Python 版本:
- 3.9
- 发布历史:
摘要
本 PEP 提议为 CPython 扩展方法添加一种访问上下文的方式,例如它们所定义模块的状态。
这将允许扩展方法使用直接指针解引用而不是 PyState_FindModule 来查找模块状态,从而减少或消除使用模块作用域状态而非进程全局状态的性能开销。
这解决了采用 PEP 3121(扩展模块初始化和终结)和 PEP 489(多阶段扩展模块初始化)的剩余障碍之一。
尽管本 PEP 朝着完全解决 PEP 3121 和 PEP 489 开始解决的问题又迈出了一步,但它并不试图解决所有剩余问题。特别是,从槽方法(nb_add
等)访问模块状态的问题尚未解决。
术语
进程全局状态
C 级静态变量。由于这是非常低级的内存存储,因此必须仔细管理。
模块级状态
模块对象本地的状态,作为模块对象初始化的一部分动态分配。这使状态与模块的其他实例(包括其他子解释器中的实例)隔离。
通过 PyModule_GetState()
访问。
静态类型
定义为 C 级静态变量的类型对象,即编译入的类型对象。
静态类型需要在模块实例之间共享,并且不包含其所属模块的信息。静态类型没有 __dict__
(尽管其实例可能有)。
堆类型
在运行时创建的类型对象。
定义类
方法的定义类(无论是绑定还是非绑定)是定义该方法的类。仅仅从其基类继承方法的类不是定义类。
例如,int
是 True.to_bytes
、True.__floor__
和 int.__repr__
的定义类。
在 C 中,定义类是使用相应的 tp_methods
或“tp 槽”[1] 条目定义的类。对于在 Python 中定义的方法,定义类保存在 __class__
闭包单元中。
C-API
Python 文档中描述的“Python/C API”。CPython 实现了 C-API,但存在其他实现。
基本原理
PEP 489 引入了一种新的扩展模块初始化方式,它为实现它的扩展带来了几个优势
- 扩展模块的行为更像它们的 Python 对应物。
- 扩展模块可以轻松支持加载到现有模块对象中,这为
runpy
或支持扩展模块重新加载的系统铺平了道路。 - 可以从同一个扩展加载多个模块,这使得可以在单个解释器中测试模块隔离(正确子解释器支持的关键特性)。
采用 PEP 489 的最大障碍是允许从扩展类型的方法访问模块状态。目前,从扩展方法访问此状态的方法是通过 PyState_FindModule
查找模块(与扩展模块中的模块级函数相反,后者接收模块引用作为参数)。然而,PyState_FindModule
查询线程本地状态,与 C 级进程全局访问相比,开销相对较高,因此阻碍了模块作者使用它。
此外,PyState_FindModule
依赖于在每个子解释器中,最多只有一个模块对应于给定的 PyModuleDef
的假设。对于使用 PEP 489 多阶段初始化的模块,此假设不成立,因此 PyState_FindModule
不适用于这些模块。
需要一种更快、更安全的方式从扩展方法访问模块级状态。
背景
Python 方法的实现可能需要访问以下一个或多个信息
- 它被调用的实例 (
self
) - 底层函数
- 定义类,即定义该方法的类
- 相应的模块
- 模块状态
在 Python 代码中,Python 级等价物可以这样检索
import sys
class Foo:
def meth(self):
instance = self
module_globals = globals()
module_object = sys.modules[__name__] # (1)
underlying_function = Foo.meth # (1)
defining_class = Foo # (1)
defining_class = __class__ # (2)
注意
定义类不是 type(self)
,因为 type(self)
可能是 Foo
的子类。
标有 (1) 的语句隐式依赖于通过函数的 __globals__
进行基于名称的查找:要么是访问定义类和 Python 函数对象的 Foo
属性,要么是查找 sys.modules
中模块对象的 __name__
。
在 Python 代码中,这是可行的,因为在执行函数定义时,__globals__
会适当设置,即使命名空间已被操作以返回不同的对象,最坏的情况也会引发异常。
__class__
闭包 (2) 是一种更安全的获取定义类的方式,但它仍然依赖于 __closure__
的适当设置。
相比之下,扩展方法通常作为普通 C 函数实现。这意味着它们只能访问其参数以及 C 级线程局部和进程全局状态。传统上,许多扩展模块将其共享状态存储在 C 级进程全局变量中,这在以下情况下会引发问题
- 在同一进程中运行多个初始化/终结周期
- 重新加载模块(例如,测试条件导入)
- 在子解释器中加载扩展模块
PEP 3121 试图通过提供 PyState_FindModule
API 来解决此问题,但对于扩展方法(而非模块级函数)而言,这仍然存在重大问题
- 它明显慢于直接访问 C 级进程全局状态
- 它仍然固有地依赖于进程全局状态,这意味着它仍然不能可靠地处理模块重新加载
还有一种情况是,在查找 C 级结构(如模块状态)时,提供意外的对象布局可能会使解释器崩溃,因此确保扩展方法接收到它们期望的对象类型更为重要。
提案
目前,绑定扩展方法(PyCFunction
或 PyCFunctionWithKeywords
)只接收 self
,以及(如果适用)提供的位置和关键字参数。
虽然模块级扩展函数已经通过它们的 self
参数获得了对定义模块对象的访问权限,但扩展类型的方法却没有这种便利:它们通过 self
接收绑定实例,因此无法直接访问定义类或模块级状态。
上述附加模块级上下文可以通过两个更改来实现。这两个新增功能都是可选的;扩展作者需要选择启用才能开始使用它们
- 向堆类型对象添加指向模块的指针。
- 将定义类传递给底层 C 函数。
在 CPython 中,定义类在创建内置方法对象 (
PyCFunctionObject
) 时很容易获得,因此可以将其存储在一个扩展PyCFunctionObject
的新结构中。
然后可以通过 PyModule_GetState
从模块对象检索模块状态。
请注意,此提议意味着任何需要访问模块级状态的类型都必须是堆类型,而不是静态类型。这对于支持从单个扩展加载多个模块对象是必要的:静态类型作为 C 级全局变量,不包含其所属模块对象的任何信息。
槽方法
上述更改不涵盖槽方法,例如 tp_iter
或 nb_add
。
槽方法的问题在于它们的 C API 是固定的,因此我们不能简单地添加一个新参数来传入定义类。对此问题提出了两种可能的解决方案
- 通过遍历 MRO 查找类。这可能开销很大,但如果性能不是问题(例如在引发模块级异常时)则可以使用。
- 将每个槽的定义类指针存储在单独的表
__typeslots__
[2] 中。这在技术上是可行的且快速,但侵入性很大。
受此问题影响的模块还可以选择使用线程局部状态或PEP 567 上下文变量作为缓存机制,或者定义自己的重新加载友好查找缓存方案。
普遍解决此问题将推迟到未来的 PEP。
规范
向堆类型添加模块引用
C-API 将新增一个用于创建模块的工厂方法
PyObject* PyType_FromModuleAndSpec(PyObject *module,
PyType_Spec *spec,
PyObject *bases)
这与 PyType_FromSpecWithBases
的行为相同,此外还将提供的模块对象与新类型关联起来。(在 CPython 中,这将设置下文描述的 ht_module
。)
此外,还将提供一个访问器 PyObject * PyType_GetModule(PyTypeObject *)
。如果类型设置了关联模块,它将返回该模块;否则,它将设置 TypeError
并返回 NULL。当给定静态类型时,它将始终设置 TypeError
并返回 NULL。
为了在 CPython 中实现这一点,PyHeapTypeObject
结构将获得一个新成员 PyObject *ht_module
,它将存储指向关联模块的指针。它默认为 NULL
,并且在创建类型对象后不应修改。
ht_module
成员不会被子类继承;对于每个需要它的类型,都需要使用 PyType_FromModuleAndSpec
来设置它。
通常,创建设置了 ht_module
的类会创建涉及类和模块的引用循环。这不是问题,因为模块的拆解不是性能敏感的操作,并且模块级函数通常也会创建引用循环。通过 f_globals
打破函数循环的现有“将所有模块全局变量设置为 None”代码也将打破通过 ht_module
形成的新循环。
将定义类传递给扩展方法
将添加一个新的签名标志 METH_METHOD
,用于 PyMethodDef.ml_flags
。从概念上讲,它将 defining_class
添加到函数签名中。为了简化初始实现,该标志只能用作 (METH_FASTCALL | METH_KEYWORDS | METH_METHOD)
。(它不能与其他标志(如 METH_O
或裸 METH_FASTCALL
)一起使用,但可以与 METH_CLASS
或 METH_STATIC
组合使用)。
使用此标志组合定义的方法的 C 函数将使用名为 PyCMethod
的新 C 签名进行调用
PyObject *PyCMethod(PyObject *self,
PyTypeObject *defining_class,
PyObject *const *args,
size_t nargsf,
PyObject *kwnames)
未来(甚至在此 PEP 的初始实现中)可能会添加其他组合,如 (METH_VARARGS | METH_METHOD)
。然而,METH_METHOD
应该始终是附加标志,即只有在需要时才应传入定义类。
在 CPython 中,将添加一个扩展 PyCFunctionObject
的新结构来存储额外信息
typedef struct {
PyCFunctionObject func;
PyTypeObject *mm_class; /* Passed as 'defining_class' arg to the C func */
} PyCMethodObject;
当 PyCFunction
实现发现设置了 METH_METHOD
标志时,它会将 mm_class
传递给 PyCMethod
C 函数。将添加一个新宏 PyCFunction_GET_CLASS(cls)
以更方便地访问 mm_class
。
C 方法如果不需要访问其定义类/模块,则可以继续使用其他 METH_*
签名。如果未设置 METH_METHOD
,则转换为 PyCMethodObject
是无效的。
参数诊所
为了支持将定义类传递给使用 Argument Clinic 的方法,CPython 的 Argument Clinic 工具中将添加一个名为 defining_class
的新转换器。
每个方法只能有一个参数使用此转换器,并且它必须出现在 self
之后,或者如果未使用 self
,则作为第一个参数。该参数的类型将为 PyTypeObject *
。
使用时,Argument Clinic 将选择 METH_FASTCALL | METH_KEYWORDS | METH_METHOD
作为调用约定。该参数不会出现在 __text_signature__
中。
新的转换器最初将与 __init__
和 __new__
方法不兼容,后者不能使用 METH_METHOD
约定。
助手函数
从堆类型获取模块级状态是一项非常常见的任务。为了简化此操作,将添加一个助手函数
void *PyType_GetModuleState(PyObject *type)
此函数接受一个堆类型,成功时,它返回指向该堆类型所属模块状态的指针。
失败时,可能会出现两种情况。当传入非类型对象或没有模块的类型时,TypeError
被设置并返回 NULL
。如果找到模块,则返回指向状态的指针(可能为 NULL
),而不设置任何异常。
初始实现中转换的模块
为了验证该方法,在初始实现期间将修改 _elementtree
模块。
API 变更和新增功能摘要
以下内容将添加到 Python C-API 中
PyType_FromModuleAndSpec
函数PyType_GetModule
函数PyType_GetModuleState
函数METH_METHOD
调用标志PyCMethod
函数签名
以下新增内容将作为 CPython 实现细节添加,不会被记录
PyCFunction_GET_CLASS
宏PyCMethodObject
结构_heaptypeobject
的ht_module
成员- Argument Clinic 中的
defining_class
转换器
向后兼容性
所有堆类型都添加了一个新指针。所有其他更改都是添加新函数和结构,或更改私有实现细节。
实施
未来可能的扩展
槽方法
未来可能会增加一种将定义类(或模块状态)传递给槽方法的方式。
此 PEP 的先前版本提出了一个辅助函数,该函数将通过在 MRO 中搜索定义了特定函数槽的类来确定定义类。但是,如果类被修改(对于堆类型,这在 Python 代码中是可能的),此方法将失败。解决此问题留待未来的讨论。
轻松创建带有模块引用的类型
可以添加一个 PEP 489 执行槽类型,以使创建堆类型比调用 PyType_FromModuleAndSpec
容易得多。这留待未来的 PEP。
最好能有一种好方法通过受限 API 创建静态异常类型。这样的异常类型可以在子解释器之间共享,但无需特定模块状态即可实例化。这也留待未来可能的讨论。
优化
如本文所述,使用 METH_METHOD
标志定义的方法仅支持一种特定签名。
如果出于性能原因需要其他签名,可以添加。
参考资料
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0573.rst