2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python拡張でBox::leakした値を回収しない設計

2
Posted at

Python拡張でBox::leakした値を回収しない設計

Pythonから呼ぶRust拡張の内部で、Box::leakした値のアドレスを保存し、後からBox::from_rawで回収するコードは書けます。実行してDropまで走ると、問題がないようにも見えます。

しかしRust 1.99では、この「leakして後で戻す」使い方を避けるよう公式ドキュメントが更新されました。後で解放する所有権なら、最初からBox::into_non_nullで取り出し、Box::from_non_nullで戻すのが意図の明確な書き方です。

Pythonから見えるAPIが同じでも、unsafe境界の内側で何を所有しているのかは変えられます。PyO3の小さな拡張で差を確認します。

動くから正しい、とは限らない

環境はRust 1.99.0、PyO3 0.29.2を使います。Python側からRustの2つの実装を呼び、どちらも最終的にDropされたかを確認します。

Cargo.tomlの該当部分は次のとおりです。

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

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

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

まず、今回避けたい書き方です。

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

struct Tracked {
    dropped: Arc<AtomicBool>,
}

impl Drop for Tracked {
    fn drop(&mut self) {
        self.dropped.store(true, Ordering::SeqCst);
    }
}

#[pyfunction]
fn roundtrip_with_leak() -> bool {
    let dropped = Arc::new(AtomicBool::new(false));

    let leaked: &mut Tracked = Box::leak(Box::new(Tracked {
        dropped: Arc::clone(&dropped),
    }));

    let ptr: *mut Tracked = leaked;

    unsafe {
        drop(Box::from_raw(ptr));
    }

    dropped.load(Ordering::SeqCst)
}

#[pymodule]
mod box_roundtrip {
    use super::*;

    #[pymodule_export]
    use super::roundtrip_with_leak;
}

Box::leakはBox<T>を消費し、&mut Tを返します。その参照をポインタに変換し、Box::from_rawでBox<T>を再構築しています。

Pythonから見ると普通に動きます。

import box_roundtrip

assert box_roundtrip.roundtrip_with_leak() is True
print("dropped")
dropped

ここで厄介なのは、クラッシュしないことです。Dropも呼ばれています。

そのため「Box::leakしたけれど、元のアドレスが分かっているのだから後で戻せばよい」と考えたくなります。

Rust 1.99.0のリリースでは言語仕様そのものが変更されたわけではありません。一方、Box::leakのドキュメントには、後でメモリを解放するならBox::into_rawまたはBox::into_non_nullを使うよう明記されました。

理由として挙げられているのが、現在および将来のコンパイラ最適化との相互作用、そしてcustom allocatorとの関係です。

つまり問題は、この短いプログラムが今日動くかどうかではありません。

leakは一時的な所有権退避ではない

Python拡張でBox::leakした値を回収しない設計

Box::leakという名前のとおり、このAPIが表現している操作は「あとで回収するために所有権をポインタへ退避する」ことではありません。

型を見ると違いがはっきりします。

Box<T>
  -> Box::leak
&mut T

所有していたBoxから得ているのは参照です。

一方、Rust 1.99でstableになったBox::into_non_nullはこうなります。

Box<T>
  -> Box::into_non_null
NonNull<T>

こちらはBoxを消費したあと、そのメモリを管理する責任が呼び出し側へ移ります。公式ドキュメントも、後始末する簡単な方法としてBox::from_non_nullでBoxへ戻す方法を示しています。

この違いはunsafeコードでは無視しにくいものです。

&mut Tには単なる「NULLではないアドレス」以上の意味があります。Rustの参照としてaliasingや有効期間に関する前提を持ちます。そこから生ポインタを作り、「これは実質的には元のBoxの所有権だ」と別の意味を持たせ始めると、コード上の型と設計上の所有権がずれます。

さらにBox::leakの現在のドキュメントは、返されたmutable referenceからBoxを再構築するunleakingをgrey areaと表現し、多くの一見無害な操作がundefined behaviorになり得るため避けるよう求めています。

unsafeを書くときは、「この実行で壊れなかった」よりも「どのAPIから所有権を受け取り、どのAPIへ返すのか」を追えることのほうが頼りになります。

Python拡張ではNonNullを所有権として扱う

先ほどの関数をRust 1.99のAPIで書き直します。

#[pyfunction]
fn roundtrip_with_non_null() -> bool {
    let dropped = Arc::new(AtomicBool::new(false));

    let boxed = Box::new(Tracked {
        dropped: Arc::clone(&dropped),
    });

    let ptr = Box::into_non_null(boxed);

    unsafe {
        drop(Box::from_non_null(ptr));
    }

    dropped.load(Ordering::SeqCst)
}

モジュールにも公開します。

#[pymodule]
mod box_roundtrip {
    use super::*;

    #[pymodule_export]
    use super::{
        roundtrip_with_leak,
        roundtrip_with_non_null,
    };
}

Python側はほとんど変わりません。

import box_roundtrip

assert box_roundtrip.roundtrip_with_leak() is True
assert box_roundtrip.roundtrip_with_non_null() is True

重要なのは実行結果の差ではありません。どちらもこの最小例ではtrueになります。

違うのはRust側で説明できる所有権の流れです。

Box<Tracked>
    |
    | Box::into_non_null
    v
NonNull<Tracked>
    |
    | unsafe { Box::from_non_null(...) }
    v
Box<Tracked>
    |
    | drop
    v
deallocate

Box::into_non_nullを呼んだ時点で、元のBoxは消費されています。その後は呼び出し側がメモリ管理の責任を持ちます。

Box::from_non_nullを呼ぶと、そのポインタの所有権は再構築されたBoxへ移ります。同じNonNullから2回Boxを作ればdouble-freeにつながるため、ここは依然としてunsafeです。

つまり新しいAPIがunsafeを消してくれるわけではありません。unsafeで証明しなければならない所有権の流れを、APIの形と一致させやすくするための変更だと捉えるほうが分かりやすいです。

PyO3の拡張でPythonオブジェクトの外側にRustのヒープ領域を保持するような設計でも、この区別は効きます。Python側から整数化したアドレスやopaque handleしか見えない場合、Rust側で「これは借用なのか、解放責任を伴う所有ポインタなのか」が曖昧になるほど、後のレビューが難しくなります。

from_non_nullにも条件がある

Box::from_non_nullに変えれば任意のポインタを安全に解放できるわけではありません。

Rust 1.99のBox::from_non_nullはunsafe fnです。渡すNonNull<T>は、Boxが要求するメモリレイアウトに従って確保された領域を指している必要があります。今回のようにBox::into_non_nullから得た値を対応するBox::from_non_nullへ戻す形なら、その関係をコード上で追いやすくなります。

逆に、PythonやC側で確保したメモリ、別allocatorで確保した領域、すでに解放済みの領域をBox::from_non_nullへ渡してよい、という話ではありません。

custom allocatorまで扱う場合はさらに注意が必要です。Rust 1.99のリリースノートでも、round-trip unleakingの問題は今後のcustom allocator安定化との関係が特に問題になると説明されています。確保したallocatorと解放するallocatorの対応まで所有権の一部として考える必要があります。

既存コードを直すときも、Box::leakを機械的にBox::into_non_nullへ置換するのでは足りません。まず、そのメモリが本当に最後までleakする設計なのか、それとも一時的にBoxの外へ所有権を出したかっただけなのかを分けます。

前者ならBox::leakには用途があります。公式ドキュメントも、プログラム終了まで生存するデータなど、本当にleakさせる用途を想定しています。

後者なら、Rust 1.99以降はBox::into_non_nullとBox::from_non_nullの組み合わせを第一候補にできます。さらに生ポインタで十分なら、従来からあるBox::into_rawとBox::from_rawも所有権のround-tripとして文書化された経路です。

Python拡張でFFI境界を越えてポインタを長期間保持する場合は、NonNullへの置換だけで安全性が決まるわけではありません。Pythonオブジェクトの寿命、二重解放、スレッド間移動、allocatorの対応まで含めてunsafe invariantを決める必要があります。次に確認するなら、ポインタを単体で公開するのではなく、所有権を持つRust側の型に閉じ込めた場合にunsafe範囲をどこまで小さくできるか、です。

参考

2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?