12
pyo3 双向互操作:把热点给 Rust,把生态留给 Python
pyo3 · Bidirectional Rust & Python
"Python 训练、Rust 部署"是第 1 章就立下的主线,但现实往往更暧昧:模型预处理还在 Python,业务编排还在 Airflow,只有那个卡住 CPU 的热点函数需要 Rust。这时候你需要的不是"重写整个服务",而是让两边互相调用。pyo3 就干这件事——它是 Rust 与 CPython 之间的双向桥。这一章讲清三个问题:Rust 怎么变成 Python 能 import 的模块、GIL 到底该怎么处理、numpy 数组怎么零拷贝互传。技术基线:pyo3 0.29 + maturin 1.x。
三个方向,三种配置:先搞清楚你要的是哪一种
pyo3 的很多"诡异报错"其实都来自一开始选错了方向——三个方向的 Cargo feature 是互斥的。
| 方向 | 产物 | Cargo 配置 | 典型场景 |
Rust → Python (写扩展模块) |
.so / .pyd / wheel,Python import 它 |
crate-type = ["cdylib"] + features = ["extension-module"] |
把热点函数、高性能解析器、加密/编解码搬到 Rust,Python 侧无感调用 |
Python → Rust (内嵌解释器) |
普通 Rust 二进制,运行时启动一个 Python 解释器 |
不能开 extension-module;需要 features = ["auto-initialize"] |
Rust 服务里跑一段用户脚本、调用 Python 生态里的某个库(如 pyarrow) |
双向 (扩展模块里回调 Python) |
扩展模块,但内部会调 Python 函数/触发回调 |
extension-module + 注意 GIL 边界 |
Rust 实现执行引擎,Python 侧传入回调函数(进度、日志、hook) |
论为什么 extension-module 与 auto-initialize 不能同时开
① extension-module 的语义是"符号由宿主解释器提供"。编出来的 .so 被 Python import 时,进程里已经有一个活着的解释器了,所以构建时不链接 libpython,所有 Python 符号留到运行时从宿主那里解析。这样做的收益很实在:不绑定具体 Python 版本,产物可以在多个 Python 版本上加载。
② auto-initialize 的语义是"进程里还没有解释器,我要自己起一个"。这在 Rust 二进制里必须链接 libpython,并在第一次用到时初始化解释器。
③ 两者的前提正相反,所以同时开会出现经典的链接错误:在 macOS 上表现为 Symbol not found: _PyBaseObject_Type、在 Linux 上是 undefined symbol。看到这类报错,第一件事就是检查 Cargo.toml 里 feature 是不是选错了方向。
用 maturin 起项目:三分钟跑通第一个扩展
工程骨架与依赖配置
# 1) 装 maturin(pyo3 官方的构建工具,负责打包 wheel、装进虚拟环境)
pip install maturin
# 2) 生成项目脚手架:--bindings pyo3 是关键
maturin new --bindings pyo3 fasttext-rs
cd fasttext-rs
# 3) 目录结构(maturin 会自动生成 pyproject.toml 与 src/lib.rs)
# fasttext-rs/
# ├── Cargo.toml
# ├── pyproject.toml # Python 侧的打包元数据(模块名、构建后端)
# ├── python/ # 纯 Python 包装层(可选,用于加类型提示与友好 API)
# └── src/lib.rs # Rust 入口
# 4) 开发:编译并安装到当前虚拟环境(改代码后重新跑一次)
maturin develop --release
# 5) 发布:产出 wheel,供 pip install
maturin build --release
# 产物在 target/wheels/,文件名形如 mylib-0.1.0-cp39-abi3-macosx_11_0_arm64.whl
Cargo.toml 与 pyproject.toml
# Cargo.toml
[package]
name = "fasttext-rs"
version = "0.1.0"
edition = "2021"
[lib]
# 扩展模块必须是 cdylib(动态库),不能是 rlib
name = "fasttext_rs" # 注意:这个名字就是 Python 里 import 的名字,用下划线
crate-type = ["cdylib"]
[dependencies]
# abi3-py39:用"受限 API",一个 wheel 同时支持 CPython 3.9+
# 代价是只能用 PEP 384 保证的那部分 API;绝大多数场景够用
pyo3 = { version = "0.29", features = ["extension-module", "abi3-py39"] }
# numpy 互操作:让 pyo3 认识 numpy 数组
numpy = "0.27"
# pyproject.toml
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "fasttext-rs"
requires-python = ">=3.9"
[tool.maturin]
# 告诉 maturin 这不是普通 bin,而是 pyo3 扩展
bindings = "pyo3"
features = ["pyo3/extension-module"]
最小可运行的模块:函数 + 类 + 自定义异常
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use std::collections::HashMap;
/// 用 #[pyfunction] 暴露一个函数。签名会被自动转换成 Python 可调用对象。
// Rust 的返回值会自动转成 Python 对象;出错时返回 PyErr,Python 侧变成异常
#[pyfunction]
fn count_tokens(text: &str, sep: char) -> usize {
text.split(sep).filter(|s| !s.is_empty()).count()
}
/// 返回 PyResult 表示"可能抛异常"。错误类型会自动映射成 Python 异常
#[pyfunction]
fn parse_id(raw: &str) -> PyResult<u64> {
raw.trim()
.parse::<u64>()
// PyValueError 对应 Python 的 ValueError,比抛 RuntimeError 友好得多
.map_err(|e| PyValueError::new_err(format!("非法 id {raw:?}: {e}")))
}
/// 用 #[pyclass] 把 Rust 结构体暴露成 Python 类
// 注意:pyclass 要求字段可通过 getter 访问,字段本身默认不暴露
#[pyclass]
struct Counter {
counts: HashMap<String, u64>,
}
#[pymethods]
impl Counter {
// #[new] 标记的是 Python 的 __init__
#[new]
fn new() -> Self {
Counter { counts: HashMap::new() }
}
/// 累加计数。&mut self 在 Python 侧表现为普通方法调用
fn add(&mut self, key: &str) {
*self.counts.entry(key.to_string()).or_insert(0) += 1;
}
// #[getter] 让方法变成属性访问(Python 里写 c.total 而不是 c.total())
#[getter]
fn total(&self) -> u64 { self.counts.values().sum() }
fn top(&self, py: Python<'_>, n: usize) -> PyResult<Vec<(String, u64)>> {
// 返回 Vec 会被转成 Python list;元素是元组则转成 tuple
let mut v: Vec<(String, u64)> = self.counts.iter().map(|(k, &c)| (k.clone(), c)).collect();
v.sort_by(|a, b| b.1.cmp(&a.1));
v.truncate(n);
// 想让 Python 能做类型标注,可以返回 dict 或自定义类型,见 pyproject 的 stub 生成
Ok(v)
}
// __repr__ 让 print(obj) 输出可读内容,调试时非常有用
fn __repr__(&self) -> String {
format!("Counter(keys={}, total={})", self.counts.len(), self.total())
}
}
/// #[pymodule] 是模块入口,函数名要和 lib name 一致
// pyo3 0.29 的写法:参数是 &Bound<'_, PyModule>,不再需要传 py
#[pymodule]
fn fasttext_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
// wrap_pyfunction! 把 Rust 函数包装成 Python 可调用对象再加进模块
m.add_function(wrap_pyfunction!(count_tokens, m)?)?;
m.add_function(wrap_pyfunction!(parse_id, m)?)?;
m.add_class::<Counter>()?;
// 顺手把版本号暴露给 Python,排查"装的是哪个版本"时省事
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
Ok(())
}
Python 侧使用(和普通模块完全一样)
import fasttext_rs
print(fasttext_rs.__version__)
print(fasttext_rs.count_tokens("a,b,,c", ",")) # 3
try:
fasttext_rs.parse_id("abc")
except ValueError as e: # Rust 的 PyValueError 变成了 Python ValueError
print("捕获到:", e)
c = fasttext_rs.Counter()
for w in "the cat the dog the".split():
c.add(w)
print(c.total) # 5 —— 注意没有括号,因为用了 #[getter]
print(c.top(2)) # [('the', 3), ('cat', 1)]
print(repr(c))
GIL:pyo3 里最容易出事的地方
GIL(全局解释器锁)保证同一时刻只有一个线程在执行 Python 字节码。pyo3 用 Rust 类型系统把这个约束表达出来了:Python<'py> 令牌代表"我持有 GIL",Bound<'py, T> 表示"与 GIL 绑定的 Python 对象引用"。
版本提醒:0.26 起方法改名了
| 旧写法(老教程里常见) | 新写法 | 语义 |
Python::with_gil(|py| ...) | Python::attach(|py| ...) | 获取 GIL 并拿到 Python 令牌 |
py.allow_threads(|| ...) | py.detach(|| ...) | 释放 GIL 执行纯 Rust 计算 |
py.import_bound("numpy") | py.import("numpy") | 导入模块,返回 Bound<PyModule> |
obj.into_py(py) | obj.into_pyobject(py) | Rust 值转 Python 对象(0.23 起换名) |
释放 GIL 跑纯 Rust 计算:这是 pyo3 性能收益的关键
use pyo3::prelude::*;
/// 反例:整个计算都持有 GIL,其他 Python 线程全部被挡住
#[pyfunction]
fn sum_sqrt_bad(data: Vec<f64>) -> f64 {
// CPU 密集循环里一直握着 GIL —— 多线程 Python 程序完全无法并行
data.iter().map(|x| x.sqrt()).sum()
}
/// 正例:把数据搬进 Rust 之后立刻 detach,计算期间把 GIL 让出去
#[pyfunction]
fn sum_sqrt(py: Python<'_>, data: Vec<f64>) -> f64 {
// 注意:Vec<f64> 已经从 Python 侧拷贝成 Rust 的所有权数据了,
// 所以闭包里不再需要访问任何 Python 对象 —— 这是 detach 能成立的前提
py.detach(|| {
// 这里没有 GIL,其他 Python 线程可以继续跑
data.iter().map(|x| x.sqrt()).sum()
})
}
/// detach 内不能碰 Python 对象;如果算到一半要回调 Python,必须重新 attach
#[pyfunction]
fn batched_with_callback(py: Python<'_>, data: Vec<f64>, cb: PyObject) -> PyResult<f64> {
let chunk = 1000;
let mut acc = 0.0;
for part in data.chunks(chunk) {
// 1) 先 detach 做重活,数据是 Rust 自己的,安全
let sub: f64 = py.detach(|| part.iter().map(|x| x.sqrt()).sum());
acc += sub;
// 2) 需要调 Python 时,回到持有 GIL 的上下文里调用(这里 py 令牌仍然可用)
cb.call1(py, (sub,))?;
}
Ok(acc)
}
坑:GIL 死锁与"跨线程传 Bound"
① 典型的 GIL 死锁。线程 A 持有 GIL 等待一把 Rust 互斥锁;线程 B 持有那把锁、同时想获取 GIL 去调 Python 函数。两边互相等,进程卡死。规避规则很简单:不要在持有 GIL 的时候去等一个"可能会去拿 GIL"的锁;需要长时间等待时先 py.detach(),拿到锁之后、真的要动 Python 对象之前再 attach。
② pyo3 的类型分两类,混用会编译失败。Bound<'py, T> 是与 GIL 绑定的引用,不是 Send,所以不能丢进 tokio::spawn 或别的线程。Py<T>(拥有所有权的引用计数指针)是 Send + Sync 的,可以跨线程传递,但用的时候必须重新 attach 拿到 GIL。把一个 Bound 硬塞进线程里,编译器会拦住你——这是保护,不是麻烦。
③ 别在 detach 闭包里捕获 Python 对象。即使编译器有时能过(比如捕获了一个 Py<T>),语义上你也在没有 GIL 的情况下持有引用计数——一旦在别的线程里 drop,行为就不确定了。规矩:进 detach 之前把所有需要的数据拷贝成 Rust 原生类型(Vec、String、ndarray),出来之后再 attach 转回 Python。
错误转换:把 Rust 的错误变成 Python 能 catch 的异常
Python 用户期望的是 try/except 能抓住具体的异常类型。如果 Rust 侧把所有错误都变成 RuntimeError,Python 侧就只能靠字符串匹配——非常糟糕的体验。
四种错误映射策略,按推荐度排列
use pyo3::exceptions::{PyFileNotFoundError, PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use pyo3::{create_exception, exceptions::PyException};
// 策略 1:直接用内置异常类型(推荐用于通用错误)
#[pyfunction]
fn read_config(path: &str) -> PyResult<String> {
std::fs::read_to_string(path).map_err(|e| match e.kind() {
// 让 Python 侧能写 except FileNotFoundError,符合它的习惯
std::io::ErrorKind::NotFound => PyFileNotFoundError::new_err(format!("配置文件不存在: {path}")),
std::io::ErrorKind::PermissionDenied => PyErr::new::<pyo3::exceptions::PyPermissionError, _>(format!("无权限读取 {path}")),
_ => PyRuntimeError::new_err(e.to_string()),
})
}
// 策略 2:自定义异常类型,让调用方能精确 catch
// 生成的 ModelError 可以在 Python 里 from fasttext_rs import ModelError 后 except ModelError
create_exception!(fasttext_rs, ModelError, PyException, "模型加载或推理失败");
// 策略 3:用 thiserror 定义 Rust 侧错误,再集中映射一次(中大型项目推荐)
#[derive(Debug, thiserror::Error)]
pub enum FastTextError {
#[error("模型文件损坏: {0}")]
CorruptModel(String),
#[error("不支持的编码 {0}")]
BadEncoding(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
// 实现 From<FastTextError> for PyErr,之后函数里就能直接 `?`
impl From<FastTextError> for PyErr {
fn from(e: FastTextError) -> Self {
match e {
// 自定义异常 + 原始信息,两边好处都占
FastTextError::CorruptModel(msg) => ModelError::new_err(msg),
FastTextError::BadEncoding(enc) => PyValueError::new_err(format!("编码不支持: {enc}")),
FastTextError::Io(e) => e.into(),
}
}
}
// 策略 4:Rust panic 的兜底。panic 会被 pyo3 转成 PanicException 抛给 Python,
// 但那不是"正常错误" —— 生产代码里应该把可能 panic 的地方改成返回 Result
#[pyfunction]
fn divide(a: f64, b: f64) -> PyResult<f64> {
if b == 0.0 {
// 显式返回异常,比让它 panic 好得多
return Err(PyValueError::new_err("除数不能为 0"));
}
Ok(a / b)
}
numpy 互操作:零拷贝传递大数组
这是 pyo3 在 AI 场景里最有价值的一块。把 numpy.ndarray 转成 Rust Vec 是拷贝,一亿个 f32 就是 400MB 的复制;用 numpy crate 提供的视图就是零拷贝。
读写数组:借用视图 vs 拷回 Python
use numpy::{IntoPyArray, PyArray2, PyArrayMethods, PyReadonlyArray2};
use pyo3::prelude::*;
/// 零拷贝:PyReadonlyArray2 借用 numpy 的缓冲区,不复制数据
// 'py 生命周期保证"这个视图不会活过 GIL 的持有期",所以是安全的
#[pyfunction]
fn row_sums<'py>(x: PyReadonlyArray2<'py, f32>) -> Vec<f32> {
// as_array 拿到 ndarray 的视图(还是零拷贝)
let arr = x.as_array();
// 维度顺序要自己确认:numpy 的 shape 是行优先 [rows, cols]
let rows = arr.shape()[0];
let cols = arr.shape()[1];
let mut out = Vec::with_capacity(rows);
for r in 0..rows {
let mut s = 0.0f32;
for c in 0..cols {
s += arr[[r, c]];
}
out.push(s);
}
out
}
/// 返回 numpy 数组:into_pyarray 会把 Rust 的 Array 转成 numpy 数组
// 传 ndarray::Array2 进去可以零拷贝转成 numpy(所有权转移给 Python)
#[pyfunction]
fn normalize<'py>(
py: Python<'py>,
x: PyReadonlyArray2<'py, f32>,
) -> Bound<'py, PyArray2<f32>> {
let view = x.as_array();
// 计算出一个新的 ndarray(这次确实产生了新内存)
let sum = view.sum();
let out = view.mapv(|v| v / sum.max(f32::EPSILON));
// into_pyarray 把 ndarray 的所有权交给 Python,零拷贝
out.into_pyarray(py)
}
/// 释放 GIL 做重计算:先把数据拷成 Rust 自己的 ndarray,再 detach
#[pyfunction]
fn heavy_matmul(py: Python<'_>, a: PyReadonlyArray2<'_, f32>, b: PyReadonlyArray2<'_, f32>)
-> PyResult<Py<PyArray2<f32>>>
{
// 关键:to_owned() 把 numpy 缓冲区拷成 Rust 自有内存,之后就可以安全 detach
let a = a.as_array().to_owned();
let b = b.as_array().to_owned();
let c = py.detach(|| a.dot(&b)); // 这里没有 GIL,其他线程能跑
// Py<PyArray2> 是 Send 的拥有型句柄,适合从函数里返回或跨线程传递
Ok(c.into_pyarray(py).unbind())
}
// 注意一:数组可能是非连续的(切片、转置的结果),
// as_array() 仍能正确处理,但内存访问会慢;性能敏感时先 as_standard_layout()
// 注意二:PyReadonlyArray 只读;要原地修改用 PyArray2 的 readonly/writeable 变体
// (需要 Python 侧传入可写数组,否则会抛异常)
反向:在 Rust 里内嵌 Python 解释器
有时候你要反过来——Rust 服务里跑用户脚本,或者必须用某个只有 Python 版的库(pyarrow、某些云 SDK)。这时要用内嵌模式,配置与扩展模块完全相反。
内嵌模式的 Cargo.toml 与最小示例
# 内嵌模式:绝对不能开 extension-module,否则链接不到 libpython
[dependencies]
pyo3 = { version = "0.29", features = ["auto-initialize"] }
// src/main.rs —— 一个能把业务数据交给 Python 脚本处理的 Rust 服务片段
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};
fn run_user_script(script_path: &str, records: &[Record]) -> PyResult<Vec<String>> {
// attach 拿到 GIL;首次调用会初始化解释器(因为开了 auto-initialize)
Python::attach(|py| {
// 1) 把脚本文件当模块加载(比 exec 一段字符串更可控,能看到行号)
let source = std::fs::read_to_string(script_path)
.map_err(|e| pyo3::exceptions::PyIOError::new_err(e.to_string()))?;
let module = PyModule::from_code(py, &std::ffi::CString::new(source).unwrap(), script_path, "user_script")?;
// 2) 把 Rust 数据转成 Python 列表传进去
let py_records = PyList::empty(py);
for r in records {
let d = PyDict::new(py);
d.set_item("id", &r.id)?;
d.set_item("amount", r.amount)?;
py_records.append(d)?;
}
// 3) 调用脚本里的 transform 函数,拿回结果
let func = module.getattr("transform")?;
let result = func.call1((py_records,))?;
// extract 做类型转换:类型不符会抛 TypeError 而不是 UB
result.extract::<Vec<String>>()
})
}
// 被处理的业务数据需要能转成 Python 对象
struct Record { id: String, amount: f64 }
// 一个最容易被忽略的现实约束:内嵌解释器是【进程级单例】,
// 高并发下所有请求都在抢同一把 GIL,性能会直接退化成单线程 ——
// 所以内嵌 Python 通常只用于"低频、可选、可用脚本化配置"的场景,
// 高 QPS 的热路径永远不要走"Rust 内嵌调 Python"
打包发布:abi3、wheel 命名与 CI
| 打包方式 | 产物 | 适用与代价 |
abi3-py39 |
一个 wheel 支持 CPython 3.9+ |
最省事,推荐默认。代价:只能用 PEP 384 的受限 API(约 90% 常用接口),且不能用某些版本专有特性 |
| 不开 abi3 |
每个 Python 小版本一个 wheel |
能用全部 API、性能略好(少一层间接),但发布矩阵变大,PyPI 上会有 5~6 个文件 |
| PyPy / 自由线程构建 |
专用 wheel |
只有在确实需要时再支持;pyo3 对它们的支持程度要查当前版本的说明 |
CI 里用 maturin-action 一次性构建多平台 wheel
# .github/workflows/release.yml
- uses: PyO3/maturin-action@v1
with:
# manylinux 是"兼容绝大多数 Linux 发行版"的构建环境(内部用旧 glibc 编译)
target: x86_64-unknown-linux-gnu
manylinux: auto
args: --release --out dist --features pyo3/abi3-py39
- uses: PyO3/maturin-action@v1
with:
target: aarch64-apple-darwin # Apple Silicon
args: --release --out dist --features pyo3/abi3-py39
- uses: PyO3/maturin-action@v1
with:
target: x86_64-pc-windows-msvc
args: --release --out dist --features pyo3/abi3-py39
# 用 abi3 之后:3 个平台 × 1 个 Python 版本 = 3 个 wheel,
# 不开 abi3 就要 × 6 个 Python 版本 = 18 个 wheel
# 最后一步:发布到 PyPI(用 trusted publishing,不要存长期 token)
- uses: pypa/gh-action-pypi-publish@release/v1
论什么时候该上 pyo3,什么时候该"另起一个服务"
① pyo3 的甜区是"同进程、细粒度、高频调用"。比如 Python 侧一个循环里要调用十万次的高性能解析函数——这种场景用 HTTP 或子进程通信,光序列化和进程开销就比计算本身贵。pyo3 的调用开销在微秒级,这是它不可替代的地方。
② pyo3 的暗面是"把两个运行时绑在一起"。你会同时继承 Python 的部署复杂度(虚拟环境、wheel 平台矩阵、Python 版本)和 Rust 的编译复杂度(目标平台交叉编译、链接 libpython)。更麻烦的是故障排查横跨两个世界:GIL 死锁、引用计数问题、异常在边界被"翻译"掉导致堆栈不完整,都要同时懂两边才能定位。
③ 决策标准很朴素:如果两边的交互是"数据进去、结果出来,一天几次",那就老老实实拆成两个服务用 HTTP/RPC 通信;如果交互是"紧循环里反复调用、延迟敏感、数据量大到不能序列化",那 pyo3 是唯一合理的答案。中间地带(比如"每分钟几千次调用")要算一下:省下的序列化和网络开销,够不够抵掉工程复杂度。
常见坑清单
| 现象 | 原因 | 修法 |
macOS 上 import 报 Symbol not found: _PyBaseObject_Type |
扩展模块错误地链接了 libpython(该库应由宿主解释器提供) |
确认开了 extension-module、且没有同时开 auto-initialize |
| Rust 二进制启动报找不到 Python 符号 |
内嵌模式却开了 extension-module |
内嵌场景改开 auto-initialize,并确保装了 Python 开发包(有 libpython) |
| 多线程 Python 程序调 Rust 函数后没有加速 |
函数全程持有 GIL,Python 线程无法并行 |
把数据搬成 Rust 原生类型后 py.detach(...);注意 detach 内不能碰 Python 对象 |
| 进程随机卡死不退出 |
GIL 死锁:持 GIL 等锁、持锁等 GIL |
拉长等待前先 detach;不要在持 GIL 时调可能再次获取 GIL 的同步代码 |
编译报 Bound<...> cannot be sent between threads |
Bound 绑定 GIL 生命周期,不是 Send |
跨线程传 Py<T>(拥有型句柄),用的时候重新 Python::attach |
Python 侧 except 抓不到具体的异常类型 |
所有错误都被映射成 RuntimeError 或 PanicException |
按语义映射到内置异常(ValueError/FileNotFoundError),或用 create_exception! 定义自定义异常 |
| 传大数组进 Rust 后内存翻倍 |
用了 Vec<f32> 参数,pyo3 会把 numpy 数组整个拷一份 |
改成 PyReadonlyArray2<f32> 借用视图;需要脱离 GIL 计算时再 to_owned() 拷一次 |
| 数组计算结果与 numpy 不一致 |
数组不连续(切片/转置的结果),或形状理解成 [cols, rows] |
先 as_standard_layout() 转连续;用 arr.shape() 打印实际形状而不是靠猜 |
maturin develop 成功但 import 失败 |
模块名与 [lib] name 不一致;或没在同一个虚拟环境里跑 |
[lib] name 必须等于 #[pymodule] 的函数名,也等于 Python 里 import 的名字;确认虚拟环境激活 |
坑:把 pyo3 当成"免费的性能提升",结果更慢
最常见的误判是:把 Python 里的一个循环搬到 Rust,却让每次迭代都跨一次语言边界。比如 Python 侧写 for x in data: acc += fastlib.process(x)——每次调用都要做参数转换、GIL 检查、异常检查,边界开销可能比计算本身还大,最后比纯 Python 还慢。
正确的做法是把粒度做粗:接口设计成"传一整个数组进去、返回一整个数组",让 Rust 侧在内部循环。一次调用处理 100 万个元素,跟调用 100 万次处理 1 个元素,开销差三个数量级。
第二个误判是数据转换方式:用 Vec<f64> 接 numpy 数组会拷贝一次,用 PyReadonlyArray2 就是零拷贝。大数组场景下这一条能决定"提速 5 倍"还是"根本没提速"。结论:先 profile,再决定要不要 pyo3;决定用之后,先设计接口的粒度与数据通道,再写实现。
记
本章小结
① 先定方向再写代码:扩展模块用 extension-module,内嵌解释器用 auto-initialize,两者互斥。
② 工程骨架靠 maturin:maturin new --bindings pyo3 → maturin develop --release → maturin build --release。
③ [lib] name = #[pymodule] 函数名 = Python 里 import 的名字(用下划线)。
④ GIL 是性能关键:重计算前把数据转成 Rust 原生类型,然后 py.detach(...);detach 内绝不碰 Python 对象。
⑤ Bound 不是 Send、Py<T> 是:跨线程传拥有型句柄,用之前重新 attach。
⑥ 错误要映射成具体的 Python 异常(PyValueError、create_exception!),别一律 RuntimeError。
⑦ 大数组用 PyReadonlyArray 零拷贝;要脱离 GIL 就先 to_owned();非连续数组先 as_standard_layout()。
⑧ 开 abi3-py39 把 wheel 矩阵从 18 个降到 3 个;CI 用 maturin-action + trusted publishing。
⑨ 接口粒度要比你想的更粗:跨语言边界按"一次一个数组"设计,而不是"一次一个元素"。
小练习 · 五道 pyo3 自测题(点开看答案)
1.(排错题)Rust 二进制里用 pyo3 调 Python,编译时报 undefined symbol: Py_Initialize,最可能是什么问题?
查看答案
内嵌模式却开了 extension-module。该 feature 的语义是"符号由宿主解释器提供、构建时不链接 libpython",而独立二进制里没有宿主,就出现了未定义符号。修法:内嵌场景改开 auto-initialize(并确保系统装了 Python 开发包),且不要同时开 extension-module。
2.(性能题)把一个大数组从 Python 传给 Rust 做计算,怎样避免拷贝?
查看答案
用 PyReadonlyArray2<f32> 而不是 Vec<f32>。前者是 numpy 缓冲区的借用视图,as_array() 拿到的也是零拷贝视图。如果计算要脱离 GIL(py.detach),必须先用 to_owned() 拷成 Rust 自有内存——这一次拷贝是必要且安全的;之后再 into_pyarray 返回给 Python,如果所有权转交得当也能做到零拷贝。
3.(概念题)py.allow_threads 在新版本里叫什么?为什么需要它?
查看答案
现在叫 py.detach(...)(Python::with_gil 也改名为 Python::attach)。它的作用是在纯 Rust 计算期间释放 GIL,让其他 Python 线程能继续执行。如果 CPU 密集函数全程持 GIL,多线程 Python 程序调用它时完全无法并行——"用 Rust 写扩展"却不 release GIL,是拿不到并发收益的。
4.(设计题)Python 里有个循环,每次调 Rust 函数处理一个元素,结果比纯 Python 还慢。为什么?怎么改?
查看答案
跨语言边界的固定开销(参数转换、GIL 检查、异常检查、对象装箱)被放大了几十万次。修法是把粒度做粗:接口改成接收整个数组、在 Rust 内部循环、一次返回整个结果。一次调用处理 100 万个元素 vs 100 万次调用各处理 1 个元素,开销差三个数量级。这也是判断"要不要用 pyo3"的核心标准:边界开销必须远小于单次计算量。
5.(工程题)为什么发布时推荐开 abi3-py39?代价是什么?
查看答案
收益:一个 wheel 就能被 CPython 3.9 及以上加载,把"3 平台 × 6 个 Python 版本 = 18 个 wheel"压成 3 个文件,CI 时间和发布复杂度都大幅下降。代价:只能用 PEP 384 定义的受限 API 子集,个别版本专有特性不可用,且由于通过稳定 ABI 间接调用,理论上会有一点点额外开销(通常可忽略)。绝大多数扩展模块都适合开 abi3。