楼层: 首页/ 软件技术/ Rust + AI 全栈/ pyo3 双向互操作:把热点给 Rust,把生态留给 Python
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。