Rust 1.36以前、開発者は後で初期化される値のためにスタックメモリを割り当てるためにstd::mem::uninitializedを使用していました。この関数は基本的に非安全であり、コンパイラにそのメモリ位置に有効なTが存在すると伝えていましたが、実際にはビットはランダムでした。安全の不変式を持つ型(例えばbool、char、または参照)は、コンパイラがその値が有効であるという仮定に基づいて最適化するため、直ちに未定義の動作に至りました(例: boolが0または1であること)。RFC 1892は、まだ有効なTを持たないメモリを明示的に示すために**MaybeUninit<T>**を導入し、この安全性の穴を解決しました。
コアとなる問題は、LLVMの初期化されていないメモリの扱いがundefまたはpoisonであることと、Rustの自動ドロップグルーの生成が相まっていることから来ています。コンパイラが型Tの変数が生存していると考えると、デストラクタの呼び出しやニッチ最適化を行う可能性があります。Tがboolのとき、初期化されていないバイトが値2を持っていると、ビットの有効性の不変式が違反します。ドロップチェックや識別子検査の際にこれを読み取ることは未定義の動作を構成します。さらに、初期化が配列の途中で失敗した場合、配列型のドロップグルーはすべての要素をドロップしようとし、初期化されていないスタックバイトをポインタとして解釈し、use-after-freeまたはdouble-freeエラーを引き起こす可能性があります。
MaybeUninit<T>は、有効なTを持つ場合と持たない場合がある型付きコンテナとして機能します。これにより、コンパイラが初期化を仮定することを防ぎ、ドロップグルーの生成や無効なビットパターンの最適化を抑制します。プログラマーは通常、別々のインデックスまたはブール配列を介してどのインスタンスが初期化されているかを手動で追跡しなければなりません。値を抽出するためには、assume_init、assume_init_ref、またはstd::ptr::readを使用しますが、必ずwriteまたはポインタ操作を介して有効なTを書き込んだ後に行います。重要な不変式は、assume_initは完全に初期化されていないメモリに対して決して呼び出されないべきであり、部分的に初期化された構造体を放棄する際には、プログラマーが手動で初期化された要素のみをptr::drop_in_placeを使用してドロップし、リソースリークを避けなければならないということです。
use std::mem::{self, MaybeUninit}; use std::ptr; fn init_array_fallible<T, E, const N: usize>( mut f: impl FnMut(usize) -> Result<T, E>, ) -> Result<[T; N], E> { let mut array: [MaybeUninit<T>; N] = unsafe { MaybeUninit::uninit().assume_init() }; let mut i = 0; while i < N { match f(i) { Ok(val) => { array[i].write(val); i += 1; } Err(e) => { for j in 0..i { unsafe { ptr::drop_in_place(array[j].as_mut_ptr()); } } return Err(e); } } } Ok(unsafe { mem::transmute::<[MaybeUninit<T>; N], [T; N]>(array) }) }
あなたは、ヒープの割り当てが禁止され、レイテンシが決定論的でなければならないため、no_stdカーネルドライバーをネットワークインターフェースカード用に開発しています。1024のConnectionオブジェクトの固定サイズテーブルをスタックに割り当てる必要があります。各Connectionの初期化は、NICバッファが満杯の場合に失敗する可能性があるハードウェアレジスタへの書き込みを含みます。500番目の接続が失敗した場合には、前の499が適切に閉じられ(ファイルディスクリプタをドロップしてDMAマッピングを解放)、残りの524スロットはそのままにして未定義の動作を避けることを保証するのが課題です。
1つの潜在的なアプローチは、Default::default()を使用してセンチネル値で配列を事前に初期化することです。これにはConnectionがDefaultを実装する必要があり、これは問題です。なぜなら、「デフォルト」の接続は今でもカーネルリソースを取得するため、明示的に解放しなければならず、エラーパスを複雑にします。さらに、1024のダミー接続を構築してオーバーライトするだけでは、初期化サイクルを無駄にしてしまい、ドライバーのインターフェースをオンラインにするための厳しいタイミング要件に違反します。
2つ目の戦略は、Vec<Connection>をwith_capacityで使用し、動的にプッシュした後に固定配列に変換します。これはユーザースペースコードでは安全で慣用的ですが、Vecはグローバルアロケーターを必要とし、これはこのカーネルコンテキストでは利用できません。また、カーネル空間では受け入れられないパニックパスやメモリの断片化を引き起こし、固定サイズの配列への変換にはランタイムチェックが必要となり、エラーハンドリングロジックを複雑にします。
3つ目のアプローチは、MaybeUninit<[Connection; 1024]>を利用して初期化せずにストレージを割り当てます。正常に初期化された接続はMaybeUninit::writeを介して書き込まれ、エラーがインデックスiで発生した場合、手動で0からi-1まで繰り返し、初期化されたスロットごとにptr::drop_in_placeを呼び出してエラーを返します。成功した場合、全体の配列を初期化された型にトランスミュートします。このソリューションを選択した理由は、決定論的なパフォーマンスでゼロコストのスタック割り当てを提供し、no_std制約を満たし、リソースのクリーンアップが真に初期化されたオブジェクトのみを対象とすることを保証するからです。その結果、部分的な障害回復中に未定義の動作を引き起こさず、一貫したマイクロ秒レベルの初期化レイテンシを維持する堅牢なドライバーが得られました。
なぜ初期化されていないMaybeUninit<T>に対してassume_initを呼び出すことは、後で値が明示的に読み取られなくても未定義の動作を構成するのですか?
多くの候補者は、未定義の動作はデータに物理的にアクセスしたとき(例えば、それを印刷したり、そこから分岐したりするとき)にのみ発生すると考えています。しかし、Rustの型システムは、assume_initが呼び出されるとすぐに有効なTが存在することをコンパイラに通知します。ニッチ最適化を持つ型(bool、char、Option<&T>、またはNonNull<T>など)については、コンパイラがビットパターンを検査して列挙型の変数や有効性を判断するコードを生成する可能性があります。メモリがランダムなビットを保持している場合(例えば、boolの0xFF)、この検査はLLVMにおいて未定義の動作を引き起こします(poisonまたはundefを読み込むことになる)。さらに、スコープが終了すると、コンパイラはTのためにドロップグルーを挿入し、ゴミデータに対してデストラクタを実行しようとし、クラッシュやセキュリティの脆弱性を引き起こすことになります。このように、assume_initはプログラマーが有効な初期化を保証する契約です。これを破ると、明示的な読み取りに関わらずコンパイラの状態が壊れます。
MaybeUninit::writeを使用することと、MaybeUninit::as_mut_ptr()によって返されるポインタに対してstd::ptr::writeを使用することの違いは何ですか?それぞれはいつ適切ですか?
MaybeUninit::writeは、安全なメソッドであり、Tの所有権を取得し、初期化されていないスロットに書き込み、現在初期化されたデータへの可変参照を返します。値が準備できていて、直ちに安全にアクセスしたい場合に推奨されます。一方、std::ptr::writeはunsafe関数であり、古い値を読み取ったりドロップしたりせずに、生のポインタに値を書き込みます(メモリが初期化されていないため、これは重要です)。**as_mut_ptr()**から取得した生ポインタを介して書き込むときは、writeの借用チェッカーの制約を避ける必要があるため、ptr::writeを使用しなければなりません。また、ポインタだけを持つ低レベルの抽象を実装する場合にも使用します。主な違いは、writeが安全性の保証とライフタイム追跡を提供するのに対し、ptr::writeは宛先が有効で適切に整列されており、未初期化であることを手動で確認する必要があることです。そうしないと、エイリアシング違反や早期のドロップを引き起こす恐れがあります。
どのようにMaybeUninit<T>の部分的に初期化された配列を正しくドロップし、リソースをリークせず、未定義の動作を引き起こさないようにするのか、操作の順序がなぜ重要なのか?
初期化がインデックスiで失敗した場合、要素0..iのみをドロップする必要があります。正しい手順は、0からi-1まで繰り返し、std::ptr::drop_in_place(array[j].as_mut_ptr())を呼び出すことです。これにより、MaybeUninitラッパーから値を移動させずにTのデストラクタを実行します(これは、スロットが移動済みの状態に残るのを防ぎ、根本的には未初期化ですが)。このクリーンアップを失敗直後に、エラーを返す前に実行することが重要です。そうしないと、mem::forgetを配列に対して使用したり単にリターンしたりすると、MaybeUninitラッパーが削除されます(ノーオペレーション)になりますが、内部の生のTインスタンスがリソースをリークします(ファイルハンドルやヒープメモリなど)。逆に、要素i..Nを誤ってドロップすると、無効なTインスタンスとしてゴミメモリを扱うことで未定義の動作を引き起こしてしまうでしょう。