メモリフェンスの概念は、CPUがスループットを最大化するために順序を無視して実行するハードウェアメモリモデルに由来します。Rustのstd::sync::atomic::fenceは、データを変更することなく異なる場所のメモリ操作の順序制約を確立するために、これらの低レベルのプリミティブを公開しています。原子操作はデータの変更と順序保証を結びつけるのに対し、フェンスはメモリアクセスの可視性ルールを強制する同期障壁として機能します。
一般的な誤解は、原子変数にOrdering::SeqCstを使用することで、スレッド間の無関係なメモリ位置への以前の全ての書き込みが自動的に同期されるというものです。これは誤りです。なぜなら、SeqCstは原子操作そのものに対してのみ完全な順序を提供し、他のデータのための伝播する発生-前関係を提供しないからです。スレッドAがバッファに書き込み、次に原子フラグへのReleaseストアを実行した場合、スレッドBがそのフラグに対してAcquireローディングを行っても、フェンスや強い順序が2つのドメインをリンクしない限り、バッファへの書き込みを見ることはできません。
これを解決するために、**fence(Ordering::Release)**は、プログラム順序でそれに先行する全てのメモリ操作が他のスレッドに可視化されることを保証します。その後の原子ストアの前に。逆に、fence(Ordering::Acquire)は、その後の全てのメモリ操作が別のスレッドにおける対応するReleaseフェンスの前に書き込まれた値を観察することを保証します。このペアの同期は、原子変数だけでなく、メモリ状態全体にわたる発生-前エッジを作成します。これにより、制御とデータチャネルが分離されたロックフリーアルゴリズムが可能になります。
ゼロコピーのネットワークパケットプロセッサを考えてみましょう。一つのスレッドがパケットデータで共有リングバッファを満たし、ヘッダポインタを更新する一方、別のスレッドがポインタを読み取ってパケットを処理します。プロデューサは通常の書き込み(非原子的操作)を使用してバッファにパケットバイトを書き込んだ後、Ordering::Releaseを使用してヘッダインデックスを原子的にインクリメントし、新しいデータの可用性を示します。コンシューマはインデックスが変更されるのを待ち、その後バッファからパケットデータを読み取ります。
一つの考えられた解決策は、全バッファとインデックスをstd::sync::Mutexで保護することでした。この方法はメモリ安全性と逐次的一貫性を保証しますが、深刻な競合を引き起こします。各パケット書き込みはロックの取得を必要とし、プロデューサを直列化し、キャッシュ局所性を破壊します。このアプローチでは、高頻度取引の要件に対してスループットが許可できないレベルに低下しました。このため、低遅延システムには不適切です。
別の考慮されたアプローチは、ヘッダポインタに対してRelease/AcquireペアをOrdering::SeqCstに置き換えることでした。これは、その全体の順序によりバッファの書き込みを暗黙的にフラッシュするだろうと仮定しました。しかし、これは失敗しました。なぜなら、SeqCstはSeqCst操作そのものの間にのみ完全な順序を確立し、コンパイラとCPUは非原子的なバッファ書き込みを原子ストアの後に再配置する自由があるからです。その結果、コンシューマはバッファデータの古い読み取りをしながら更新されたヘッダインデックスを観察する可能性があり、見かけ上は強い原子的順序にもかかわらずメモリ安全性に違反します。
選ばれた解決策は、バッファ書き込みを全て完了した後、しかしヘッダインデックスの更新をストアする前に、プロデューサ側で**fence(Ordering::Release)を挿入することでした。コンシューマスレッドは、ヘッダインデックスを読み込んだ後、バッファポインタを逆参照する前にfence(Ordering::Acquire)**を置きました。このペアリングは、インデックスの更新が公開される前にバッファの書き込みが全体に可視化され、インデックスが同期されるまでコンシューマが推測的にバッファを読み取ることができないことを保証し、ロックなしでデータ競合を排除します。
その結果、マイクロ秒単位の遅延で毎秒数百万のパケットを処理できるロックフリーのSPSC(単一プロデューサ単一コンシューマ)キューが誕生しました。ベンチマークでは、Mutexベースのアプローチに対して10倍の改善が示され、MiriおよびLoomの同時実行チェックツールの下でゼロデータ競合が確認されました。これは、適切なフェンスの使用がハードウェアレベルのパフォーマンスに匹敵しながら、Rustの安全性保証を維持できることを示しました。
なぜ独立したAcquireローディングが、たとえそのスレッドが同じ変数に対してReleaseストアを使用したとしても、生成スレッド内の以前の非原子的書き込みの可視性を保証しないのか?
独立したAcquireローディングは、その特定の原子位置でのReleaseストアとのみ同期します。つまり、その変数に限られた発生-前関係を作成します。他の書き込みを同期するためには、プロデューサはストアの前にReleaseフェンスを使用する必要がある、またはコンシューマがローディングの後にAcquireフェンスを使用する必要があります。これらのフェンスがなければ、コンパイラは原子ストアの後に非原子的な書き込みを再配置する場合があり、CPUはそれらの可視化を遅らせ、無関係なデータに対してデータ競合を引き起こす可能性があります。
コンパイラはRelaxed原子操作をどのように最適化し、なぜこれがx86_64上で直感に反する古い読み取りを引き起こす可能性があるのか?
ハードウェアが強い順序を提供するx86_64上でさえ、Relaxed操作は原子的であること(壊れた読み取り/書き込みなし)を保証しますが、周囲の操作に対する順序制約を課しません。コンパイラはRelaxedローディングやストアを他の命令と再配置したり、レジスタに値を保持したりする自由があるため、スレッドはプログラムの論理的な流れに対して古い値を観察することがあります。候補者はしばしばハードウェアのコヒーレンスをコンパイラの保証と誤解し、Relaxedがコンパイラの最適化に対する保護を提供しないことを忘れがちであり、再配置を防ぐためにAcquire/Releaseセマンティクスが必要です。
SeqCstフェンスとAcquireおよびReleaseフェンスの組み合わせとの違いは何か、そしてSeqCstのグローバルな完全順序が必要とされる特定のアルゴリズム上の要件は何か?**
SeqCstフェンスは、全スレッド間での全SeqCst操作のグローバルに一貫した完全な順序を強制します。これにより、各スレッドがこれらのイベントの同じシーケンスを観察することを保証します。一方、Acquire/Releaseフェンスは、特定のスレッドとメモリ位置間の対に同期を確立するだけで、全体的な合意は得られません。SeqCstは、Dekkerの相互排除アルゴリズムや分散タイムスタンプカウンタなど、無関係な操作の相対的な順序に関して、複数のスレッドが独立して同じ結論に達する必要があるアルゴリズムに不可欠です。単純なプロデューサ-コンシューマーシナリオでは、Acquire/Releaseの対に同期が十分であり、より高性能です。