0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PyO3でGIL前提の共有状態をRust側のMutexへ移す

0
Posted at

PyO3でGIL前提の共有状態をRust側のMutexへ移す

PyO3でRust拡張を書いたからといって、free-threaded Pythonでそのまま安全に並列実行できるわけではありません。特に危ないのは、これまでGILが事実上直列化していた共有状態です。

PyO3 0.28以降は、モジュールがfree-threaded Pythonをサポートすることがデフォルトになりました。つまり移行時に見るべき場所はPythonのスレッド生成コードだけではなく、Rust側で「同時に呼ばれない」と仮定していた箇所です。

ここではPyO3 0.29.2とPython 3.14のfree-threaded buildを前提に、小さなカウンタを使って境界を整理します。

PyO3 0.28以降は「GILが必要」がデフォルトではない

まず押さえておきたい変更があります。

PyO3のmigration guideによると、0.28からPyO3で作るモジュールはfree-threaded Pythonをサポートすることがデフォルトになりました。GILによる直列化が必要なモジュールは、逆に明示する必要があります。

#[pymodule(gil_used = true)]
mod my_extension {
    // GILによる直列化が必要な実装
}

gil_used = trueを指定した拡張をfree-threaded buildでimportすると、Python側はGILを再度有効化できます。

一方、指定しなければPyO3 0.28以降では、モジュールはGILを必要としないものとして扱われます。

これは「PyO3が共有状態を全部ロックしてくれる」という意味ではありません。

Python 3.14のfree-threading HOWTOにもある通り、free-threaded buildでは複数のスレッドが並列に実行できます。拡張モジュール内部に共有状態があれば、その状態をどう同期するかは拡張側の設計問題になります。

PyO3 0.29では3.13tのサポートが外れ、3.14t以降がfree-threaded版の対象として継続されています。変更履歴はPyO3 0.29.2 CHANGELOGで確認できます。

&mut selfなら安全、では意味が足りない

最小のカウンタを考えます。

Cargo.tomlは次の程度です。

[package]
name = "counter_ext"
version = "0.1.0"
edition = "2024"

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

[dependencies]
pyo3 = { version = "0.29.2", features = ["extension-module"] }

PyO3の#[pyclass]に可変カウンタを持たせます。

use pyo3::prelude::*;

#[pyclass]
struct Counter {
    value: u64,
}

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

    fn increment(&mut self) -> u64 {
        self.value += 1;
        self.value
    }
}

#[pymodule]
mod counter_ext {
    #[pymodule_export]
    use super::Counter;
}

Rustだけを見れば、incrementが&mut selfを取っているので「同時書き込みは起きない」と感じます。

ただしPythonから見たCounterは、複数スレッドから参照され得る一つのPythonオブジェクトです。

free-threaded Pythonでは、次のような呼び出しが自然に発生します。

from concurrent.futures import ThreadPoolExecutor
from counter_ext import Counter

counter = Counter()

def work():
    return counter.increment()

with ThreadPoolExecutor(max_workers=8) as pool:
    futures = [pool.submit(work) for _ in range(1000)]
    results = [f.result() for f in futures]

print(len(results))

ここで「Rustだからデータ競合にはならない」と「APIとして同時呼び出しを正しく処理できる」は別の話です。

PyO3はpyclassへの可変アクセスについてランタイムのborrow checkingを持っています。同じインスタンスに対して可変borrowが重なると、free-threaded環境ではRuntimeError: Already borrowedになる場合があります。

つまり、メモリ安全性のために競合を拒否することと、複数の呼び出しを直列化してカウンタとして成立させることは同じではありません。

同期をAPIの意味として必要とするなら、その同期を状態そのものに持たせる必要があります。

GILではなくRustの型が共有状態を守る

PyO3でGIL前提の共有状態をRust側のMutexへ移す

カウンタなら、所有者をRust側に置き、同期方法まで型に含める方が意図が明確です。

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

#[pyclass(frozen)]
struct Counter {
    value: Mutex<u64>,
}

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

    fn increment(&self) -> PyResult<u64> {
        let mut value = self
            .value
            .lock()
            .map_err(|_| pyo3::exceptions::PyRuntimeError::new_err(
                "counter lock poisoned"
            ))?;

        *value += 1;
        Ok(*value)
    }

    fn get(&self) -> PyResult<u64> {
        let value = self
            .value
            .lock()
            .map_err(|_| pyo3::exceptions::PyRuntimeError::new_err(
                "counter lock poisoned"
            ))?;

        Ok(*value)
    }
}

#[pymodule]
mod counter_ext {
    #[pymodule_export]
    use super::Counter;
}

ここではCounter自体をfrozenにし、内部可変性をMutex<u64>に限定しています。

重要なのはMutexを使ったこと自体より、責務の位置です。

以前の発想は、

Pythonが呼ぶ
    ↓
GILがある
    ↓
たぶん同時に触られない
    ↓
valueを書き換える

でした。

変更後は、

Pythonが呼ぶ
    ↓
複数スレッドから同時に来てもよい
    ↓
Counter.valueのMutexを取る
    ↓
valueを書き換える

になります。

この形なら、通常のCPythonでもfree-threaded buildでも、「valueを変更するときはMutexを保持する」という不変条件は変わりません。

GILの有無を状態管理の前提から外せます。

なお、Python APIへアクセスしながらRustのロックを長時間保持する設計には別の注意が必要です。PyO3には、Python runtimeとの相互作用を考慮してMutexを取得するための仕組みもあります。単純な整数の更新とは違い、ロック中にPythonオブジェクトを操作するならPyO3のthread safetyガイドまで確認した方が安全です。

Python境界と純Rust処理を混ぜない

free-threaded対応で見落としやすいのは、ロックを追加した結果、処理全体を直列化してしまうことです。

たとえば実際の拡張が、

Pythonの引数を読む
→ キャッシュを確認
→ 重いRust計算
→ キャッシュを更新
→ Pythonオブジェクトを返す

という構造だったとします。

この全体を一つのMutexで囲めば安全側には倒せますが、重いRust計算まで一列に並びます。free-threadedにした意味を自分で消してしまいます。

分けたいのは「PythonかRustか」だけではなく、共有状態へ触る区間と、共有しなくてよい区間です。

たとえば概念的には次の形です。

fn calculate(&self, input: u64) -> PyResult<u64> {
    if let Some(value) = self.lookup(input)? {
        return Ok(value);
    }

    // 共有状態をロックせず計算できる区間
    let result = expensive_pure_rust_calculation(input);

    self.store(input, result)?;

    Ok(result)
}

キャッシュなら、検索時と更新時だけロックし、計算そのものはロック外へ出せます。

MutexとRwLockの選択も同じ観点で考えます。読み取りが多いから無条件にRwLock、という話ではありません。クリティカルセクションの長さ、更新頻度、ロック競合を実際のアクセスパターンに合わせて判断します。

さらに、PythonオブジェクトそのものをRustの共有状態へ保存する場合は話が増えます。Python runtimeへのアクセスとRustのロック取得順序が絡むため、「Mutexに入れたので終わり」にはなりません。

PyO3のSupporting Free-Threaded CPythonでも、従来GILで保護していた静的な可変状態についてstd::sync::Mutexやatomic型への移行が示されています。

ここから分かるのは、free-threaded対応を「GILを外す作業」と捉えない方がよいということです。

確認したいのは次の4点です。

  1. Pythonから同じRustオブジェクトが同時に呼ばれるか
  2. Rust内部のどの状態が複数スレッドで共有されるか
  3. その状態を守っていたものが本当にRustの型なのか、それとも暗黙にGILだったのか
  4. ロックを保持したままPython APIや時間のかかる処理を呼んでいないか

Python 3.14ではsys._is_gil_enabled()で実行時にGILが有効か確認できます。テスト時には通常版とfree-threaded版を分けるだけでなく、実際にGILが無効な状態かを確認しておくと、テストしたつもりでGILが再有効化されていた、という取り違えを避けられます。

free-threaded Pythonでまず監査したいのは「どこを並列化するか」ではなく、「どの不変条件をGILに預けていたか」です。そこをRustのMutex、RwLock、atomic型などへ移した後で初めて、ロック区間を縮める価値や並列化できる計算区間が見えてきます。

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?