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 选择而变慢。
这篇文章讨论四个相互关联的问题:
- Python 如何通过 C API 与原生代码交互;
- Cython 和 PyO3 分别解决了什么问题;
- API、ABI、Limited API、Stable ABI 和 wheel 标签分别意味着什么;
- Python 3.14 的 GIL、自由线程构建、对象边界和性能边界如何影响生产交付。
一、先建立正确的性能模型:快在哪里,慢在哪里
调用一个原生扩展函数,实际经历的路径通常不是:
Python → C/Rust → 返回结果
更接近:
Python 参数
↓
调用协议与参数解析
↓
Python 对象 → C/Rust 表示
↓
原生计算
↓
C/Rust 表示 → Python 对象
↓
引用计数、异常检查、返回
↓
Python 结果
可以将一次调用的时间近似写成:
其中:
- :Python 调用扩展函数本身的分派成本;
- :输入参数解析和数据转换;
- :C、C++ 或 Rust 代码执行时间;
- :结果重新包装成 Python 对象的成本;
- :分配 Python 对象、数组或中间缓冲区的成本;
- :GIL、互斥锁、线程同步和调度成本。
原生扩展只有在下面的条件下才可能有明显收益:
这里的 表示改用原生代码后,真正节省的计算时间。
1. 一个反例:把单个整数交给扩展
假设有一个扩展函数:
result = fastmath.square(3)
它在 C 中只是执行:
return x * x;
即使 C 的乘法比 Python 的乘法更快,整个调用仍然可能不值得。因为 Python 需要:
- 找到
square函数; - 把 Python
int解析为 C 整数; - 执行一次乘法;
- 创建新的 Python
int; - 检查异常并返回。
计算量太小时,边界成本占主导。
2. 另一个算例:一次处理一百万个元素
如果 Python 代码逐元素处理一百万个数字:
out = [x * x + 1 for x in values]
而扩展函数一次接收连续内存:
out = fastmath.square_plus_one(values)
则可以将一百万次 Python 层循环压缩为一次边界调用。此时:
- 只支付一次;
- 输入可以通过 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) 函数,计算:
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: 返回可用模块
多阶段初始化的价值不只是代码结构更现代,还包括:
- 模块状态可以与模块实例绑定;
- 更容易支持多个解释器实例;
- 解释器可以读取模块声明的能力;
- 可以在执行阶段失败时正确回滚。
传统单阶段初始化通常直接调用:
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_THREADS 和 Py_END_ALLOW_THREADS 即使在自由线程构建中仍然有用途,例如避免死锁。(docs.python.org)
释放 GIL 的正确条件是:
下面的代码是危险的:
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 释放的真实收益
设单次任务的原生计算时间为 ,进入和退出 GIL 区域成本为 ,线程数为 。
理想并行加速大致受限于:
当 很小, 和任务调度成本占比很高,并行反而可能变慢;当 足够大,释放 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 或迭代器是否被多个线程同时使用。
自由线程构建的内置 dict、list、set 等类型在常见修改路径上使用内部锁,但这属于当前实现行为,不应替代应用层同步。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;
- 调用期间底层存储保持有效。
如果不能满足这些条件,就应该选择:
- 接受更一般的 buffer 并按 strides 访问;
- 显式复制为内部连续布局;
- 在 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,如果 values、x 和 total 都保持 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
这里发生了几件事:
const int64_t[:]使用 typed memoryview 接收 buffer;values[i]不需要把每个元素先转换为 Pythonint;total是 C 整数;- 循环主体可以生成接近 C 数组循环的代码;
- 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
其中:
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,而且多个线程同时修改同一个列表还需要额外同步。正确方向通常是:
- 预先分配 C 数组或 typed memoryview;
- 每个线程写入独立下标;
- 退出并行区后再一次性包装为 Python 对象。
Cython 的 prange 不是“把任意 Python 循环自动并行化”,而是要求循环主体已经被约束到可并行的原生数据操作。
九、PyO3:用 Rust 的所有权系统包装 Python 边界
PyO3 允许使用 Rust 编写 Python 扩展模块,也可以在 Rust 程序中嵌入 Python。它不是另一套 Python 运行时,而是对 Python C API、Python 对象和扩展模块初始化机制的 Rust 封装。(pyo3.rs)
PyO3 的主要价值不是“Rust 一定比 C 快”,而是:
- Rust 编译器检查内存安全;
Result和PyResult适合表达错误路径;- 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
公式为:
这里的 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)
因此可以将选择写成:
不是“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 对象语义;
total和value是 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_contiguous 和 wrong_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 交付]
这条路径的核心不是工具偏好,而是边界条件:
- 热点是否确实在 Python 层;
- 数据是否能批量进入原生代码;
- 是否需要零复制;
- 是否需要释放 GIL;
- 是否需要多个 Python 版本共用一个二进制;
- 是否需要支持 Python 3.14 自由线程构建;
- 团队是否能维护 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 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 集成测试:Testcontainers、数据库、消息队列和稳定隔离
- 下一篇:Python 实验可复现:随机种子、环境、数据、制品和运行记录
- 延伸:Python 性能剖析:timeit、cProfile、tracemalloc、采样和证据链
- 延伸:Python GIL 与自由线程构建:互斥边界、扩展兼容和并行选择
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论