将 Cython 代码移植到 PyPy

Cython 对 cpyext 提供基本支持,cpyext 是 PyPy 中模拟 CPython 的 C-API 的层。这是通过在 C 编译时使生成的 C 代码适应来实现的,因此生成的代码可以在 CPython 和 PyPy 中不变地编译。

但是,除了 Cython 可以内部覆盖和适应的范围之外,cpyext C-API 模拟在 CPython 中的真实 C-API 中存在一些差异,这些差异对用户代码有明显的影响。本页列出了主要差异以及处理这些差异的方法,以便编写可在 CPython 和 PyPy 中运行的 Cython 代码。

引用计数

PyPy 中的一般设计差异是运行时在内部不使用引用计数,而是始终使用垃圾收集器。引用计数仅在 cpyext 层通过计算在 C 空间中持有的引用来模拟。这意味着 PyPy 中的引用计数通常与 CPython 中的引用计数不同,因为它不计算在 Python 空间中持有的任何引用。

对象生命周期

作为不同垃圾收集特性的直接结果,对象可能在与 CPython 中不同的点看到其生命周期的结束。因此,当对象预计在 CPython 中已死,但在 PyPy 中可能没有死时,需要特别注意。具体来说,扩展类型的析构方法(__dealloc__())可能比在 CPython 中晚得多地被调用,这是由内存变得更紧张而不是由对象死亡触发的。

如果代码中已知对象应该死亡的点(例如,当它与另一个对象或函数的执行时间绑定时),值得考虑是否可以在该点手动将其失效并清理,而不是依赖于析构函数。

作为副作用,这有时甚至可以导致更好的代码设计,例如,当上下文管理器可以与 with 语句一起使用时。

借用引用和数据指针

PyPy 中的内存管理允许在内存中移动对象。C-API 层只是对 PyPy 对象的间接视图,并且通常将数据或状态复制到 C 空间,然后将其绑定到 C-API 对象的生命周期而不是底层 PyPy 对象的生命周期。重要的是要了解这两个对象在 cpyext 中是独立的。

结果可能是,当使用数据指针或借用引用时,并且拥有对象不再从 C 空间直接引用,引用或数据指针可能在某个时刻变得无效,即使对象本身仍然存活。与 CPython 相比,仅将对对象的引用保存在列表(或其他 Python 容器)中是不够的,因为这些内容仅在 Python 空间中管理,因此仅引用 PyPy 对象。Python 容器中的引用不会使 C-API 对其的视图保持活动状态。Python 类字典中的条目显然也不起作用。

这可能发生的一个更明显的地方是访问字节字符串的 char* 缓冲区。在 PyPy 中,这仅在 Cython 代码直接引用字节字符串对象本身时才有效。

另一个要点是,当直接使用返回借用引用的 CPython C-API 函数时,例如 PyTuple_GET_ITEM() 和类似的函数,但也包括一些返回对内置模块或运行时环境的低级对象的借用引用的函数。PyPy 中的 GIL 仅保证借用引用在下次调用 PyPy(或其 C-API)之前保持有效,但不一定更长时间有效。

当访问 Python 对象的内部或使用借用引用超过下次调用 PyPy(包括引用计数或任何释放 GIL 的操作)时,因此需要在 C 空间中额外保留对这些对象的直接拥有引用,例如在函数的局部变量中或扩展类型的属性中。

如有疑问,请避免使用返回借用引用的 C-API 函数,或在使用借用引用时显式地用一对调用 Py_INCREF()(获取引用时)和 Py_DECREF()(完成时)将其转换为拥有引用。

内置类型、插槽和字段

以下内置类型目前在 cpyext 中以其 C 级表示形式不可用:PyComplexObjectPyFloatObjectPyBoolObject

许多内置类型的类型插槽函数在 cpyext 中未初始化,因此无法直接使用。

类似地,几乎没有内置类型的(实现)特定结构字段在 C 级公开,例如 PyLongObjectob_digit 字段或 PyListObject 结构的 allocated 字段等。虽然容器的 ob_size 字段(由 Py_SIZE() 宏使用)可用,但不能保证其准确性。

最好不要访问任何这些结构字段和插槽,而是使用正常的 Python 类型以及正常的 Python 对象操作协议。Cython 会将它们映射到 CPython 和 cpyext 中对 C-API 的适当使用。

GIL 处理

目前,GIL 处理函数 PyGILState_Ensure() 在 PyPy 中不可重入,并在两次调用时发生死锁。这意味着尝试“以防万一”获取 GIL 的代码(因为它可能在有或没有 GIL 的情况下被调用)在 PyPy 中将无法按预期工作。参见 PyGILState_Ensure should not deadlock if GIL already held.

效率

在 CPython 中用于提高速度的简单函数,尤其是宏,在 cpyext 中可能会表现出明显不同的性能特征。

之前已经提到过返回借用引用的函数需要特别注意,但它们还会导致更多的运行时开销,因为它们在 PyPy 中经常创建弱引用,而在 CPython 中只返回一个普通指针。一个明显的例子是 PyTuple_GET_ITEM()

一些更高级别的函数也可能表现出完全不同的性能特征,例如 PyDict_Next() 用于字典迭代。虽然它是 CPython 中迭代字典的最快方法,具有线性时间复杂度和低开销,但它目前在 PyPy 中具有二次运行时间,因为它映射到正常的字典迭代,无法在两次调用之间跟踪当前位置,因此需要在每次调用时重新开始迭代。

这里的一般建议比在 CPython 中更适用,即始终最好依赖 Cython 为您生成适当的 C-API 处理代码,而不是直接使用 C-API - 除非您真的知道自己在做什么。如果您发现 PyPy 和 cpyext 中有比 Cython 目前更好的方法,最好修复 Cython 以造福所有人。

已知问题

  • 从 PyPy 1.9 开始,在某些罕见情况下,对内置类型的子类型化会导致方法调用时的无限递归。

  • 特殊方法的文档字符串不会传播到 Python 空间。

  • pypy3 中的 Python 3.x 改编才开始慢慢包含 C-API,因此预计会出现更多不兼容性。

错误和崩溃

PyPy 中的 cpyext 实现比经过充分测试的 C-API 及其在 CPython 中的底层原生实现年轻得多,成熟度也低得多。在遇到崩溃时应该记住这一点,因为问题可能并不总是在您的代码或 Cython 中。此外,PyPy 及其 cpyext 实现比 CPython 和 Cython 更难在 C 级进行调试,仅仅因为它们不是为此而设计的。