《Python高级编程》9.3 PyO3 与 Rust 扩展

讲清 PyO3 0.29.3 用 Rust 写扩展模块的架构:声明式模块、Bound<'py, T> 如何把 GIL 生命周期编进类型、detach 释放 GIL、abi3 与 maturin 分发,并用一个实测的 cffi 最小扩展做对照取舍。

本节目标:讲清 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 / cffiCythonPyO3
需编译否(ABI)/ 是(API)是是
新语言无类 Python(.pyx)Rust
引用计数安全手动半自动(Cython 管)类型系统保证
释放 GIL自动(实测)with nogilpy.detach(手动)
单次调用开销262–440 ns(实测)极低极低
跨版本分发ABI 模式免编译逐版本或限 ABIabi3 一份轮子
适用调用现成 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 加速 · 下一节:剖析器内部与采样原理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时