编程 PyO3 深度拆解:当 Rust 扩展遇上无 GIL 的 Python 3.14——从 Bound API、GIL 释放到 free-threaded 并发安全的工程全貌(2026)

2026-07-19 04:44:18 +0800 CST views 12

PyO3 深度拆解:当 Rust 扩展遇上无 GIL 的 Python 3.14——从 Bound API、GIL 释放到 free-threaded 并发安全的工程全貌(2026)

2025 年 10 月 7 日,Python 3.14(官方玩梗 πthon)正式发布,PEP 779 把「自由线程(free-threaded)」构建从实验性正式升格为受支持阶段。困扰 Python 三十年的 GIL,第一次露出了真正退场的曙光。

而这件事对绝大多数纯 Python 开发者来说,是「性能可能会好一点」;对写 C 扩展、写 Rust 扩展的人来说,却是地动山摇——你过去所有「反正只有一个线程在跑 Python 代码」的隐含假设,全部作废。

这篇文章不谈鸡汤,只谈工程。我们从 GIL 的本质讲起,一路拆到 PyO3 的智能指针模型、Bound API 演进、GIL 释放机制,再到 free-threaded 构建下真正会咬人的并发安全陷阱,最后落到打包发布的生产实战。全程配代码,5000 字起,读完你能真正写出一个在无 GIL Python 下不崩、不慢、不数据竞争的 Rust 扩展。


一、背景:为什么 2026 年必须重新学一遍 PyO3

先说清楚现状,避免被网上真假掺半的信息带偏:

  • Python 3.13(2024-10):首次引入 free-threaded 构建,PEP 703,但明确标注为 experimental。二进制发行版里叫 python3.13t(t = threaded)。
  • Python 3.14(2025-10):PEP 779 落地,free-threaded 进入 Phase II,正式「受支持」。但仍是可选,默认发行版依旧带 GIL,你要显式装 python3.14t 或用 --disable-gil 编译。距离「默认无 GIL」(Phase III)还有至少一到两个大版本。

判断一个进程到底有没有 GIL,官方给了两个开关:

import sys, sysconfig

# 运行期:GIL 现在是不是被关掉了
print(sys._is_gil_enabled())          # free-threaded 构建下通常返回 False

# 构建期:这个解释器本身支不支持 free-threaded
print(sysconfig.get_config_var("Py_GIL_DISABLED"))  # 1 表示支持

为什么这跟 Rust 扩展作者关系巨大?因为GIL 一直在偷偷帮 C/Rust 扩展干活:它是一把进程级大锁,保证同一时刻只有一个线程在执行 Python 字节码、动 Python 对象的引用计数。你写扩展时,只要「拿着 GIL」,就等于独占了整个解释器状态,几乎不用考虑数据竞争。

GIL 一旦消失,这层免费的保护罩就没了:

  1. Python 对象的引用计数变成了多线程并发修改的共享状态。
  2. 你的扩展里那些 static mut、全局缓存、lazy_static,随时可能被多个 Python 线程同时撞进来。
  3. 过去「反正串行」的性能假设、锁假设,全部要重算。

好消息是:PyO3 从 0.23 起系统性地支持了 free-threaded 构建,并且用 Rust 的类型系统把很多陷阱在编译期就拦下来了。这正是 Rust 相比手写 C 扩展在无 GIL 时代的最大优势——Send/Sync 不是文档里的君子协定,是编译器强制。


二、GIL 到底锁了什么:从引用计数说起

要理解 PyO3 的设计,得先理解 GIL 保护的核心资产:引用计数

CPython 用引用计数做主要的内存管理。每个 PyObject 头部有个 ob_refcnt

typedef struct _object {
    Py_ssize_t ob_refcnt;      // 引用计数
    PyTypeObject *ob_type;     // 类型指针
} PyObject;

Py_INCREF / Py_DECREF 本质就是 ob_refcnt++ / ob_refcnt--。在带 GIL 的世界里,这两个操作不需要原子指令——因为同一时刻只有一个线程能碰它,一条普通的 add 指令就够,快得飞起。

free-threaded 构建做了什么?它把引用计数改成了一套混合方案:

  • 不可变的单例对象NoneTrue、小整数、interned 字符串等)用「永生对象(immortal objects)」标记,压根不再改计数,避免热点争抢。
  • 普通对象引入了 偏向引用计数(biased reference counting):区分「创建对象的属主线程」和「其他线程」,属主线程走快路径的非原子计数,跨线程访问走原子的共享计数,最后合并。
  • 容器的内部结构(list、dict)加了细粒度锁。

代价是显而易见的:单线程性能有回退(早期约 5%~10%,3.14 已优化不少),内存布局变复杂。但对我们扩展作者来说,关键结论只有一句:

在 free-threaded 下,任何跨线程共享的 Python 对象,其引用计数的增减都必须是线程安全的;你自己维护的共享状态则必须自己加锁。

PyO3 的整个智能指针体系,就是围绕「安全地增减引用计数、安全地访问对象」这件事设计的。


三、PyO3 的核心模型:Python token 与三种智能指针

PyO3 里有个贯穿始终的类型:Python<'py>。它是一个零大小的 token,代表「当前线程此刻有权与 Python 解释器交互」。

在带 GIL 的构建里,持有 Python<'py> ≈ 持有 GIL;在 free-threaded 构建里,它代表「当前线程已 attach 到解释器(有一个有效的 thread state)」。这个抽象很聪明——它让同一套 API 在两种构建下都语义自洽。

围绕它,PyO3 有三类指向 Python 对象的指针,务必分清:

3.1 Bound<'py, T>:绑定生命周期的主力

Bound<'py, T> 是 PyO3 0.21 引入、如今的一等公民。它同时携带两个信息:

  • 一个指向 Python 对象的指针;
  • 一个 'py 生命周期,证明「我在持有解释器访问权的期间内」。
use pyo3::prelude::*;
use pyo3::types::PyList;

fn demo(py: Python<'_>) -> PyResult<()> {
    // 创建一个 Python list,得到 Bound<'py, PyList>
    let list: Bound<'_, PyList> = PyList::new(py, [1, 2, 3])?;
    list.append(4)?;
    println!("len = {}", list.len());
    Ok(())
}

因为 'py 生命周期在,编译器保证你不可能在没有解释器访问权的时候解引用它。这就是 Rust 的护城河。

3.2 Py<T>:不绑定生命周期的持久句柄

有时你需要把对象存起来,跨越「当前这段解释器访问期」——比如放进一个 Rust 结构体、发到另一个线程。这时用 Py<T>PyObject 就是 Py<PyAny> 的别名):

use pyo3::prelude::*;

#[pyclass]
struct Holder {
    // 存一个 Python 对象,跨调用存活
    callback: Py<PyAny>,
}

#[pymethods]
impl Holder {
    #[new]
    fn new(callback: Py<PyAny>) -> Self {
        Holder { callback }
    }

    fn call(&self, py: Python<'_>) -> PyResult<()> {
        // 用时再 bind 回 Bound,拿到 'py 生命周期
        self.callback.bind(py).call0()?;
        Ok(())
    }
}

Py<T> 不带 'py,所以你不能直接用它调方法——必须先 .bind(py).into_bound(py) 换回 Bound。这条规则强制你「只有在真正持有解释器访问权时才操作对象」。

3.3 GIL Refs(&PyAny 等):已被埋葬

老代码里常见 &'py PyAny&'py PyList 这种「GIL 引用」。它们是 PyO3 早期设计,依赖一个隐藏的对象缓冲池,在 free-threaded 下天然有问题(那个池子是靠 GIL 保护的)。PyO3 已经在 0.23 弃用、0.24/0.25 彻底移除。

迁移铁律:看到 &PyAnypy.eval(...) 返回裸引用、PyList::empty(py) 返回 &PyList 这类老写法,一律换成 Bound。这是进入 free-threaded 时代的第一道门槛。

对照表:

老(GIL Refs,已移除)新(Bound API)
&'py PyAnyBound<'py, PyAny>
&'py PyListBound<'py, PyList>
obj.extract::<T>()不变,但接收者是 Bound
PyList::new(py, x) 返回 &PyList返回 PyResult<Bound<PyList>>
Py::from(&py_ref)bound.unbind() / bound.into()

四、写第一个扩展:从 Hello 到并行计算

理论讲够了,上手。用 maturin 起项目:

pip install maturin
maturin new -b pyo3 fastmath
cd fastmath

Cargo.toml 关键部分:

[lib]
name = "fastmath"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.25", features = ["extension-module"] }
rayon = "1.10"

src/lib.rs

use pyo3::prelude::*;

/// 一个简单函数:计算 [0, n) 平方和
#[pyfunction]
fn sum_squares(n: u64) -> u64 {
    (0..n).map(|x| x * x).sum()
}

#[pymodule]
fn fastmath(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_squares, m)?)?;
    Ok(())
}
maturin develop --release
python -c "import fastmath; print(fastmath.sum_squares(1_000_000))"

跑通了。但这个函数有个隐患:只要它在执行,GIL 就被它攥着——即使它压根没碰任何 Python 对象。别的 Python 线程只能干等。下面解决它。


五、释放解释器:allow_threadsUngil 约束

纯计算、纯 IO 的 Rust 代码不需要碰 Python 对象,就应该主动把解释器让出去,让其他 Python 线程能跑。这靠 Python::allow_threads

use pyo3::prelude::*;

#[pyfunction]
fn sum_squares(py: Python<'_>, n: u64) -> u64 {
    // 交出解释器访问权,闭包里是纯 Rust 世界
    py.allow_threads(|| {
        (0..n).map(|x| x * x).sum()
    })
}

注意签名多了 py: Python<'_>——PyO3 会自动注入,Python 侧调用参数不变。

allow_threads 有个精妙的编译期约束:闭包捕获的东西必须满足 Ungil。简单说,你不能把 BoundPy<T>Python 这种「需要解释器访问权」的东西带进闭包——因为你都把访问权交出去了,再碰 Python 对象就是 UB。PyO3 用一个 Ungil marker trait 在编译期拦你:

#[pyfunction]
fn bad_example(py: Python<'_>, obj: Bound<'_, PyAny>) -> PyResult<()> {
    py.allow_threads(|| {
        // ❌ 编译错误:obj 不满足 Ungil,不能带进来
        // obj.call0()?;
        Ok(())
    })
}

这就是 Rust 相比 C 的降维打击——C 扩展里你调 Py_BEGIN_ALLOW_THREADS 后误碰对象,编译器一声不吭,运行期随机崩;PyO3 直接编译不过。

结合 rayon 做数据并行

释放解释器后,Rust 侧可以放开手脚跑多核。用 rayon 把上面的计算并行化:

use pyo3::prelude::*;
use rayon::prelude::*;

#[pyfunction]
fn sum_squares_parallel(py: Python<'_>, n: u64) -> u64 {
    py.allow_threads(|| {
        (0..n).into_par_iter().map(|x| x * x).sum()
    })
}

关键点:这里的多核并行是纯 Rust 线程池跑的,跟 Python 的 GIL / free-threaded 完全无关。哪怕你在带 GIL 的 Python 3.13 上,这段照样吃满所有核心。很多人以为「Python 要无 GIL 才能多核」,其实把重活扔进 Rust + rayon,早就绕过 GIL 了——这也是 PyO3 长期最实用的性能套路。


六、free-threaded 时代真正的坑:并发安全

前面都是「老 PyO3 也能做」的东西。现在进入 3.14 free-threaded 独有的深水区。

6.1 声明你的模块支持 free-threaded

默认情况下,即使跑在 python3.14t 上,只要有一个 C/Rust 扩展没声明自己支持 free-threaded,CPython 会自动重新打开 GIL(打印一行 RuntimeWarning),退回到安全模式。也就是说,你不主动声明,无 GIL 就白搭。

CPython 的机制是模块级的 Py_mod_gil slot。PyO3 把它包装成了 gil_used 标志:

use pyo3::prelude::*;

#[pymodule(gil_used = false)]
fn fastmath(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_squares_parallel, m)?)?;
    Ok(())
}

gil_used = false 等价于「我保证我的代码在无 GIL 下是线程安全的,别为我重开 GIL」。这是一句承诺,不是魔法——你签了字,就得真的做到线程安全。做不到就是数据竞争、崩溃。

6.2 GILProtected 死了

PyO3 老代码里有个类型 GILProtected<T>:它的思路是「反正 GIL 保证串行,那被 GIL 保护的数据就不用额外加锁」。在 free-threaded 下这个前提彻底崩塌,所以 GILProtected 在 free-threaded 构建里直接不可用。

迁移方案:老实用标准锁。

use pyo3::prelude::*;
use std::sync::Mutex;

#[pyclass]
struct Counter {
    // 老写法:GILProtected<Cell<u64>>  —— free-threaded 下失效
    // 新写法:直接上锁
    value: Mutex<u64>,
}

#[pymethods]
impl Counter {
    #[new]
    fn new() -> Self {
        Counter { value: Mutex::new(0) }
    }

    fn incr(&self) {
        let mut v = self.value.lock().unwrap();
        *v += 1;
    }

    fn get(&self) -> u64 {
        *self.value.lock().unwrap()
    }
}

6.3 #[pyclass] 的可变性与并发

这是最容易翻车的地方。PyO3 的 #[pyclass] 默认允许 Python 侧通过 &mut self 方法修改实例,它内部用一个类似 RefCell运行期借用检查来保证安全:

#[pymethods]
impl Foo {
    fn mutate(&mut self, x: i64) { /* ... */ }  // 独占借用
    fn read(&self) -> i64 { /* ... */ }          // 共享借用
}

在带 GIL 时,这套借用检查靠 GIL 串行化,基本不会冲突。free-threaded 下,两个线程可能同时对同一个对象发起 &mut self 调用,此时 PyO3 的运行期借用检查会检测到冲突并抛出 Python 异常RuntimeError: Already borrowed),而不是让你数据竞争。

这意味着:能安全跑,但可能在你没预料的地方抛异常。健壮的做法是——对确实要跨线程共享可变状态的 pyclass,把可变字段包进 Mutex/RwLock/原子类型,把方法签名改成 &self,自己控制粒度:

use pyo3::prelude::*;
use std::sync::atomic::{AtomicU64, Ordering};

#[pyclass]
struct AtomicCounter {
    value: AtomicU64,
}

#[pymethods]
impl AtomicCounter {
    #[new]
    fn new() -> Self {
        AtomicCounter { value: AtomicU64::new(0) }
    }

    // 注意是 &self,不是 &mut self —— 绕开 pyclass 借用检查
    fn incr(&self) -> u64 {
        self.value.fetch_add(1, Ordering::Relaxed) + 1
    }
}

原则:free-threaded 下,让 pyclass 的方法尽量 &self + 内部同步原语,把并发正确性握在自己手里,而不是赌运行期借用检查不冲突。

6.4 全局缓存:GILOnceCell 与它的替身

扩展里常见「初始化一次的全局对象」——比如缓存一个从 Python 侧拿到的类型对象。老 PyO3 用 GILOnceCell<T>,靠 GIL 保证初始化只发生一次。free-threaded 下它仍能用,但语义变了:初始化闭包可能被多个线程并发执行多次(结果只有一个会被采用,其余丢弃)。所以:

  • 初始化闭包必须是幂等、无副作用的;
  • 有副作用的初始化,改用 std::sync::OnceLock + 显式同步。
use pyo3::prelude::*;
use pyo3::sync::GILOnceCell;
use pyo3::types::PyType;

static DECIMAL_TYPE: GILOnceCell<Py<PyType>> = GILOnceCell::new();

fn get_decimal_type<'py>(py: Python<'py>) -> PyResult<&Bound<'py, PyType>> {
    DECIMAL_TYPE
        .get_or_try_init(py, || {
            // 这段在 free-threaded 下可能被并发跑多次,必须幂等
            let decimal = py.import("decimal")?;
            let ty = decimal.getattr("Decimal")?.downcast_into::<PyType>()?;
            Ok(ty.unbind())
        })
        .map(|ty| ty.bind(py))
}

七、性能优化:跨界成本与零拷贝

Rust 快,但Python↔Rust 的跨界(FFI 边界)不是免费的。真实项目里,性能杀手往往不是算法,而是「跨界太频繁」和「无谓拷贝」。

7.1 减少跨界次数:批处理而非逐元素

反例——每个元素调一次 Rust:

# ❌ 一百万次 FFI 跨界
result = [fastmath.process_one(x) for x in data]

正解——一次把整个数组丢给 Rust:

use pyo3::prelude::*;

#[pyfunction]
fn process_batch(py: Python<'_>, data: Vec<f64>) -> Vec<f64> {
    // data 已经在进入函数时一次性从 Python 转成 Rust Vec
    py.allow_threads(|| {
        data.into_iter().map(|x| x * 2.0 + 1.0).collect()
    })
}

一次跨界 + 一次批量转换,吊打百万次跨界。

7.2 零拷贝:缓冲区协议与 numpy

上面的 Vec<f64> 仍然拷贝了一次数据。要处理大数组又不想拷,走缓冲区协议。配合 numpy crate(基于 PyO3)可以直接借用 numpy 数组的底层内存,零拷贝读写:

[dependencies]
numpy = "0.25"
use numpy::{PyArray1, PyReadonlyArray1, PyArrayMethods};
use pyo3::prelude::*;

#[pyfunction]
fn scale_inplace(mut arr: numpy::PyReadwriteArray1<f64>, factor: f64) {
    // 直接拿到 &mut [f64],指向 numpy 的底层缓冲,零拷贝
    let slice = arr.as_slice_mut().unwrap();
    for x in slice.iter_mut() {
        *x *= factor;
    }
}

#[pyfunction]
fn dot(a: PyReadonlyArray1<f64>, b: PyReadonlyArray1<f64>) -> f64 {
    let a = a.as_slice().unwrap();
    let b = b.as_slice().unwrap();
    a.iter().zip(b).map(|(x, y)| x * y).sum()
}

Python 侧:

import numpy as np, fastmath
x = np.arange(1_000_000, dtype=np.float64)
fastmath.scale_inplace(x, 2.0)   # 原地修改,无拷贝

对大数组,这比「Python list → Vec → 算 → Vec → Python list」快一个数量级,还省内存。

7.3 GIL 释放的粒度权衡

allow_threads 本身有开销(切换 thread state)。经验法则:

  • 计算耗时 > 几十微秒:释放,值。
  • 计算耗时 < 几微秒 的高频小函数:别释放,切换成本超过收益。
  • 循环里反复释放:把循环整体包进一次 allow_threads,而不是每轮一次。

八、生产打包:abi3、maturin 与 free-threaded wheel

写完扩展,还得让全世界的 Python 能装上。三个关键决策:

8.1 abi3:一次编译,多版本通用(但和 free-threaded 有取舍)

abi3(稳定 ABI)让你编译出的 wheel 能在多个 Python 版本上通用,省去为每个小版本单独出包:

pyo3 = { version = "0.25", features = ["extension-module", "abi3-py39"] }

abi3-py39 表示「兼容 3.9 及以上」。注意:free-threaded 构建目前不走标准 abi3——free-threaded 的稳定 ABI 还在演进。所以如果你要出 free-threaded wheel,通常得针对 python3.13t / python3.14t 单独构建,wheel 文件名带 t 后缀(如 cp314-cp314t)。

8.2 cibuildwheel + manylinux 出全平台包

CI 上用 cibuildwheel,它已支持 free-threaded 目标:

# pyproject.toml
[tool.cibuildwheel]
# 同时构建常规版和 free-threaded 版
build = "cp39-* cp310-* cp311-* cp312-* cp313-* cp314-*"
enable = ["cpython-freethreading"]  # 打开 t 后缀构建

pypa/manylinuxpypa/cibuildwheel 都已支持带 t 后缀的 free-threaded 目标,直接产出 manylinux 兼容的双版本 wheel。

8.3 条件编译:一套代码适配两种构建

如果某段逻辑要区分 GIL / free-threaded,用 PyO3 暴露的 cfg:

#[cfg(Py_GIL_DISABLED)]
fn concurrency_note() -> &'static str {
    "running on free-threaded build"
}

#[cfg(not(Py_GIL_DISABLED))]
fn concurrency_note() -> &'static str {
    "running with GIL"
}

配合 sys._is_gil_enabled() 做运行期日志,排障时能快速确认扩展到底跑在哪种模式。


九、一个完整的实战范例:并行文本词频统计扩展

把前面的点串起来,做一个真正有用、且在 free-threaded 下安全的扩展:读入一批文档,多核统计全局词频。

use pyo3::prelude::*;
use pyo3::types::PyDict;
use rayon::prelude::*;
use std::collections::HashMap;

/// 对一批文档做并行词频统计,返回 Python dict
#[pyfunction]
fn word_count(py: Python<'_>, docs: Vec<String>) -> PyResult<Py<PyDict>> {
    // 1) 重活在纯 Rust 世界完成,释放解释器让别的 Python 线程能跑
    let counts: HashMap<String, u64> = py.allow_threads(|| {
        docs.par_iter()
            .map(|doc| {
                // 每个文档单独统计,得到局部 map
                let mut local = HashMap::new();
                for word in doc.split_whitespace() {
                    *local.entry(word.to_lowercase()).or_insert(0u64) += 1;
                }
                local
            })
            // 2) reduce 合并所有局部 map —— 无锁,靠 rayon 归约
            .reduce(HashMap::new, |mut a, b| {
                for (k, v) in b {
                    *a.entry(k).or_insert(0) += v;
                }
                a
            })
    });

    // 3) 拿回解释器访问权后,再一次性构建 Python dict
    let dict = PyDict::new(py);
    for (word, count) in counts {
        dict.set_item(word, count)?;
    }
    Ok(dict.unbind())
}

#[pymodule(gil_used = false)]
fn textstats(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(word_count, m)?)?;
    Ok(())
}

这个例子把本文的核心工程要点全用上了:

  1. gil_used = false:声明支持 free-threaded,不给自己招来「自动重开 GIL」。
  2. allow_threads:把 CPU 重活期间的解释器访问权让出去。
  3. 纯 Rust 内做并发:rayon 的 par_iter + reduce,全程不碰 Python 对象、无共享可变状态、无锁竞争——这是 free-threaded 下最安全的并发范式:把并发关在 Rust 里,只在边界处碰 Python
  4. 边界处一次性转换:进来时批量收 Vec<String>,出去时一次性建 dict,跨界成本降到最低。

Python 侧:

import textstats
docs = [open(f).read() for f in file_list]
freq = textstats.word_count(docs)   # 多核跑,主线程/其他线程不被独占

python3.14t 上,这个 word_count 调用期间,其他 Python 线程可以照常执行——这在带 GIL 的时代是做不到的(虽然 rayon 那部分本来就多核,但调用它的那个 Python 线程仍会攥着 GIL)。


十、迁移检查清单:把老扩展搬进 free-threaded 时代

给一份可直接对照的 checklist:

  1. 升级 PyO3 到 0.24+,最好 0.25。
  2. 消灭所有 GIL Refs&PyAnyBound<'py, PyAny>&PyListBound<'py, PyList>,以此类推。
  3. 函数返回值&PyDictBound<'py, PyDict>Py<PyDict>(要跨界存储时)。
  4. 审查全局状态static mutlazy_staticGILProtected 一律重审;GILProtectedMutex/原子。
  5. 审查 #[pyclass]:跨线程共享的可变字段包 Mutex/RwLock/Atomic,热点方法尽量 &self
  6. GILOnceCell 初始化闭包:确认幂等,有副作用的换 OnceLock + 显式同步。
  7. #[pymodule(gil_used = false)]:仅当你真的做完了上面所有步骤。没做完就别加——带 GIL 跑着更安全。
  8. CI 加 free-threaded 目标cibuildwheel 打开 cpython-freethreading,出 t 后缀 wheel。
  9. 压测验证:多线程并发调用你的扩展,观察有没有 Already borrowed 异常、有没有数据竞争(可用 cargo +nightly 的 ThreadSanitizer 辅助)。
  10. 保留退路:短期内同时发布常规 wheel 和 free-threaded wheel,让用户自选。

十一、总结与展望

把这篇的核心结论浓缩成几句能带走的话:

  • free-threaded 不是「Python 变快了」,而是「扩展作者的安全模型变了」。GIL 曾经免费帮你串行化一切,现在这层保护没了。
  • Rust + PyO3 是无 GIL 时代最舒服的扩展方案。因为 Send/SyncUngil、pyclass 借用检查,把「你会不会数据竞争」这个问题从运行期赌博变成了编译期约束。
  • 最稳的并发范式:把并发关在 Rust 里,边界处才碰 Pythonallow_threads + rayon + 局部数据归约,既快又不会踩 Python 对象的线程安全雷区。
  • gil_used = false 是承诺不是开关。签字前先做完迁移清单,否则宁可让 CPython 帮你重开 GIL。
  • 打包上,abi3 与 free-threaded 暂时是两条线,用 cibuildwheel 出双版本 wheel 平滑过渡。

再往前看,Phase III(free-threaded 成为默认)大概率会在未来一到两个 Python 大版本落地。到那天,「我的扩展支不支持无 GIL」不再是加分项,而是能不能被安装使用的及格线。现在就动手迁移,等于给自己抢出一两年的缓冲期。

Rust 生态这几年在 Python 扩展领域的攻城略地——pydantic-corepolarsruffuvtokenizers——本质上都在讲同一个故事:性能敏感、并发敏感的那部分,正在系统性地从 C/纯 Python 迁往 Rust。而 free-threaded Python 的到来,只会让这个趋势加速。如果你是后端、数据、AI 基础设施方向的工程师,PyO3 大概率会是你未来几年绕不开的一把利器。

现在,去把你那个跑得慢的 Python 模块,用 Rust 重写一小块试试看。你会回不去的。


本文所有代码基于 PyO3 0.25.x、Python 3.14 free-threaded 构建的公开文档与 API 语义整理,具体版本行为请以官方 CHANGELOG 与 migration guide 为准。

复制全文 生成海报 PyO3 Rust Python free-threaded GIL 无GIL CPython maturin

推荐文章

JavaScript设计模式:发布订阅模式
2024-11-18 01:52:39 +0800 CST
Gai:AI 原生的 Go Web 全栈框架
2026-05-21 16:19:43 +0800 CST
支付轮询打赏系统介绍
2024-11-18 16:40:31 +0800 CST
程序员茄子在线接单