Python 基础体系 · 第 109/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 原生扩展与性能:C API、Cython、PyO3、ABI 和边界

Python 原生扩展,是指以编译后的共享库形式加载到 Python 进程中的模块。Linux 上通常是 .so,Windows 上通常是 .pyd,macOS 上通常也是动态库形式。一个 CPython 扩展模块需要导出符合约定的初始化函数,例如模块名为 fastmath 时,初始化函数通常是 PyInit_fastmath。解释器加载共享库后,通过这个入口创建模块、注册函数和类型。(docs.python.org)

“原生”描述的是实现和加载方式,不等于“必然更快”。原生扩展可以减少 Python 字节码执行、对象创建和动态分派,但也可能因为参数转换、内存复制、锁竞争、频繁跨边界调用或不合理的 ABI 选择而变慢。

这篇文章讨论四个相互关联的问题:

  1. Python 如何通过 C API 与原生代码交互;
  2. Cython 和 PyO3 分别解决了什么问题;
  3. API、ABI、Limited API、Stable ABI 和 wheel 标签分别意味着什么;
  4. Python 3.14 的 GIL、自由线程构建、对象边界和性能边界如何影响生产交付。

一、先建立正确的性能模型:快在哪里,慢在哪里

调用一个原生扩展函数,实际经历的路径通常不是:

Python → C/Rust → 返回结果

更接近:

Python 参数
  ↓
调用协议与参数解析
  ↓
Python 对象 → C/Rust 表示
  ↓
原生计算
  ↓
C/Rust 表示 → Python 对象
  ↓
引用计数、异常检查、返回
  ↓
Python 结果

可以将一次调用的时间近似写成:

Ttotal=Tdispatch+Tconvert-in+Tnative+Tconvert-out+Tallocation+TsyncT_{\text{total}} = T_{\text{dispatch}} + T_{\text{convert-in}} + T_{\text{native}} + T_{\text{convert-out}} + T_{\text{allocation}} + T_{\text{sync}}

其中:

  • TdispatchT_{\text{dispatch}}:Python 调用扩展函数本身的分派成本;
  • Tconvert-inT_{\text{convert-in}}:输入参数解析和数据转换;
  • TnativeT_{\text{native}}:C、C++ 或 Rust 代码执行时间;
  • Tconvert-outT_{\text{convert-out}}:结果重新包装成 Python 对象的成本;
  • TallocationT_{\text{allocation}}:分配 Python 对象、数组或中间缓冲区的成本;
  • TsyncT_{\text{sync}}:GIL、互斥锁、线程同步和调度成本。

原生扩展只有在下面的条件下才可能有明显收益:

Tnative,saved>Tdispatch+Tconvert-in+Tconvert-out+Tallocation+TsyncT_{\text{native,saved}} > T_{\text{dispatch}} + T_{\text{convert-in}} + T_{\text{convert-out}} + T_{\text{allocation}} + T_{\text{sync}}

这里的 Tnative,savedT_{\text{native,saved}} 表示改用原生代码后,真正节省的计算时间。

1. 一个反例:把单个整数交给扩展

假设有一个扩展函数:

result = fastmath.square(3)

它在 C 中只是执行:

return x * x;

即使 C 的乘法比 Python 的乘法更快,整个调用仍然可能不值得。因为 Python 需要:

  1. 找到 square 函数;
  2. 把 Python int 解析为 C 整数;
  3. 执行一次乘法;
  4. 创建新的 Python int
  5. 检查异常并返回。

计算量太小时,边界成本占主导。

2. 另一个算例:一次处理一百万个元素

如果 Python 代码逐元素处理一百万个数字:

out = [x * x + 1 for x in values]

而扩展函数一次接收连续内存:

out = fastmath.square_plus_one(values)

则可以将一百万次 Python 层循环压缩为一次边界调用。此时:

  • TdispatchT_{\text{dispatch}} 只支付一次;
  • 输入可以通过 buffer protocol 暴露,而不是逐个转换;
  • 原生代码在连续内存上执行紧凑循环;
  • 输出可以一次性分配。

因此,原生扩展的第一个核心原则是:

优化的通常不是某条 C 语句,而是 Python 层循环、对象创建和边界交互的总量。


二、Python C API:扩展模块真正依赖的运行时协议

Python C API 是 CPython 向 C 和 C++ 程序提供的接口集合,用于编写扩展模块、定义新的 Python 类型,以及在宿主程序中嵌入 Python。它不是 Python 语言规范的一部分,而是 CPython 实现提供的编程接口。(docs.python.org)

因此必须区分:

层次 含义
Python 语言 规定语法、对象模型和语言行为
Python 实现 CPython、PyPy、GraalPy 等
Python C API 主要面向 CPython 的扩展接口
编译器 ABI 编译器、平台、结构体布局、调用约定等二进制约定
Python wheel 标签 包管理器用于判断兼容性的交付信息

使用 C API 编写的扩展,通常意味着:

你的代码
  ↓
Python.h
  ↓
CPython 运行时

它并不自动意味着可以在 PyPy 或其他 Python 实现中直接使用。


三、最小 C 扩展:从 Python 参数到 C 整数再返回

下面的扩展实现一个 sum_range(n) 函数,计算:

0+1+2++(n1)0 + 1 + 2 + \dots + (n-1)

1. C 源文件

// fastsum.c
#define PY_SSIZE_T_CLEAN
#include <Python.h>

static PyObject *
fastsum_sum_range(PyObject *self, PyObject *args)
{
    Py_ssize_t n;

    if (!PyArg_ParseTuple(args, "n:sum_range", &n)) {
        return NULL;
    }

    if (n < 0) {
        PyErr_SetString(PyExc_ValueError, "n must be non-negative");
        return NULL;
    }

    unsigned long long total = 0;

    for (Py_ssize_t i = 0; i < n; i++) {
        total += (unsigned long long)i;
    }

    return PyLong_FromUnsignedLongLong(total);
}

static PyMethodDef fastsum_methods[] = {
    {
        "sum_range",
        fastsum_sum_range,
        METH_VARARGS,
        "Return sum(range(n))."
    },
    {NULL, NULL, 0, NULL}
};

static int
fastsum_exec(PyObject *module)
{
    return 0;
}

static PyModuleDef_Slot fastsum_slots[] = {
    {Py_mod_exec, fastsum_exec},
#if PY_VERSION_HEX >= 0x030D0000
    /*
     * 声明该模块不依赖 GIL。
     *
     * 只有当整个模块确实满足自由线程构建的并发要求时,
     * 才能使用 Py_MOD_GIL_NOT_USED。
     */
    {Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
    {0, NULL}
};

static struct PyModuleDef fastsum_module = {
    PyModuleDef_HEAD_INIT,
    .m_name = "fastsum",
    .m_doc = "A minimal C extension.",
    .m_size = 0,
    .m_methods = fastsum_methods,
    .m_slots = fastsum_slots,
};

PyMODINIT_FUNC
PyInit_fastsum(void)
{
    return PyModuleDef_Init(&fastsum_module);
}

这个例子包含几个关键机制。

2. 参数解析

if (!PyArg_ParseTuple(args, "n:sum_range", &n)) {
    return NULL;
}

"n" 表示解析为 Py_ssize_t。解析失败时,C 函数必须返回 NULL,并且参数解析函数已经设置了 Python 异常。

在 C API 中,异常通常不是通过 C++ 异常或 Rust Result 自动传播,而是通过:

设置 Python 异常对象
  ↓
返回 NULL 或错误码
  ↓
解释器发现错误并向 Python 抛出异常

因此,下面这种写法是错误的:

if (!PyArg_ParseTuple(args, "n", &n)) {
    return PyLong_FromLong(-1);
}

这会把“参数错误”伪装成正常结果,破坏 Python 的异常语义。

3. 返回值和引用

return PyLong_FromUnsignedLongLong(total);

这是一个新的 Python 对象引用。调用方将其作为返回值交给解释器。

C API 中常见的引用语义包括:

  • new reference:调用者拥有一个新引用,最终需要释放;
  • borrowed reference:借用引用,不负责释放,但必须保证原对象仍然存活;
  • stolen reference:某个 API 接管调用者传入的引用。

Python C API 的引用计数规则本质上是所有权规则:Py_INCREF() 增加一个拥有者,Py_DECREF() 释放一个拥有者;借用引用不应直接递减。(docs.python.org)

典型错误包括:

PyObject *item = PyList_GetItem(list, 0);  // 通常是 borrowed reference
Py_DECREF(item);                           // 错误:释放了不属于自己的引用

以及:

PyObject *item = PySequence_GetItem(seq, 0); // 通常是 new reference
// 忘记 Py_DECREF(item)                       // 泄漏

实际开发中,应根据 API 文档确认每个返回值的引用类型,而不是根据变量名猜测。


四、多阶段初始化:扩展模块的生命周期

Python 3.14 文档将扩展模块初始化分为多阶段初始化和传统单阶段初始化。多阶段初始化允许解释器先识别模块能力,再创建模块对象,最后执行模块初始化逻辑。(docs.python.org)

一个典型生命周期是:

sequenceDiagram
    participant I as Python 解释器
    participant L as ExtensionFileLoader
    participant E as 扩展共享库
    participant M as 模块对象

    I->>L: import fastsum
    L->>E: 加载共享库
    L->>E: 调用 PyInit_fastsum
    E-->>L: 返回 PyModuleDef
    L->>M: 创建模块对象
    L->>E: 执行 Py_mod_exec
    E-->>M: 注册状态、函数、类型
    M-->>I: 返回可用模块

多阶段初始化的价值不只是代码结构更现代,还包括:

  1. 模块状态可以与模块实例绑定;
  2. 更容易支持多个解释器实例;
  3. 解释器可以读取模块声明的能力;
  4. 可以在执行阶段失败时正确回滚。

传统单阶段初始化通常直接调用:

PyObject *module = PyModule_Create(&moduledef);
return module;

它仍然受支持,但对模块状态隔离和多解释器兼容的表达能力较弱。

模块状态和全局变量

下面这种写法容易造成状态污染:

static PyObject *global_cache;
static int initialized;

如果多个解释器、多个模块实例或测试进程共享这些全局变量,就可能出现:

  • 一个解释器修改了另一个解释器的状态;
  • 模块卸载后全局指针仍指向已释放对象;
  • 测试顺序改变后出现非确定性失败;
  • 多线程下发生数据竞争。

更稳妥的方向是把状态放入模块对象的 module state 中,并通过 PyModule_GetState() 获取。模块是否支持多个解释器,也需要通过设计隔离或显式声明来保证;Python 文档要求扩展模块要么支持多个解释器,要么明确限制其使用范围。(docs.python.org)


五、GIL:允许并发,不等于允许任意 C 代码访问 Python

在传统 GIL 构建中,同一时刻通常只有持有 GIL 的线程可以操作 Python 对象或调用 Python C API。长时间运行、且不访问 Python 对象的原生计算,可以释放 GIL:

Py_BEGIN_ALLOW_THREADS

/* 这里只能访问已经准备好的非 Python 数据 */
do_expensive_calculation(buffer, size);

Py_END_ALLOW_THREADS

Python 3.14 文档明确指出,Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS 即使在自由线程构建中仍然有用途,例如避免死锁。(docs.python.org)

释放 GIL 的正确条件是:

代码块内不访问 Python 对象不调用 Python C API所有外部数据生命周期仍然有效\text{代码块内不访问 Python 对象} \land \text{不调用 Python C API} \land \text{所有外部数据生命周期仍然有效}

下面的代码是危险的:

PyObject *item = PyList_GetItem(list, 0);

Py_BEGIN_ALLOW_THREADS
use_python_object(item);  // 错误
Py_END_ALLOW_THREADS

即使 item 指针没有立刻失效,代码也违反了 C API 的线程约束。正确做法是先把数据复制或转换到独立的 C 表示:

long value = PyLong_AsLong(item);
if (PyErr_Occurred()) {
    return NULL;
}

Py_BEGIN_ALLOW_THREADS
long result = expensive_c_function(value);
Py_END_ALLOW_THREADS

GIL 释放的真实收益

设单次任务的原生计算时间为 CC,进入和退出 GIL 区域成本为 GG,线程数为 pp

理想并行加速大致受限于:

S(p)CG+C/pS(p) \leq \frac{C}{G + C/p}

CC 很小,GG 和任务调度成本占比很高,并行反而可能变慢;当 CC 足够大,释放 GIL 才有机会让多个 Python 线程同时推进原生计算。

因此,不能看到 Py_BEGIN_ALLOW_THREADS 就推断性能一定提升。必须测量:

  • 单线程、有 GIL;
  • 多线程、有 GIL;
  • 单线程、自由线程构建;
  • 多线程、自由线程构建;
  • 不同任务粒度和数据规模。

六、Python 3.14 自由线程构建:扩展必须声明并发能力

自由线程构建是禁用 GIL 的 CPython 构建方式。Python 3.13 开始提供这种构建,Python 3.14 继续支持。自由线程并不意味着所有第三方扩展自动变得线程安全;未声明支持的 C API 扩展在导入时可能导致 GIL 被重新启用,并打印警告。(docs.python.org)

对于多阶段初始化模块,可以添加:

#if PY_VERSION_HEX >= 0x030D0000
    {Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif

对于单阶段初始化模块,可以在自由线程构建中调用:

#ifdef Py_GIL_DISABLED
    PyUnstable_Module_SetGIL(module, Py_MOD_GIL_NOT_USED);
#endif

但这个声明不是“打开并行开关”,而是一个承诺:

模块在没有 GIL 保护的情况下,也不会依赖未同步的共享状态,并且正确遵守 Python 3.14 自由线程构建的 C API 约束。

尤其需要检查:

  • 静态 C 全局变量;
  • 模块级缓存;
  • 自定义对象内部字段;
  • 延迟初始化;
  • 引用计数以外的共享状态;
  • 调用第三方 C 库时的线程安全性;
  • 同一个 buffer 或迭代器是否被多个线程同时使用。

自由线程构建的内置 dictlistset 等类型在常见修改路径上使用内部锁,但这属于当前实现行为,不应替代应用层同步。Python 文档仍建议优先使用 threading.Lock 等显式同步原语。(docs.python.org)

此外,Python 3.14 的自由线程构建目前不支持 Limited C API 和 Stable ABI,因此不能简单地同时宣称“一个 abi3 wheel 覆盖普通构建和自由线程构建”。自由线程版本需要针对该构建单独编译和分发。(docs.python.org)


七、Buffer Protocol:避免“看起来没有复制,实际上复制了”

当扩展需要处理大量连续数据时,最重要的边界设计通常不是函数名,而是数据表示。

逐个接受 Python 对象:

fastsum.sum_values([1, 2, 3, 4])

会涉及:

  • 列表中的每个元素都是 Python 对象;
  • 每个元素都可能需要类型检查和转换;
  • 访问列表元素需要遵守 Python 对象规则;
  • 返回列表又要创建大量 Python 对象。

更适合批量计算的接口是 buffer protocol:

fastsum.sum_float64(memoryview(array))

C 端可以通过 PyObject_GetBuffer() 获取:

Py_buffer view;

if (PyObject_GetBuffer(
        input,
        &view,
        PyBUF_CONTIG_RO | PyBUF_FORMAT) < 0) {
    return NULL;
}

/*
 * view.buf      : 数据起始地址
 * view.len      : 总字节数
 * view.itemsize : 单个元素字节数
 * view.ndim     : 维度
 * view.shape    : 每个维度的长度
 * view.strides  : 步长
 * view.format   : 元素格式
 */

double total = 0.0;

/* 只有确认格式、维度和连续性后才能这样解释 */
if (view.itemsize != sizeof(double) ||
    view.ndim != 1 ||
    view.len % sizeof(double) != 0) {
    PyBuffer_Release(&view);
    PyErr_SetString(PyExc_TypeError, "expected contiguous 1-D float64 buffer");
    return NULL;
}

const double *data = (const double *)view.buf;
Py_ssize_t count = view.len / sizeof(double);

Py_BEGIN_ALLOW_THREADS
for (Py_ssize_t i = 0; i < count; i++) {
    total += data[i];
}
Py_END_ALLOW_THREADS

PyBuffer_Release(&view);
return PyFloat_FromDouble(total);

Py_buffer 不只是一个指针,它还描述了长度、维度、格式和步长。消费者通过 PyObject_GetBuffer() 获得的 view.obj 持有导出对象的强引用,使用完毕后必须且只能调用一次 PyBuffer_Release(),否则会泄漏引用或错误释放。(docs.python.org)

为什么不能无条件地把 buf 转成数组

下面的代码并不总是正确:

double *data = (double *)view.buf;
for (Py_ssize_t i = 0; i < count; i++) {
    total += data[i];
}

因为 buffer 可能:

  • 不是连续内存;
  • 具有负步长;
  • 是二维或多维;
  • 元素格式不是 double
  • 使用大端或小端表示;
  • 对象元素本身是 Python 对象;
  • 只读或生命周期不满足要求。

所以高性能接口通常需要明确契约,例如:

输入必须是:
- 一维;
- C contiguous;
- 只读;
- float64;
- 不包含 Python object;
- 调用期间底层存储保持有效。

如果不能满足这些条件,就应该选择:

  1. 接受更一般的 buffer 并按 strides 访问;
  2. 显式复制为内部连续布局;
  3. 在 Python 层先规范化输入。

“零复制”不是无条件属性,而是输入布局、生命周期和访问方式共同成立后的结果。


八、Cython:让 Python 代码生成更接近 C 的执行路径

Cython 是一种将带有静态类型声明的 Python 风格代码编译成 C 或 C++ 扩展的工具。它不是独立的 Python 解释器,也不是自动把任意 Python 代码变成高性能 C。

Cython 中至少要区分三种函数形态:

形式 Python 可直接调用 Cython 内部调用 典型用途
def 可以,但通常经过 Python 调用语义 Python API
cdef 内部 C 层函数
cpdef 同时提供 Python 和 Cython 调用入口

Cython 的性能来自减少动态操作,例如:

# slow.pyx
def sum_squares_python_style(values):
    total = 0
    for x in values:
        total += x * x
    return total

即使文件扩展名是 .pyx,如果 valuesxtotal 都保持 Python 对象语义,循环仍然会执行大量 Python 操作。

加入静态类型后:

# fast.pyx
from libc.stdint cimport int64_t
from cython.parallel import prange

cpdef int64_t sum_squares(const int64_t[:] values):
    cdef Py_ssize_t i
    cdef int64_t total = 0

    for i in range(values.shape[0]):
        total += values[i] * values[i]

    return total

这里发生了几件事:

  1. const int64_t[:] 使用 typed memoryview 接收 buffer;
  2. values[i] 不需要把每个元素先转换为 Python int
  3. total 是 C 整数;
  4. 循环主体可以生成接近 C 数组循环的代码;
  5. Python 端仍然可以调用 sum_squares()

可执行构建示例

目录结构:

cydemo/
├── pyproject.toml
└── fast.pyx

pyproject.toml

[build-system]
requires = ["setuptools>=68", "wheel", "Cython>=3"]
build-backend = "setuptools.build_meta"

[project]
name = "cydemo"
version = "0.1.0"

[tool.setuptools]
ext-modules = [
  {name = "fast", sources = ["fast.pyx"]}
]

构建和测试:

python -m pip install -U build
python -m build
python -c "import fast; print(fast.sum_squares([1, 2, 3]))"

这里的列表是否能直接匹配 const int64_t[:],取决于 Cython 对输入 buffer 的转换能力和元素格式。生产代码更适合传入 array.array、NumPy 数组或其他明确实现 buffer protocol 的对象,并在测试中覆盖 dtype、连续性和只读属性。

例如使用标准库 array

from array import array
import fast

values = array("q", [1, 2, 3, 4])
print(fast.sum_squares(values))  # 30

其中:

12+22+32+42=301^2 + 2^2 + 3^2 + 4^2 = 30

Cython 的 nogil

Cython 可以标记不访问 Python 对象的代码块为 nogil

from libc.stdint cimport int64_t

cdef int64_t sum_squares_nogil(const int64_t[:] values) noexcept nogil:
    cdef Py_ssize_t i
    cdef int64_t total = 0

    for i in range(values.shape[0]):
        total += values[i] * values[i]

    return total

调用它的 Python 可见函数仍然需要在进入 nogil 前获取 buffer,并在退出后把结果包装成 Python 对象:

cpdef int64_t sum_squares_parallel(const int64_t[:] values):
    return sum_squares_nogil(values)

关键边界是:

Python object 访问 ──需要 GIL──┐
                              ├── 原生数值循环 ──可以 nogil
Python object 创建 ──需要 GIL──┘

Cython 文档强调,nogil 只适用于不访问 Python 数据的代码;prange 的循环体也要求满足 nogil 条件。(cython.readthedocs.io)

一个常见的 Cython 误区

下面的代码不能因为写了 nogil=True 就自动安全:

for i in prange(len(values), nogil=True):
    result.append(values[i] * 2)

result.append() 是 Python 对象操作,需要 GIL,而且多个线程同时修改同一个列表还需要额外同步。正确方向通常是:

  1. 预先分配 C 数组或 typed memoryview;
  2. 每个线程写入独立下标;
  3. 退出并行区后再一次性包装为 Python 对象。

Cython 的 prange 不是“把任意 Python 循环自动并行化”,而是要求循环主体已经被约束到可并行的原生数据操作。


九、PyO3:用 Rust 的所有权系统包装 Python 边界

PyO3 允许使用 Rust 编写 Python 扩展模块,也可以在 Rust 程序中嵌入 Python。它不是另一套 Python 运行时,而是对 Python C API、Python 对象和扩展模块初始化机制的 Rust 封装。(pyo3.rs)

PyO3 的主要价值不是“Rust 一定比 C 快”,而是:

  • Rust 编译器检查内存安全;
  • ResultPyResult 适合表达错误路径;
  • Rust 类型可以减少手工释放资源的错误;
  • 生命周期和线程边界更加显式;
  • 可以把核心计算写成不依赖 Python 的纯 Rust 函数。

1. 一个最小 PyO3 模块

项目结构:

rustsum/
├── Cargo.toml
└── src/
    └── lib.rs

Cargo.toml

[package]
name = "rustsum"
version = "0.1.0"
edition = "2021"

[lib]
name = "rustsum"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.23", features = ["extension-module"] }

src/lib.rs

use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;

#[pyfunction]
fn sum_range(n: i64) -> PyResult<u128> {
    if n < 0 {
        return Err(PyValueError::new_err("n must be non-negative"));
    }

    let n = n as u128;
    Ok(n * (n - 1) / 2)
}

#[pymodule]
fn rustsum(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_range, m)?)?;
    Ok(())
}

构建:

python -m pip install maturin
maturin develop
python -c "import rustsum; print(rustsum.sum_range(10))"

预期输出:

45

公式为:

i=0n1i=n(n1)2\sum_{i=0}^{n-1} i = \frac{n(n-1)}{2}

这里的 Rust 代码没有循环,而是直接使用公式。这个例子说明:性能提升可能来自算法改进,而不是来自“换成 Rust”。

2. PyO3 的边界仍然存在

下面的 Rust 函数接收 Python 列表:

#[pyfunction]
fn sum_values(values: Vec<i64>) -> i64 {
    values.into_iter().sum()
}

它使用起来很方便:

rustsum.sum_values([1, 2, 3, 4])

但“方便”不代表零复制。列表中的 Python 对象仍然需要被读取和转换为 Rust i64,并且 Vec<i64> 通常需要一个 Rust 侧连续存储。

对于大数据,接口设计应考虑:

  • 接受 &[T] 所对应的 buffer;
  • 使用 NumPy 或 memoryview 的缓冲区;
  • 明确数据类型和连续性;
  • 避免在 Rust 和 Python 之间逐元素往返。

PyO3 的安全抽象可以降低内存管理错误,但不能消除 Python 对象模型和 buffer 生命周期的成本。


十、C API、Cython 和 PyO3 的本质差异

三者可以放在同一条抽象链上理解:

手工控制程度 ↑
性能和 ABI 细节可控性 ↑
内存安全与开发便利性 ↓

C API  ←──── Cython ────→ PyO3

但这不是简单的优劣排序。

C API

C API 适合:

  • 需要直接控制 Python 对象和类型;
  • 已经存在 C/C++ 核心库;
  • 需要使用某些 C API 细节;
  • 需要最小运行时开销;
  • 可以承担引用计数和并发审查。

主要风险:

  • 引用泄漏;
  • use-after-free;
  • double decref;
  • 异常路径遗漏;
  • 数据竞争;
  • ABI 误配;
  • 第三方库释放 GIL 后线程不安全。

Cython

Cython 适合:

  • 主要代码已经是 Python;
  • 可以逐步添加静态类型;
  • 需要 typed memoryview;
  • 需要调用 C/C++ 头文件;
  • 希望保留 Python 侧 API。

Cython 的性能取决于类型覆盖和生成代码。只把文件改成 .pyx,而不减少 Python 对象操作,通常不会得到预期收益。

PyO3

PyO3 适合:

  • 核心算法希望使用 Rust;
  • 希望通过类型系统约束内存和错误;
  • 需要调用成熟 Rust 生态库;
  • 希望把 Python 作为 API 层,把 Rust 作为计算和状态层。

PyO3 的安全保证有边界:它不能让 Python 对象自动成为线程安全对象,也不能保证外部 C 库的线程安全;涉及 Python 对象的代码仍需遵守解释器和 GIL/自由线程规则。


十一、ABI 与 API:为什么“编译成功”不代表“可以部署”

1. API 是源码层契约

API 关注:

PyObject *PyLong_FromLong(long v);

调用者是否可以使用这个函数、参数类型是什么、返回值所有权是什么、错误如何报告,都属于 API 语义。

2. ABI 是二进制层契约

ABI 关注:

  • 符号名称;
  • 函数调用约定;
  • 参数和返回值布局;
  • 结构体大小与字段偏移;
  • 对齐方式;
  • 动态库链接方式;
  • 编译器和平台约定。

假设扩展直接访问某个结构体内部字段:

value = ((PyLongObject *)obj)->ob_digit[0];

这可能在某个 CPython 版本和编译配置下可用,但它依赖具体实现布局。结构体布局变化后,可能出现:

  • 编译失败;
  • 链接失败;
  • 运行时读取错误;
  • 内存破坏;
  • 结果悄悄错误。

3. 版本特定 ABI

默认情况下,扩展通常针对特定 Python 版本和平台编译。例如:

cp314-cp314-manylinux...

它表达的是:

  • CPython;
  • Python 3.14;
  • 使用 Python 3.14 的版本特定 ABI;
  • 某个操作系统和架构约束。

这种方式可以使用更多 CPython 优化,但通常需要为不同 Python 次版本分别构建 wheel。

4. Limited API 与 Stable ABI

Python 的 Limited API 是 C API 的受限子集。只使用 Limited API 的扩展,可以在多个 Python 3.x 版本间复用;Stable ABI 是为了支持这种跨版本二进制兼容而提供的一组稳定符号。使用方式通常是在包含 Python.h 之前定义:

#define Py_LIMITED_API 0x030C0000
#include <Python.h>

上例表示以 Python 3.12 作为最低支持版本。Python 文档建议直接写死最低版本,而不是使用当前编译器的 PY_VERSION_HEX,这样在未来 Python 版本上重新编译时不会意外改变最低兼容版本。(docs.python.org)

使用 Limited API 的代价是可用接口更少,并且某些宏、内联函数和依赖具体实现布局的优化不可用。例如,宏版本可能更快,但不属于 Limited API;受限 API 为了稳定性可能退回函数调用。(docs.python.org)

因此可以将选择写成:

交付兼容性实现细节可用性潜在局部性能\text{交付兼容性} \leftrightarrow \text{实现细节可用性} \leftrightarrow \text{潜在局部性能}

不是“Stable ABI 一定更慢”,而是:

  • 受限 API 限制了可以使用的实现细节;
  • 具体性能取决于调用频率和热点位置;
  • 真正的大头通常仍是算法、数据布局和边界次数。

5. abi3 不是“所有 Python 都能加载”

abi3 通常表示面向 Python 3.x Stable ABI 的 wheel 标签。它不是:

  • 任意 Python 实现都支持;
  • 任意 Python 3.x 版本都支持;
  • 自动兼容自由线程构建;
  • 自动保证行为完全一致。

例如,一个以 Python 3.12 Limited API 为最低目标构建的扩展,不能被 Python 3.11 安全加载。wheel 标签只是交付系统的兼容性声明,解释器不会替你验证扩展是否真的遵守 Stable ABI。(docs.python.org)

对于 Python 3.14 自由线程构建,官方文档明确说明目前不支持 Limited C API 和 Stable ABI。因此生产交付应分别验证:

CPython 3.14 GIL 构建
CPython 3.14 free-threaded 构建

不能只因为普通构建使用了 abi3,就认为自由线程版本无需重新编译。(docs.python.org)


十二、一个完整边界示例:批量求和的三种实现

Python 版本

def sum_values(values):
    total = 0
    for value in values:
        total += value
    return total

特征:

  • 每次循环都经过 Python 对象语义;
  • totalvalue 是 Python 对象;
  • 适合小数据、低复杂度和高可读性;
  • 便于调试和跨实现运行。

Cython 版本

from libc.stdint cimport int64_t

cpdef int64_t sum_values(const int64_t[:] values):
    cdef Py_ssize_t i
    cdef int64_t total = 0

    for i in range(values.shape[0]):
        total += values[i]

    return total

特征:

  • Python 只调用一次;
  • 输入通过 buffer 进入;
  • 元素访问使用 C 类型;
  • 结果只包装一次;
  • 可以进一步尝试 nogil

C/PyO3 版本

C API 和 PyO3 都可以实现相同结构:

获取 buffer
  ↓
校验 ndim、format、itemsize、连续性
  ↓
取得裸数据指针或 Rust slice
  ↓
释放 GIL 或进入不依赖 Python 的 Rust 计算
  ↓
退出原生区
  ↓
释放 buffer
  ↓
创建一个 Python 整数

重要的是,这三种版本的性能差距不能靠语言名称推断。应测量以下函数:

sum_values_small(...)
sum_values_large(...)
sum_values_non_contiguous(...)
sum_values_wrong_dtype(...)

特别是 non_contiguouswrong_dtype,它们能暴露接口是否隐式复制或反复转换。


十三、错误路径比成功路径更容易破坏扩展

原生扩展的错误处理必须覆盖每个中间资源。

C API 的资源路径

Py_buffer view;
PyObject *result = NULL;

if (PyObject_GetBuffer(obj, &view, PyBUF_SIMPLE) < 0) {
    return NULL;
}

if (invalid_input(&view)) {
    PyErr_SetString(PyExc_ValueError, "invalid input");
    PyBuffer_Release(&view);
    return NULL;
}

result = PyLong_FromSsize_t(view.len);
PyBuffer_Release(&view);
return result;

每条路径都必须满足:

  • 已获得的 buffer 被释放;
  • 已拥有的新引用被释放;
  • 已设置的异常不被无意清除;
  • 失败返回符合 C API 约定。

PyO3 的错误路径

PyO3 可以将 Rust 错误转换成 Python 异常:

#[pyfunction]
fn checked_divide(a: i64, b: i64) -> PyResult<i64> {
    if b == 0 {
        return Err(pyo3::exceptions::PyZeroDivisionError::new_err(
            "division by zero",
        ));
    }

    Ok(a / b)
}

Python 侧得到的是正常的 Python 异常:

try:
    checked_divide(10, 0)
except ZeroDivisionError as exc:
    print(exc)

但如果 Rust 代码调用外部库,仍然需要明确:

  • 外部库错误如何映射;
  • 是否发生部分写入;
  • 是否持有 Python 对象;
  • 是否允许跨线程;
  • panic 是否会越过 FFI 边界。

十四、性能诊断:先证明边界值得优化

原生扩展开发不应从“重写成 C”开始,而应从证据链开始。

1. 先确认 Python 层热点

使用 cProfile 可以看到函数级调用结构:

python -m cProfile -s cumulative app.py

使用 timeit 测量稳定的小片段:

import timeit

print(timeit.timeit(
    "sum(x * x for x in values)",
    setup="values = range(10000)",
    number=1000,
))

timeit 不会告诉你内存分配和调用路径;cProfile 也不能精确解释原生函数内部的 CPU 热点。

2. 观察扩展边界

测试时至少记录:

输入大小
输入类型
是否连续
是否发生复制
调用次数
单次调用平均耗时
P50/P95/P99
峰值内存
线程数
Python 构建类型

如果一个扩展函数被调用一百万次,每次只处理两个元素,那么优化方向通常是批量化接口,而不是继续优化 C 函数内部的一次加法。

3. 用二进制工具验证交付

Linux 上可以检查共享库依赖:

ldd fastsum.cpython-314-x86_64-linux-gnu.so

检查符号:

nm -D fastsum.cpython-314-x86_64-linux-gnu.so | grep PyInit

检查 Python 识别到的扩展后缀:

python - <<'PY'
import importlib.machinery
print(importlib.machinery.EXTENSION_SUFFIXES)
PY

这些命令只能证明“文件存在、依赖可解析、符号大致正确”,不能证明:

  • 引用计数没有错误;
  • 异常路径没有泄漏;
  • 自由线程构建安全;
  • ABI 声明真实;
  • 性能达到预期。

十五、常见误解与失败表现

误解一:Cython 等于 C

错误。Cython 只是允许你用更接近 Python 的语法生成 C/C++ 扩展。若代码仍然频繁操作 Python 对象,核心成本仍在 Python 运行时。

失败表现通常是:

编译成功
性能几乎没有变化

诊断方法是查看生成的 C 代码,确认循环中是否仍大量调用 Python C API,或者在 Cython 中补充静态类型和 typed memoryview。

误解二:Rust 扩展天然可以多线程

错误。Rust 的内存安全不等于 Python 对象线程安全,也不等于外部库线程安全。

失败表现可能是:

  • 普通构建中因为 GIL 看似正常;
  • 自由线程构建中出现数据竞争;
  • 模块导入后 GIL 被重新启用;
  • 多线程下结果不稳定。

应分别测试普通 CPython 3.14 和自由线程 CPython 3.14,并确认模块是否正确声明能力。(docs.python.org)

误解三:释放 GIL 后可以访问 Python 对象

错误。释放 GIL 的代码只能访问已经脱离 Python 对象模型的原生数据,或者使用明确支持自由线程访问的机制。

失败表现可能包括:

  • 崩溃;
  • 随机结果;
  • 解释器死锁;
  • 退出阶段挂起;
  • 仅在高并发或压力测试中复现。

误解四:abi3 解决所有部署问题

错误。abi3 只解决一部分 CPython Stable ABI 的跨版本问题,还受到:

  • 最低 Python 版本;
  • 操作系统;
  • CPU 架构;
  • C 运行时;
  • 外部动态库;
  • GIL/自由线程构建;
  • 第三方依赖 ABI

等条件限制。

误解五:零复制等于零成本

错误。零复制仍然需要:

  • 检查 buffer 元数据;
  • 保持导出对象生命周期;
  • 处理 strides;
  • 处理只读约束;
  • 处理线程安全;
  • 可能支付缓存未命中和非连续访问成本。

一个连续复制一次、随后高效顺序访问的实现,有时反而比反复进行非连续访问更快。这需要基准测试,而不是凭接口名称判断。


十六、生产交付的选择逻辑

可以用下面的决策路径选择实现方式:

flowchart TD
    A[确认 Python 层热点] --> B{是否存在大量边界调用或 Python 循环}
    B -- 否 --> C[先优化算法、数据结构或批处理]
    B -- 是 --> D{是否已有 C/C++ 核心库}
    D -- 是 --> E[C API 或 Cython 包装]
    D -- 否 --> F{团队是否使用 Rust}
    F -- 是 --> G[PyO3 + Rust 核心]
    F -- 否 --> H[Cython 逐步静态类型化]
    E --> I{是否需要跨 Python 次版本复用}
    G --> I
    H --> I
    I -- 是 --> J[评估 Limited API / Stable ABI]
    I -- 否 --> K[使用版本特定 ABI]
    J --> L{是否需要 Python 3.14 自由线程构建}
    K --> L
    L -- 是 --> M[单独构建和测试 free-threaded wheel]
    L -- 否 --> N[按普通 CPython wheel 交付]

这条路径的核心不是工具偏好,而是边界条件:

  1. 热点是否确实在 Python 层;
  2. 数据是否能批量进入原生代码;
  3. 是否需要零复制;
  4. 是否需要释放 GIL;
  5. 是否需要多个 Python 版本共用一个二进制;
  6. 是否需要支持 Python 3.14 自由线程构建;
  7. 团队是否能维护 C/Rust 的错误、ABI 和构建矩阵。

十七、最终边界:原生扩展优化的是交互形状

Python 原生扩展最重要的设计对象不是 C 函数、Rust 函数或 .pyx 文件,而是Python 与原生代码之间的交互形状

低效的交互形状是:

Python 调用
  → 转换一个值
  → 原生执行极少计算
  → 返回一个值
  → 重复数百万次

高效的交互形状通常是:

Python 一次调用
  → 获取一块结构明确的连续数据
  → 校验格式和生命周期
  → 在原生循环中完成大量计算
  → 必要时释放 GIL
  → 一次性构造结果

C API 提供最低层控制;Cython 提供从 Python 到 C 的渐进式静态化路径;PyO3 提供 Rust 的类型和所有权约束。ABI 决定二进制能否被目标解释器加载,GIL 与自由线程决定并发边界,buffer protocol 决定数据能否高效穿过边界。

因此,正确的问题不是:

C、Cython 还是 PyO3 哪个更快?

而是:

热点计算能否被批量化?输入能否避免逐对象转换?原生区是否足够长?并发是否满足解释器和外部库的约束?最终 wheel 是否真实匹配目标 ABI 和 Python 构建?

只有这些问题同时得到证据支持,原生扩展才会从“换一种语言实现”变成可验证的性能优化。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。