質問の歴史
Rustの創世期、デザイナーたちは重要な行き詰まりに直面しました。サイクリックグラフやランタイム借用チェックされたコンテナなどの基本的なデータ構造は、共有参照を介しての変更が必要でしたが、これは言語の基礎的な公理である排他的な可変アクセスに直接反していました。この問題を、ゼロコストの抽象原則を損なうことなく解決するために、UnsafeCellが導入され、共有参照**&T**に関連する不変性の保証をオプトアウトする唯一のプリミティブとして機能し、すべての安全な内部可変性抽象の基盤となっています。
問題
Rustコンパイラは、&Tの不変性を活用して、値のキャッシュや命令の並べ替えのような攻撃的な最適化を行います。これは、参照のライフタイム中に基礎となるメモリが変更されないと仮定しています。UnsafeCellは、共有参照を介してアクセスされてもその内容が変更される可能性があることをコンパイラに示し、このデータに対する最適化を無効にします。しかし、このオプトアウトはUnsafeCell::get()を介して取得した生ポインタから派生した参照には適用されず、このポインタが&mut Tに変換されると、標準のエイリアス規則が厳格に再主張されます。
解決策
この解決策では、プログラマーはUnsafeCellの生ポインタから生成された任意の可変参照**&mut Tが、そのメモリへの唯一の**アクティブなアクセスパスでなければならないという不変条件を遵守しなければなりません。この排他性は、可変参照の存在中に他のポインタや参照を介しての読み書きを禁止します。UnsafeCellは借用チェッカーを無効にするわけではなく、単に一時的な排他性を保証し、データ競合を防ぐ責任をコンパイラから開発者に移転するだけです。
問題説明
私たちは、特定の金融商品に関連するカウンタを更新する複数のスレッド用の低遅延トレーディングシステム向けに高スループットのメトリクス集約器を設計していました。共有マップは初期化後に不変でしたが、メトリクス値は頻繁なインクリメントを必要としました。Mutex<u64>の使用は許容できないコンテンションをもたらし、一方でAtomicU64は複雑な合成メトリクスタイプには不十分でした。ランタイム借用チェックなしにArcポインタの背後にある構造体へのロックフリーでゼロアロケーションの更新が必要でした。
検討された異なる解決策
解決策1: シャーディングMutex
各メトリクスをMutexでラップし、256枚に分けてコンテンションを軽減する方法を評価しました。このアプローチは、簡単な安全性と維持管理のしやすいコードを提供しました。しかし、プロファイリングの結果、コンテンションがないMutex操作でも、futexシステムコールやキャッシュ整合性プロトコルにより数百ナノ秒を消費し、我々の厳格なサブマイクロ秒のレイテンシ予算に違反しました。
解決策2: Boxed値とAtomicPtr
別のアプローチでは、値をAtomicPtr<Metric>として保存し、更新のために比較交換ループを使用する方法を採用しました。これによりブロッキングが排除されましたが、各インクリメントごとに新しいBoxインスタンスを割り当てる必要があり、深刻なメモリプレッシャーとアロケーターのコンテンションを引き起こしました。さらに、メモリ回収が複雑になり、ハザードポインターやエポックベースのガーベジコレクションを必要とし、コードの複雑さと監査面積が大幅に増加しました。
解決策3: キャッシュライン揃えのUnsafeCell
私たちは、キャッシュライン揃えの構造体内にUnsafeCell<Metric>としてメトリクスを格納することを選択しました。これにより、異なるシャードに書き込むスレッドがキャッシュラインを共有することはありませんでした。各スレッドはUnsafeCell::get()を介して生ポインタを取得し、更新時にそれを&mut Metricにキャストしました—マニュアルでの排他アクセスを保証するシャーディングロジックにより、他のスレッドがその特定のスロットにアクセスできないことが保証されました—そして変更を行いました。これにはunsafeブロックが必要であり、競合アクセス中の衝突がないことを保証するために整合性のあるハッシュの明示的な証明が必要でした。
選択された解決策と理由
私たちは解決策3を選びました。これは生のメモリに対するゼロコストの抽象を提供し、攻撃的なレイテンシ要件を満たしたからです。シャーディング保証は排他アクセスの手動証明として機能し、ランタイム同期のオーバーヘッドなしにUnsafeCellを活用させました。安全性はMIRIとloomの並行モデルチェッカーを使用して検証され、すべての可能なスレッドのインターリーブにおいてエイリアシング違反が発生しないことを徹底的に確認しました。
結果
実装は、ホットパスでのゼロアロケーション時間でサブ100ナノ秒の更新レイテンシを達成しました。しかし、その後のリファクタリング中に、メンテナンスタスクが暗黙のシャードロックを取得せずにすべてのシャードを反復処理するという微妙な回帰が発生し、同じメトリクスに対して2つの可変参照が作成されました。MIRIはCI中にこれを未定義の動作として直ちにフラグ付けし、UnsafeCellが理論的に安全性を保証する場合でも厳格な規律を要求することを強調しました。
なぜUnsafeCellから派生した2つの可変参照を同時に保持することが未定義の動作になるのか、UnsafeCellが標準の借用規則から明示的にオプトアウトしているにもかかわらず?
UnsafeCellは型レベルで共有参照に対する不変性の保証からオプトアウトしますが、&mut T型自体の基本的な不変条件を緩和するわけではありません。get()を呼び出すと、ライフタイムやエイリアス制約を持たない生ポインタ*mut Tを受け取ります。しかし、このポインタを**&mut Tとして間接参照すると、コンパイラに対してこの参照が排他的であることを主張します。同じUnsafeCellからオーバーラップするメモリに対して2つのそのような参照を作成することは、Rustのメモリモデルの基盤であるエイリアシングXOR変更**ルールに違反し、どのように参照が構成されようとも即座に未定義の動作を引き起こします。
MIRIはUnsafeCellの不変条件の違反をどのように検出し、なぜコードは本番テストに合格する可能性があるが、MIRIでは失敗するのか?
MIRIはStacked Borrows(またはオプションでTree Borrows)エイリアシングモデルを実装しており、抽象的な「タグ」を通じてメモリアクセス権限を追跡します。UnsafeCellから参照を生成すると、MIRIは固有のタグを割り当てます。その後、最初の参照がアクティブな間に異なるタグを使用して同じメモリにアクセスしようとする試みは、違反を構成します。コードが標準テストに合格することが多いのは、ハードウェアメモリモデルが寛容であり、無害なデータ競合が実際にクラッシュとして顕在化しないためです。しかし、MIRIは理論モデルを厳格に施行し、適切な同期なしに同じUnsafeCellから共有参照を作成して可変参照を無効化するような違反を捉えます。たとえアセンブリが現在のCPUアーキテクチャで機能するとしてもです。
なぜCell<T>は変化に対してunsafeブロックを必要としないのか、一方でUnsafeCell<T>はその理由とこの違いを可能にする具体的な安全保証を特定してください。
Cell<T>は内部可変性をunsafeなしに実現します。なぜなら、それは内部データへの参照を決して公開せず、Copyを実装する型のために値をコピーする(set)か、Copyでない型のために移動させる(replace)ことのみを許可するからです。Cellは含まれる値に対して**&Tや&mut Tを絶対に返さないため、エイリアシングルールを違反することは不可能です—エイリアスに対する参照は存在しません。対照的に、UnsafeCellは生ポインタ*mut Tを返すget()**を提供し、参照の作成を可能にします。この柔軟性は複雑なインプレースの変更には必要ですが、排他性を確保し、データ競合を防ぐ責任が完全にプログラマーに移るため、unsafeブロックを必要とします。