本节目标:讲清 PyO3 用 Rust 写 Python 扩展的架构与关键 API,理解
Bound<'py, T>如何把 GIL 生命周期编进类型系统,掌握 maturin 构建与 abi3 分发的意义,并用可运行的 cffi 扩展做对照。
适用版本:Python 3.12+(实测 3.14.6);PyO3 0.29.3、maturin 1.15.0(版本号来自官方文档与 PyPI,未在本机编译)
9.3 PyO3 与 Rust 扩展
实测边界声明:本机没有 Rust 工具链——
rustc、cargo、maturin实测均为command not found,因此本节的 Rust 代码与命令全部是伪代码/示意,未经编译运行。所有 PyO3 版本号与 API 名称来自官方文档(pyo3.rs v0.29.3、docs.rs、crates.io),非本机实测。唯一可运行的真实对照见 9.1 与下面的 cffi 小节。
站内专题 Python C 扩展与 FFI
给过一段 PyO3 示例,但那段的 API 已经过时——它写的是 py.allow_threads(...),而 PyO3 0.29.3 已把这个方法改名为 detach(allow_threads 在 0.29.3 的文档里已不存在)。本节按 0.29.3 的真实 API 重写,并补上专题没讲的类型系统机制。
9.3.1 为什么是 Rust,而不是又一个 C 扩展
Cython 和手写 C API 的共同问题是手动管理引用计数:C 侧每一次 Py_INCREF / Py_DECREF 配对错了,就是内存泄漏或崩溃。PyO3 的核心价值不是「换一门语言」,而是把 CPython 的引用计数规则编码进 Rust 的类型系统——你在 Rust 里根本拿不到一个「裸的、未计数的」PyObject*,所有跨语言对象都被包在带生命周期标记的智能指针里。编译能过,就说明引用计数的静态约束满足;编译不过,往往正是你在 C 里会漏掉计数的地方。
代价是引入一整套新工具链(cargo、crate 生态、maturin),以及 Rust 本身的陡峭学习曲线。这也是为什么本机没有 Rust 时,本节只能给伪代码。
9.3.2 最小扩展:Cargo.toml 与声明式模块
一个 PyO3 扩展就是一个 Rust 库,crate-type 设成 cdylib 即可产出 .so。Cargo.toml(伪代码):
[package]
name = "my_math"
version = "0.1.0"
edition = "2021"
[lib]
name = "my_math"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.29", features = ["extension-module", "abi3-py312"] }
src/lib.rs 用 **PyO3 0.29 主推的「声明式模块」**写法——整个模块是一个 #[pymodule] mod,模块内的 #[pyfunction]、#[pyclass]、常量会自动导出(伪代码):
use pyo3::prelude::*;
#[pyfunction]
fn sum_squares(n: usize) -> usize {
(0..n).map(|i| i * i).sum()
}
#[pymodule]
mod my_math {
use pyo3::prelude::*;
#[pymodule_export]
use super::sum_squares;
#[pyfunction]
fn distance(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt()
}
}
对比专题里那段旧写法(#[pymodule] fn my_math(m: &Bound<'_, PyModule>) -> PyResult<()> 再逐个 m.add_function(wrap_pyfunction!(...))),声明式写法把「模块成员」从运行时的命令式注册变成了编译期的语法结构——这是 0.29 文档现在主推的形式。两种形式底层产物相同,但新形式少一大段样板。函数签名里的 usize、f64 会被 PyO3 自动转成 Python 的 int、float;返回 Result<T, E> 时会自动转成 Python 异常(PyResult<T> 即 Result<T, PyErr>)。
构建与安装由 maturin 负责(伪代码,本机未执行):
maturin develop # 编译并装进当前 venv,开发用
maturin build --release # 产出 .whl 到 target/wheels/
9.3.3 Bound<'py, T>:把 GIL 生命周期编进类型
这是 PyO3 类型系统里最值得理解的一环。任何 Python 对象在 Rust 侧的句柄都带一个生命周期参数 'py,表示「这个对象只在一个特定的 GIL 持有期内有效」:
| Rust 类型 | 含义 |
|---|---|
Python<'py> | GIL 的持有凭证(token),拿到它才算「握着锁」 |
Bound<'py, T> | 绑定到某个 GIL 期的 Python 对象句柄,T 是具体类型 |
Py<T> | 不绑定 GIL 期的强引用,可在持有 Python<'py> 时借用成 Bound |
机制在于:Bound<'py, T> 的存在本身就证明你此刻持有 GIL。想用这个对象,你得先通过 Python::attach(旧名 with_gil)拿到 Python<'py> token,再由它派生出 Bound。Rust 借用检查器会保证:任何 Bound 都不会活得比它的 'py 更久——于是「在没持锁时访问 Python 对象」这件事在编译期就不可表达。
把这条和 9.1 的 ctypes 对照就清楚了:ctypes.string_at(p) 里的 p 是一个裸地址,ctypes 不知道它属于谁、何时失效,悬垂指针只会在运行时读到垃圾;而 PyO3 里等价的东西是一个 Bound<'py, T>,一旦 'py 结束,编译器直接拒绝再使用它。这正是「用类型系统替代人工引用计数」的具体形态。
9.3.4 释放 GIL:detach(旧名 allow_threads)
Rust 侧的多线程要和 Python 的 GIL 协调。PyO3 提供 Python::detach,在闭包执行期间临时释放 GIL,让其它线程能跑(伪代码):
use pyo3::prelude::*;
#[pyfunction]
fn search_parallel(py: Python<'_>, haystack: &str, needle: &str) -> usize {
// 闭包内没有 Python 对象,可以安全放锁
py.detach(|| {
haystack.lines().filter(|l| l.contains(needle)).count()
})
}
语义和 9.2 的 Cython with nogil: 完全对应:闭包内不能碰任何 Python 对象,detach 才安全。注意 API 名称的版本差异——专题文章和大量旧教程写的是 py.allow_threads(...),0.29.3 已统一改名为 detach,同理获取 GIL 的 Python::with_gil 改名为 Python::attach。照抄旧教程会编译失败,这是本节最实际的一条「版本差异」。
顺带对照 9.1 的实测:ctypes.CDLL 和 cffi 都是自动放锁(实测 4 线程 0.2s sleep 并发成 0.211s),ctypes.PyDLL 才持锁。PyO3 介于两者之间——它默认持锁,要并行必须显式 detach,和 Cython 的 nogil 一样是「手动声明式」的。
9.3.5 abi3 与二进制分发
C 扩展最痛的问题是**「一个 Python 版本一个轮子」:CPython 的 C-API ABI 在版本间会变,所以传统扩展要为 3.12、3.13、3.14 各编一份。PyO3 的 abi3 feature 打开的是 CPython 的稳定 ABI(Stable ABI):只要编译目标选 abi3-py312,产出的同一个 .whl 能在 3.12 及以上的所有版本里加载**。
pyo3 = { version = "0.29", features = ["extension-module", "abi3-py312"] }
abi3-py312 里的 312 是最低支持版本:用 3.12 的稳定 ABI 符号集,换取向后的全部版本兼容。代价是只能使用稳定 ABI 暴露的 API 子集,一些新特性用不了。选 abi3 而不是逐版本构建,意味着发布时一个 macOS 轮子 + 一个 Linux 轮子就覆盖所有 Python 版本,极大简化了分发——这也是 PyO3 在需要发布扩展的场景里越来越主流的原因之一。
配合 maturin,跨平台构建大致是(伪代码,未执行):
maturin build --release --target aarch64-apple-darwin
maturin build --release --target x86_64-unknown-linux-gnu
9.3.6 用 cffi 做一个等价的最小扩展(本机实测)
Rust 跑不了,但「把一个 C 函数包成 Python 模块」这件事,可以用 cffi 的 API 模式做出同构的最小扩展并实测。以下全部在本机编译运行过。C 源(adv09lib.c):
#include <math.h>
/* CPU-bound busy loop: returns sum of sqrt(i). */
double cpu_loop(long n) {
double s = 0.0;
for (long i = 0; i < n; i++) s += sqrt((double)i);
return s;
}
cffi API 模式的构建脚本(ffi.emit_c_code 落盘,再手动 cc,绕过本机缺失的 setuptools):
from cffi import FFI
ffi = FFI()
ffi.cdef("double cpu_loop(long n);")
ffi.set_source("_adv09_api", open("adv09lib.c").read())
ffi.emit_c_code("_adv09_api.c")
INC=$(/opt/homebrew/opt/python@3.14/bin/python3-config --includes)
cc -bundle -undefined dynamic_lookup -O2 $INC \
_adv09_api.c -o _adv09_api.cpython-314-darwin.so
实测这个 cffi 扩展的两个关键指标:
| 指标 | cffi API 模式实测 |
|---|---|
| 单次调用开销 | 262 ns(c_sleep(0),20 万次均值) |
| 4 线程 × 0.2s sleep 墙钟 | 0.211s(说明自动释放 GIL) |
这正是 PyO3 想替代的「手写绑定」路线:cffi 用 C 声明 + 编译得到同样的模块,但引用计数和 GIL 都得你自己盯——cffi 只能帮你自动放锁,管不了对象生命周期。PyO3 的卖点就是把后者的安全性挪到编译期。换句话说:cffi 是你今天就能在本机跑起来的对照物,PyO3 是你要先装好 Rust 工具链才能验证的升级版。
9.3.7 与 cffi / Cython 的取舍
把三条路线放在一起,用本机实测过的 cffi/Cython 数据和未实测的 PyO3 特性对照:
| 维度 | ctypes / cffi | Cython | PyO3 |
|---|---|---|---|
| 需编译 | 否(ABI)/ 是(API) | 是 | 是 |
| 新语言 | 无 | 类 Python(.pyx) | Rust |
| 引用计数安全 | 手动 | 半自动(Cython 管) | 类型系统保证 |
| 释放 GIL | 自动(实测) | with nogil | py.detach(手动) |
| 单次调用开销 | 262–440 ns(实测) | 极低 | 极低 |
| 跨版本分发 | ABI 模式免编译 | 逐版本或限 ABI | abi3 一份轮子 |
| 适用 | 调用现成 C 库 | 加速已有 Python 代码 | 新写高性能扩展 |
选型直觉:只是调用一个现成的 C 库 → cffi(本机实测 API 模式单次 262 ns,零编译风险);要加速一段已有的 Python 热点、且团队只懂 Python → Cython(实测 cdef 类型后 44x);要从零写一个会长期维护、要发布给别人的高性能扩展、并且在乎内存安全 → PyO3。前两者你可以在本机直接验证,PyO3 需要先装 Rust 工具链。
9.3.8 #[pyclass] 与错误传播
#[pyfunction] 只导出函数;要把一个 Rust 结构体暴露成 Python 类,用 #[pyclass] + #[pymethods](伪代码):
use pyo3::prelude::*;
#[pyclass]
struct Counter {
count: u64,
}
#[pymethods]
impl Counter {
#[new]
fn new() -> Self {
Counter { count: 0 }
}
fn bump(&mut self, by: u64) -> u64 {
self.count += by;
self.count
}
#[getter]
fn count(&self) -> u64 {
self.count
}
}
Python 侧就是普通的 c = Counter(); c.bump(3); c.count。关键机制:PyO3 在 Rust 值和 Python 对象之间维护了一个所有权边界——Python 对象里装的是 Rust 的 Counter,&mut self 的借用由 PyO3 在运行时保证独占,Py<T> 则是跨越 GIL 期的强引用计数。
错误传播也走类型系统。Rust 函数返回 PyResult<T>(即 Result<T, PyErr>),? 运算符把底层错误直接向上转成 Python 异常:
#[pyfunction]
fn parse_port(s: &str) -> PyResult<u16> {
s.parse::<u16>().map_err(|e| PyValueError::new_err(e.to_string()))
}
Python 侧调用 parse_port("abc") 会收到一个 ValueError——Rust 的 Result 被映射成 Python 的异常,不需要手写 PyErr_SetString。这套「类型即契约」的风格贯穿 PyO3:参数类型决定转换规则,返回 Result 决定异常行为,Bound<'py, T> 决定 GIL 约束。它比手写 C-API 安全得多,代价是你得先接受 Rust——而这正是本机无法验证、只能读文档的部分。
小结
- 本机无 Rust 工具链(
rustc/cargo/maturin均 not found),本节全部 Rust 代码为伪代码,未编译运行;PyO3 0.29.3、maturin 1.15.0 版本号来自官方来源。 - PyO3 的核心是用 Rust 类型系统编码 CPython 的引用计数规则:
Bound<'py, T>的存在即证明持有 GIL,悬垂对象在编译期不可表达。 - 0.29.3 的 API 重命名:
Python::with_gil→attach,Python::allow_threads→detach——旧教程的allow_threads会编译失败。 #[pymodule] mod声明式模块是 0.29 文档主推的写法,替代了wrap_pyfunction!的运行时注册样板。abi3-py312打开稳定 ABI:一个轮子覆盖 3.12+ 全部版本,代价是只能用稳定 ABI 子集。- GIL 三态对照:ctypes/cffi 自动放锁(实测),PyO3 与 Cython 都需手动声明(
detach/nogil);cffi 扩展实测单次调用 262 ns、自动放锁。
第 9 章到此结束:调用 C(ctypes/cffi)、编译 Python(Cython)、用 Rust 重写(PyO3)三条路线各有了落点。下一章转入性能工程——如何用采样式与确定性剖析定位真正的热点。
阅读导航:上一节:Cython 与 NumPy 加速 · 下一节:剖析器内部与采样原理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。