2 に答える
volatilecomplete
にすると、次の 2 つのことが行われます。
これにより、C# コンパイラまたはジッターが の値をキャッシュする最適化を行うことができなくなります
complete
。これは、一貫性を確保するために、読み取りのプリフェッチまたは書き込みの遅延のいずれかを含む他の読み取りおよび書き込みのキャッシュ最適化を非最適化する必要があることをプロセッサに伝えるフェンスを導入します。
最初に考えてみましょう。ジッターは、ループの本体を確認する権利の範囲内に完全に収まっています。
while(!complete) toggle = !toggle;
は変更されないためcomplete
、complete
ループの最初にある値は、永久に保持される値です。したがって、ジッターは、あなたが書いたかのようにコードを生成することができます
if (!complete) while(true) toggle = !toggle;
または、より可能性が高い:
bool local = complete;
while(local) toggle = !toggle;
volatilecomplete
にすると、両方の最適化が妨げられます。
しかし、探しているのは volatile の 2 番目の効果です。2 つのスレッドが異なるプロセッサで実行されているとします。それぞれに、メイン メモリのコピーである独自のプロセッサ キャッシュがあります。両方のプロセッサがメイン メモリのコピーを作成し、そのコピーcomplete
が false であるとします。1 つのプロセッサのキャッシュcomplete
が true に設定されている場合、complete
揮発性でない場合、「トグル」プロセッサはその事実に気付く必要はありません。まだ偽である独自のキャッシュがあり、complete
毎回メインメモリに戻るとコストがかかります。
揮発性としてマークcomplete
すると、この最適化が排除されます。それをどのように排除するかは、プロセッサの実装の詳細です。おそらく、すべての揮発性書き込みで、書き込みがメインメモリに書き込まれ、他のすべてのプロセッサがキャッシュを破棄します。あるいは、別の戦略があるかもしれません。プロセッサがそれを実現するためにどのように選択するかは、製造元次第です。
要点は、フィールドを volatile にして読み書きすると、コードを最適化するコンパイラ、ジッタ、およびプロセッサの機能が大幅に阻害されるということです。そもそも揮発性フィールドを使用しないようにしてください。高レベルの構造を使用し、スレッド間でメモリを共有しないでください。
私は文を視覚化しようとしています:「取得フェンスは、他の読み取り/書き込みがフェンスの前に移動されるのを防ぎます...」そのフェンスの前にあってはならない命令は何ですか?
指示について考えることは、おそらく逆効果です。一連の命令について考えるのではなく、読み取りと書き込みのシーケンスに集中してください。他のすべては無関係です。
メモリのブロックがあり、その一部が 2 つのキャッシュにコピーされているとします。パフォーマンス上の理由から、主にキャッシュの読み取りと書き込みを行います。ときどき、キャッシュをメイン メモリと再同期します。これは一連の読み取りと書き込みにどのような影響を与えますか?
これを単一の整数変数に発生させたいとします。
- プロセッサ Alpha はメイン メモリに 0 を書き込みます。
- プロセッサ Bravo は、メイン メモリから 0 を読み取ります。
- プロセッサ Bravo はメイン メモリに 1 を書き込みます。
- プロセッサ Alpha はメイン メモリから 1 を読み取ります。
実際に何が起こるかは次のとおりです。
- プロセッサ Alpha はキャッシュに 0 を書き込み、メイン メモリに同期します。
- プロセッサ Bravo はメイン メモリからキャッシュを同期し、0 を読み取ります。
- プロセッサ Bravo はキャッシュに 1 を書き込み、キャッシュをメイン メモリに同期します。
- プロセッサ Alpha は、キャッシュから 0 (古い値) を読み取ります。
実際に起こったことは、これとどのように違うのでしょうか?
- プロセッサ Alpha はメイン メモリに 0 を書き込みます。
- プロセッサ Bravo は、メイン メモリから 0 を読み取ります。
- プロセッサ Alpha は、メイン メモリから 0 を読み取ります。
- プロセッサ Bravo はメイン メモリに 1 を書き込みます。
違いはありません。キャッシングは、「書き込み読み取り書き込み読み取り」を「書き込み読み取り読み取り書き込み」に変えます。読み取りの 1 つを時間的に後方に移動し、この場合は同等に、書き込みの 1 つを時間的に前方に移動します。
この例では、1 つの場所に対して 2 回の読み取りと 2 回の書き込みが行われていますが、多くの場所に対して多くの読み取りと書き込みが行われるシナリオを想像することができます。プロセッサには、読み取りを時間的に後方に移動し、書き込みを時間的に前方に移動する広い許容範囲があります。どの移動が合法で、どの移動がプロセッサごとに異ならないかについての正確なルール。
フェンスは、読み取りが逆方向に移動したり、書き込みが前方に移動したりするのを防ぐバリアです。したがって、次の場合:
- プロセッサ Alpha はメイン メモリに 0 を書き込みます。
- プロセッサ Bravo は、メイン メモリから 0 を読み取ります。
- プロセッサ Bravo はメイン メモリに 1 を書き込みます。ここにフェンス。
- プロセッサ Alpha はメイン メモリから 1 を読み取ります。
プロセッサがどのようなキャッシュ戦略を使用していても、read 4 をフェンスの前の任意のポイントに移動することは許可されていません。同様に、書き込み 3 をフェンスの後の任意の時点に移動することはできません。プロセッサがフェンスをどのように実装するかは、プロセッサ次第です。
メモリバリアに関する私の回答のほとんどと同様に、矢印表記を使用します。ここで、↓ は取得フェンス (揮発性読み取り) を表し、↑ は解放フェンス (揮発性書き込み) を表します。他の読み取りまたは書き込みは、矢印の頭を越えて移動できないことに注意してください (ただし、矢印の尾を越えて移動することはできます)。
まず、書き込みスレッドを分析しましょう。それが1complete
として宣言されていると仮定します。、、およびは完全なフェンスを生成するため、これらの各呼び出しの両側に上下の矢印があります。volatile
Thread.Start
Thread.Sleep
Thread.Join
↑ // full fence from Thread.Start
t.Start();
↓ // full fence from Thread.Start
↑ // full fence from Thread.Sleep
Thread.Sleep(1000);
↓ // full fence from Thread.Sleep
↑ // release fence from volatile write to complete
complete = true;
↑ // full fence from Thread.Join
t.Join();
↓ // full fence from Thread.Join
ここで注目すべき重要な点の 1 つは、書き込みがそれ以上下にフロートするThread.Join
のを妨げているのは呼び出しであるということです。complete
ここでの効果は、書き込みがすぐにメイン メモリにコミットされることです。メインメモリにフラッシュされるのは、それ自体の揮発性ではありません。その動作を引き起こしているのcomplete
は、呼び出しとそれが生成するメモリバリアです。Thread.Join
次に、読み取りスレッドを分析します。ただし、これは while ループのために視覚化するのが少し難しいですが、これから始めましょう。
bool toggle = false;
register1 = complete;
↓ // half fence from volatile read
while (!register1)
{
bool register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓ // half fence from volatile read
}
ループをほどくと、よりよく視覚化できるかもしれません。簡潔にするために、最初の 4 つの反復のみを示します。
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓
complete
ループが巻き戻されたので、読み取りの潜在的な動きが大幅に制限されることがわかると思います。2はい、コンパイラまたはハードウェアによって少しシャッフルされる可能性がありますが、すべての反復で読み取られるようにほとんどロックされています。readcomplete
は引き続き自由に移動できますが、それによって作成されたフェンスは一緒に移動しないことに注意してください。そのフェンスは所定の位置にロックされています。これが、「フレッシュ リード」と呼ばれることが多い動作の原因です。volatile
が省略された場合complete
、コンパイラは「リフティング」と呼ばれる最適化手法を自由に使用できます。これは、メモリアドレスの読み取りがループ外で抽出または持ち上げられる場所です。volatile
その最適化がない場合は合法です。の読み取りのうち、complete
すべてが最終的にループの外に出るまでフロートアップ (またはリフト) することが許可されます。その時点で、コンパイラはループを開始する直前にそれらすべてを 1 回の読み取りに結合します。3
今重要なポイントをいくつかまとめてみましょう。
Thread.Join
への書き込みがメイン メモリにコミットされるのはへの呼び出しcomplete
であり、ワーカー スレッドが最終的にそれを取得します。のボラティリティはcomplete
、書き込みスレッドとは無関係です (これはおそらくほとんどの人にとって驚くべきことです)。- の揮発性読み取りによって生成された取得フェンスが、
complete
その読み取りがループの外に持ち出されるのを防ぎ、それによって「新しい読み取り」動作が作成されます。読み取りスレッドのボラティリティがcomplete
大きな違いを生みます (これはおそらくほとんどの人にとって明らかです)。 - 「コミットされた書き込み」と「新鮮な読み取り」は、揮発性の読み取りと書き込みによって直接引き起こされるわけではありません。しかし、それらは間接的な結果であり、特にループの場合にほとんど常に発生します。
1 x86 書き込みには既に揮発性セマンティクスがあるため、書き込みスレッドでas としてマークする必要はありません。complete
volatile
2読み取りと書き込みは矢印の尾部を通って移動できますが、矢印は所定の位置にロックされていることに注意してください。そのため、ループ外ですべての読み取りをバブルアップすることはできません。
3リフティングの最適化では、スレッドの実際の動作が、プログラマーが最初に意図したものと一致していることも確認する必要があります。この場合、コンパイラーはcomplete
そのスレッドに書き込みが行われていないことを簡単に確認できるため、この要件は簡単に満たすことができます。