アロケータとメモリレイアウト

このページの目次

アロケータは、サイズとアラインメントの要件を満たすストレージを提供し、型ルールはどのバイトが有効な値を構成するかを決定し、所有権はいつデストラクションと解放を行うかを決定します。これらの層を分離することで、「空でないポインタを取得した」ことがすでに有効な T を持っていることを意味しない理由を理解できます。

Layout を計算するサイズ、アラインメント、オーバーフローをチェックする1ストレージを割り当て、失敗をチェックする成功すると、アラインメントされた未初期化メモリが得られる2T を初期化して使用する有効な値を書き込み、所有権とアクセスルールを維持する3ライフサイクルを終了し、ストレージを解放する4バイトの要求と有効な T の作成は異なる段階です必要に応じて T をデストラクトし、一致するアロケータと Layout を使用する通常のビジネスロジックでは、Box、Vec などの RAII コンテナを優先し、手動で完全なパスを管理しないようにしてください。

グローバルアロケータとシステムアロケータ

Rust は「すべてのプラットフォームでデフォルトで glibc malloc を呼び出す」ことを言語契約として規定していません。System はプラットフォームシステムアロケータを表し、異なるシステムでは異なるインフラストラクチャを使用します。デフォルトの選択と静的/動的ライブラリ環境についても、ターゲットドキュメントを参照してください。System

以下の完全なプログラムは System を明示的に選択しており、フォワードするために GlobalAlloc の手書き実装は不要です。

use std::alloc::System;

#[global_allocator]
static GLOBAL: System = System;

fn main() {
    let values = vec![10, 20, 30];
    assert_eq!(values.iter().sum::<i32>(), 60);
}

カスタム GlobalAlloc は unsafe 実装の責任です。割り当てと解放の契約を満たさなければならず、これらのメソッドで unwind してはいけません。また、アロケータ内部で再割り当てが発生しうるログやコンテナを使用してはいけません。そうしないと、再帰的に自分自身に入り込んでしまいます。

「ソースコードに Box::new と書かれているから必ずアロケータが一度呼び出される」という前提に依存してプログラムの正しさを維持しないでください。オプティマイザは割り当てを削除したり変更したりする可能性があります。割り当て回数は制限付きの観測に使用できますが、安全性不変条件としては使用できません。GlobalAlloc

Layout:サイズ、アラインメント、および表現可能な範囲

Layout::new::() は型からレイアウトを取得し、Layout::array::(n) は配列のサイズをチェックします。from_size_align は、アラインメントが 0 でなく 2 のべき乗であることを要求し、サイズをアラインメントに丸めた結果が isize::MAX を超えないことを保証します。

Layout 自体はサイズ 0 を許可していますが、GlobalAlloc::alloc の呼び出し契約では非ゼロサイズが要求されます。「Layout の作成に成功した」ことを「任意の割り当て API に対して直接呼び出せる」と見なしてはいけません。異なる割り当てインターフェースにおけるサイズ 0 のルールはそれぞれ確認する必要があります。Layout

use std::alloc::Layout;

fn main() {
    assert!(Layout::from_size_align(16, 3).is_err());
    assert!(Layout::array::<u64>(usize::MAX).is_err());
    let first = Layout::from_size_align(1, 1).unwrap();
    let next = Layout::from_size_align(4, 4).unwrap();
    let (combined, offset) = first.extend(next).unwrap();
    let padded = combined.pad_to_align();
    assert_eq!(offset, 4);
    assert_eq!(padded.size(), 8);
    assert_eq!(padded.align(), 4);
}

extend は第二部分のオフセットを返すため、フィールドサイズを無チェックで足し合わせてはいけません。pad_to_align は末尾をパディングし、連続するオブジェクトもアラインメントを満たすようにします。この例では明示的な Layout でストレージを組み合わせるものであり、任意の Rust struct が同じフィールド順序を採用することを証明するものではありません。

末尾のパディングが配列のストライドに影響する理由

レコードにまず 4 バイト、アラインメント 4 のフィールドを置き、次に 1 バイトのフィールドを置くと仮定します。2 番目のフィールドのオフセットは 4 で、組み合わせの有効な末端は 5 です。もしオフセット 5 に次のレコードを配置すると、その最初のフィールドは 4 バイトアラインメントの要件を満たさなくなります。したがって、全体のストライドは 8 にパディングする必要があります。

use std::alloc::Layout;
fn main() {
    let word = Layout::from_size_align(4, 4).unwrap();
    let tag = Layout::from_size_align(1, 1).unwrap();
    let (record, tag_offset) = word.extend(tag).unwrap();
    assert_eq!(tag_offset, 4);
    assert_eq!(record.size(), 5);
    assert_eq!(record.pad_to_align().size(), 8);
    assert_eq!(record.pad_to_align().align(), 4);
}

ここでの 3 バイトの末尾パディングは、次の要素のアラインメントを保証するためのもので、ビジネスフィールドを担っていません。外部プロトコルへのエンコーディング時にはフィールドを記述すべきであり、パディングバイトも安定したコンテンツとして送信したりハッシュしたりしてはいけません。レイアウトの算術演算はスペースの配置を解決しますが、パディングが初期化されているかどうか、値が有効かどうか、ポインタがアクセス可能かどうかの問題は解決しません。

生の割り当て:null をチェックしてから初期化する

use std::alloc::{alloc, dealloc, handle_alloc_error, Layout};

fn main() {
    let layout = Layout::new::<u64>(); // 非ゼロサイズ
    // SAFETY: layout は alloc の要件を満たしています。
    let storage = unsafe { alloc(layout) };
    if storage.is_null() {
        handle_alloc_error(layout);
    }
    let pointer = storage.cast::<u64>();
    // SAFETY: ストレージは十分に大きく、アラインメントも適切です。排他的アクセスであり、書き込み後に読み込みます。
    let answer = unsafe {
        pointer.write(42);
        let value = pointer.read();
        // u64 は追加のデストラクションは不要です。アロケータ、アドレス、Layout は要求と一致しています。
        dealloc(storage, layout);
        value
    };
    assert_eq!(answer, 42);
}

alloc が返すストレージは未初期化です。通常の代入は古い値の置換を伴う可能性がありますが、生の初期化には適切な write または MaybeUninit インターフェースを使用する必要があります。リソースを含む T に対しては、read が所有権を移動したかどうか、drop_in_place が必要かどうかを明確にし、リークや重複デストラクションを防ぐ必要があります。

dealloc はストレージのみを解放し、T::drop を自動的に呼び出しません。この u64 の例では、割り当てと解放の間に失敗しうるビジネスロジックを意図的に含めていません。より複雑な構築では、部分的な初期化とパニックパスを処理する必要があり、通常は Box、Vec、または専用の RAII ラッパーに任せるべきです。

スロット 0有効な Tスロット 1有効な Tスロット 2未初期化スペーススロット 3未初期化スペーススロット 4未初期化スペースlen = 2:読み込みとデストラクションが許可されるVec のキャパシティ境界と有効な要素の境界は分離する必要がありますcapacity = 5:利用可能なストレージであり、5 つの T が既に存在することを意味しない図はキャパシティが 5 であることを仮定しています。割り当て API の具体的なキャパシティは capacity を読み取るべきであり、要求値から逆算してはいけません。

Vec:有効な長さを拡張する前に初期化する

Vec::with_capacity はスペースを予約しますが、len は 0 のままなので、空のスロットをインデックスで直接読み取ることはできません。spare_capacity_mut は空き部分を [MaybeUninit<T>] として公開し、書き込んだ後に len を更新して、これらの値を Vec のアクセスとデストラクションの責任に含める必要があります。Vec 空きキャパシティ

fn main() {
    let mut values = Vec::<u32>::with_capacity(5);
    assert_eq!(values.len(), 0);
    assert!(values.capacity() >= 5);
    {
        let spare = values.spare_capacity_mut();
        spare[0].write(10);
        spare[1].write(20);
    }
    // SAFETY: スロット 0、1 は有効な u32 が書き込まれており、2 は capacity を超えていません。
    unsafe { values.set_len(2); }
    assert_eq!(values, [10, 20]);
    values.push(30);
    assert_eq!(values, [10, 20, 30]);
}

これは初期化の境界を理解するための低レベルな例です。通常の追加では push を直接使用する方が保守が容易です。set_len を実行してから項目ごとに初期化してはいけません。途中でパニックが発生すると、Vec はまだ有効でない T のデストラクションを試みる可能性があります。逆に、String を書き込んでも len に含めない場合、Vec はそれをデストラクトすべきことを知らず、リソースをリークする可能性があります。

バッチ構築に失敗しうるステップがある場合は、コンテナが完了したオブジェクトを項目ごとに引き受けるように優先してください。以下では2番目の要素でエラーを返し、Vec に既に配置された最初のオブジェクトは自動的に解放されます。

use std::cell::Cell;
use std::rc::Rc;
struct Tracked(Rc<Cell<usize>>);
impl Drop for Tracked {
    fn drop(&mut self) { self.0.set(self.0.get() + 1); }
}
fn build(drops: &Rc<Cell<usize>>) -> Result<Vec<Tracked>, &'static str> {
    let mut items = Vec::with_capacity(3);
    for index in 0..3 {
        if index == 1 { return Err("構築に失敗しました"); }
        items.push(Tracked(Rc::clone(drops)));
    }
    Ok(items)
}
fn main() {
    let drops = Rc::new(Cell::new(0));
    assert!(build(&drops).is_err());
    assert_eq!(drops.get(), 1);
}

RAII の価値は、エラーパスと正常パスが所有権ルールを共有することにあります。本当に生のバッチ初期化が必要な場合は、専用のガードが初期化された数を記録し、早期終了時にその接頭辞のみをデストラクトする必要があります。有効でないストレージ全体に対して配列のデストラクションロジックを適用してはいけません。

Box を先に割り当て、値を初期化する

fn main() {
    let mut storage = Box::<u64>::new_uninit();
    storage.write(42);
    // SAFETY: 前の行で唯一の u64 が完全に初期化されており、その後それを破壊していません。
    let value: Box<u64> = unsafe { storage.assume_init() };
    assert_eq!(*value, 42);
}

Box::new_uninit は Box<MaybeUninit<T>> を返し、assume_init は呼び出し手が T が有効であることを証明することに依存します。割り当てのみを行い、初期化せずにこの Box を解放することは許可されていますが、MaybeUninit は手動で書き込まれた T に対してデストラクションを自動的に呼び出しません。初期化した後は責任を正しく移行する必要があります。ゼロ埋めもこの証明に代わるものではありません。全ゼロは任意の T の有効な表現ではありません。Box::new_uninit

容量・初期化・len の 3 つの境界

len は有効値が存在するというコンテナの約束で、添字範囲だけでなく破棄対象も決めます。初期化と len の境界を別々に動かすと、先に len を増やす不正と、書いた String の破棄を管理し忘れる問題を区別できます。通常の push はこの 2 段階を安全にまとめます。

図を準備しています
容量・初期化・len の 3 つの境界

len 未満はすべて有効値である必要があり、len 外に書いた String は Vec が自動破棄しません。

グローバル置換とコンテナごとのアロケータは同じ API ではない

GlobalAlloc と #[global_allocator] は安定したインターフェースです。標準ライブラリの Allocator trait や、コンテナごとにアロケータを選択する new_in などのインターフェースは、今回の確認済みドキュメントでは allocator_api 実験機能としてマークされています。nightly の例をそのまま安定した Rust の使用方法と呼んではいけません。Allocator

アロケータの選択は、フラグメンテーション、レイテンシ、スレッド競合、およびワークロードの測定に基づいて行うべきであり、名前による一般的な高速化の約束に依存してはいけません。予測可能なバッチオブジェクトに対しては、まず割り当て回数の削減、キャパシティの予約、またはデータ構造の変更を検討してください。

キャパシティの予約、キャパシティの解放、およびプロセスメモリは同じ指標ではない

操作または指標これに基づいて判断できること直接導き出せないこと
Vec::clear要素が削除され、ルールに従ってデストラクトされ、len が 0 になったキャパシティがアロケータに返還された
drop(Vec)コンテナが所有するストレージを解放したOS の RSS が同じバイト数だけ即座に減少した
try_reserveキャパシティのオーバーフローまたは割り当て失敗を処理できる Result入力規模がビジネスリソース予算に適合した
shrink_to_fit不要なキャパシティを縮小するよう要求したキャパシティが len と一致し、メモリが即座に OS に返還された

アロケータは解放されたブロックをキャッシュする可能性があり、ページには生存中のオブジェクトが混在する可能性があるため、RSS とオブジェクトが所有する有効なバイト数は異なります。「リーク」を分析する際は、依然として到達可能なオブジェクトの継続的な増加、コンテナによるキャパシティの保持、アロケータによる保持、およびシステムのメモリ統計が採用する定義を区別する必要があります。RSS が一度戻らないからといって、責任を特定できるわけではありません。

入力として宣言された数量を予約する前に、ビジネスの上限を検証し、リソースの失敗に対して try_reserve を使用してください。サイズ算術がオーバーフローしなくても、攻撃的または誤った入力は受け入れられないキャパシティを要求する可能性があります。ジェネリックコンテナでは、サイズ 0 の型も考慮する必要があります。それらのキャパシティセマンティクスを直接実際の要求バイト数に変換することはできません。

repr と Pin がそれぞれ保証すること

repr(C) は、構造体のレイアウトが対応する C のレイアウトルールに従うことを制約しますが、フィールド型の表現を再帰的に変更するわけではありません。repr(align(N)) はアラインメントを高め、パディングを増やす可能性もあります。ターゲット ABI、フィールドの有効値、バイトオーダーは依然として考慮する必要があり、構造体のメモリをそのままクロスプラットフォームシリアライズ形式として使用してはいけません。

Pin はアドレス依存の契約を表現し、アロケータを決定するものでもなく、型を自動的にムーブ不可能にするものでもありません。Unpin 型の場合、安全なインターフェースを通じてムーブ可能な &mut T を取得し続けることができます。String と裸のポインタのみを含む自己参照構造体は通常依然として Unpin であり、Pin<Box<_>> で囲むだけでは安全にはなりません。

自己参照抽象では、安定したアドレスで初期化を完了し、適切な !Unpin 設計(PhantomPinned など)を選択し、投影とムーブを制限し、デストラクションの契約に従う必要があります。固定されるのは pointee であり、それを持つ Box や Pin ラッパー自体はムーブ可能です。非同期 Future に関連するメカニズムについては async/await を、生のアクセス契約については 裸のポインタと FFI を参照してください。

Pin は通常のフィールドの書き換えを禁止しない

use std::pin::Pin;
fn main() {
    let mut pinned = Box::pin(String::from("old"));
    let text: &mut String = Pin::as_mut(&mut pinned).get_mut();
    let old = std::mem::replace(text, String::from("new"));
    assert_eq!(old, "old");
    assert_eq!(pinned.as_ref().get_ref(), "new");
}

String は Unpin であるため、安全なインターフェースはそれを書き換えることを許可します。PhantomPinned を持つカスタム型に置き換えたとしても、関連するムーブのエントリポイントが制限されるだけです。裸のポインタターゲットの初期化順序、投影がフィールドのムーブを許可するかどうか、デストラクション中に契約が維持されるかどうかは、抽象化作者の責任です。どのアドレス関係が維持されるべきかを明確にしてから、Pin が必要かどうかを決定すべきであり、Pin を一般的な「ヒープオブジェクトの強化」として使用してはいけません。

参考