歴史: Rustのメモリ管理は、単一のグローバルアロケータインターフェイス(GlobalAlloc、Rust 1.28で安定化)から、より柔軟かつ型に配慮したシステム(Allocator、現在は不安定だがstd::allocで利用可能)へと進化しました。GlobalAllocは、オペレーティングシステムのメモリプリミティブ(例: malloc、VirtualAlloc)への低レベルのブリッジとして機能し、型情報なしで生ポインタとバイトサイズのみに基づいて操作します。
問題は、GlobalAllocがコンパイラに検証できない生のメモリ操作を公開することで発生します。実装者は、重要な不変条件を手動で強制する必要があります: 整列保証、割り当て/解放のペアリング、および二重解放の禁止。GlobalAllocはBox、Vec、Rcを支えているため、違反が発生すると未定義の動作がプログラム全体に波及し、これらの安全契約に対する責任をプログラマーが負うことを示すためにunsafe implマーカーが必要です。
解決策は、Layout契約を厳守することです。allocメソッドはLayout::align()を満たすポインタを返さなければならず、deallocは割り当て時に使用された同一のレイアウトでのみ呼び出されなければなりません。さらに、アロケータは安全な抽象に参照されている間にメモリが再利用されないことを保証しなければなりません。Allocatorトレイトは、これらのリスクを軽減し、Layout計算を内部で処理し、unsafeな操作を基盤のGlobalAlloc実装に委譲する安全で汎用的なインターフェースを提供します。
use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicUsize, Ordering}; struct CountingAllocator { bytes_allocated: AtomicUsize, } unsafe impl GlobalAlloc for CountingAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { let ptr = System.alloc(layout); if !ptr.is_null() { self.bytes_allocated.fetch_add(layout.size(), Ordering::SeqCst); } ptr } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { System.dealloc(ptr, layout); self.bytes_allocated.fetch_sub(layout.size(), Ordering::SeqCst); } } #[global_allocator] static GLOBAL: CountingAllocator = CountingAllocator { bytes_allocated: AtomicUsize::new(0), };
高頻度取引エンジンを開発しているチームは、標準ライブラリのアロケータがグローバルヒープでのロック競合による許容できない遅延ジッターを引き起こしていることに気付きました。彼らは、NUMAローカルで決定論的なメモリアクセスを確保するために、大きなページから事前に割り当てられたカスタムバンプアロケータを必要としました。
いくつかのソリューションが評価されました。最初のアプローチは、システムアロケータをミューテックスで保護されたプールでラップすることを検討しましたが、これは単に競合を移動させ、遅延要件に対して違反するだけでした。第二のアプローチは、不安定なAllocator APIを使用し、特定の順序構造に対して型付けされたアリーナを作成するものでしたが、これにはコードベース全体でのVecとBoxの使用の膨大なリファクタリングが必要で、商業展開のための安定性の懸念がありました。
最終的に選択された第3の解決策は、取引スレッド内のすべての動的割り当てを intercept し、mmap領域によって支えられるスレッドローカルバンプアロケータを通過させるGlobalAllocを実装しました。この実装は、バンプアロケータが生ポインタを管理し、返されるポインタが最大64バイトのキャッシュライン境界で整列していることを保証する必要があったため、unsafe implを必要としました。チームは既存のコレクションタイプを変更することなく、システム全体に介入できるこの道を選びましたが、常に元の割り当てに合わせてdeallocに渡されるLayoutが一致することを確認するために、Miriを用いた厳格なテストを要求しました。その結果、p99の遅延が40%減少しましたが、チームは、市場の異常なボラティリティ期間中にメモリリークを防ぐために、unsafeコードブロックに対して厳格な監査プロトコルを維持しました。
なぜ、deallocに渡されるLayoutはallocに与えられたものと正確に一致しなければならなくて、サイズが異なっていても整列が正しい場合、何が起こりますか?
GlobalAlloc契約は、割り当てと解放に使用されたLayout間でビット単位の同一性を要求します。なぜなら、多くのアロケータ(jemallocやdlmallocなど)は、割り当てられたブロック内にメタデータを埋め込むか、サイズクラスで区分されたリストを維持するからです。異なるサイズ、たとえ小さいサイズを渡すと、アロケータは誤ったビンを探すか、統合のために不正確なオフセットを計算して、ヒープの破損や二重解放の脆弱性を引き起こします。これはCのfreeとは異なり、通常はポインタのみを要求するため、Rustの要求は厳密ですが、アロケータの無知さを必要とします。
ボックスが後でドロップされたとき、GlobalAllocはBox::newとどのように相互作用し、アロケータそのもののDropを実装することが問題なのはなぜですか?
Box::newが呼び出されると、コンパイラは**#[global_allocator]静的を介してGlobalAlloc::alloc呼び出します。Boxをドロップするとき、コンパイラはタイプのLayoutを自動的に計算してGlobalAlloc::deallocを呼び出します。候補者は、GlobalAllocの実装自体が'static**でスレッドセーフ(Syncを実装している)でなくてはならないことを見落としがちですが、それは管理している割り当てられたメモリを参照する状態を保持してはいけません。これは循環依存を引き起こし、アロケータをドロップすることがその自身にアクセスすることを必要とし、プログラムの終了中に使用後の解放を引き起こす可能性があります。
alloc_zeroedとallocの安全性要件の違いは何ですか?そして、なぜ実装が単にallocに続いてstd::ptr::write_bytesを呼び出せないのですか?**
alloc_zeroedは理論的にはallocプラスゼロ化として実装できるかもしれませんが、標準ライブラリはそれを別のメソッドとして提供しています。これは、アロケータがOS特有のゼロ化されたページの最適化を活用できるようにするためです(例: MAP_ANONYMOUSはLinuxでゼロ化されたページを返します)。安全性の観点から、alloc_zeroedは返されるメモリがゼロバイトを含むことを保証しなければならず、これはallocよりも強い後条件です(これは初期化されていないメモリを返します)。実装がゼロ化を偽って無効なガーベジを返した場合、ゼロ初期化を想定した安全なコード(セキュリティに敏感なコンテキストにとって重要)が初期化されていないデータを読み込み、Rustの安全性保証に違反することになります。