もちろん、 前のトピックで得たフロー制御に関する知識は素晴らしいですが、まだ多くの質問があります。 たとえば、 「 事前にどのように動作しますか?」 、 「
volatile
がキャッシュのリセットであることは本当ですか?」 、 「なんらかの種類のメモリモデルがあったのはなぜ ですか?」 すべてが正常でした、何かが始まったのですか?」
前の記事と同様に、この記事は「最初に、理論上何が起こるべきかを簡単に説明し、次にソースに行き、そこでどうなるかを見る」という原則に基づいて構築されています。 したがって、最初の部分はJavaだけでなく、主に適用可能であるため、他のプラットフォームの開発者は自分にとって有益なものを見つけることができます。
理論的最小値
鉄の生産性の増加は、理由により増加しています。 たとえばプロセッサを開発するエンジニアは、コードからさらに抽象的なオウムを絞ることができるさまざまな最適化を考え出します。 ただし、無料のパフォーマンスはありません。この場合、コードの実行方法の直感に反する可能性は価格です。 抽象化によって私たちから隠された鉄のさまざまな特徴がたくさんあります。 まだこれを行っていない人は、量子パフォーマンス効果と呼ばれるセルゲイ・ウォルラス・ククセンコのレポートを読むことをお勧めします。 例については説明しませんが、キャッシュを見てみましょう。キャッシュデバイス
「メインメモリ」の要求は高価な操作であり、最新のマシンでさえ数百ナノ秒かかることがあります。 この間、プロセッサは多くの命令を完了する時間があります。 永続的なダウンタイムという形での悪さを避けるために、キャッシュが使用されます。 簡単に言えば、プロセッサは、メインメモリの頻繁に使用されるコンテンツのコピーをそのすぐ隣に保存します。 さまざまなタイプのキャッシュとその階層についてのより複雑な言葉はここにありますが、キャッシュ内のデータの関連性がどのように保証されるかにもっと興味があります。 そして、シングルプロセッサ(またはカーネル、将来プロセッサという用語が使用される)の場合、明らかに問題はない場合、いくつかのコア(YAY MULTITHREADING!)で 、質問が既に発生し始めています。
プロセッサAがキャッシュした場合、プロセッサBが何らかの値を変更したことをプロセッサAはどのように知ることができますか
または、言い換えると、 キャッシュの一貫性を確保する方法は?
さまざまなプロセッサが世界の一貫した状況を把握するには、何らかの方法で相互に通信する必要があります。 彼らがこのコミュニケーションで従う規則はキャッシュコヒーレンスプロトコルと呼ばれます 。
キャッシュコヒーレンスプロトコル
さまざまなプロトコルがあり、鉄の生産者によって異なるだけでなく、1つのベンダーのフレームワーク内でも常に進化しています。 ただし、プロトコルの世界は広大ですが、ほとんどのプロトコルにはいくつかの共通点があります。 一般性をやや低下させながら、 MESIプロトコルを検討します 。 もちろん、それとは根本的に異なるアプローチがあります。たとえば、 ディレクトリベースです。 ただし、この記事では考慮されていません。
MESIでは、キャッシュ内の各セルは次の4つの状態のいずれかになります。
- 無効:キャッシュに値がありません
- エクスクルーシブ:値はこのキャッシュにのみあり、まだ変更されていません
- 変更:値はこのプロセッサによって変更されており、これまでのところ、メインメモリにも他のプロセッサのキャッシュにもありません。
- 共有:複数のプロセッサのキャッシュに存在する値
状態から切り替えるには、メッセージ交換が行われます。その形式もプロトコルの一部です。 ちなみに、このような低レベルでは、メッセージの交換によって状態の変化が正確に発生するのはむしろ皮肉です。 問題、俳優モデル嫌い?
記事のサイズを縮小し、読者が独自に学習するように促すために、メッセージングについて詳しく説明しません。 希望する人は、たとえば、すばらしい記事Memory Barriers:a Hardware View for Software Hackersでこの情報を入手できます。 伝統的に、 チェレミンからのトピックに関するより深い考えは彼のブログで見つけることができます 。
メッセージ自体の説明を巧みにスキップして、それらについて2つの発言を行います。 まず、メッセージは即座に配信されないため、状態を変更するための待ち時間が発生します。 第二に、一部のメッセージには特別な処理が必要であり、プロセッサのダウンタイムが発生します。 これはすべて、さまざまなスケーラビリティとパフォーマンスの問題につながります。
MESIおよびそれらが引き起こす問題の最適化
バッファを保存する
共有状態のメモリロケーションに何かを書き込むには、Invalidateメッセージを送信し、全員が確認するのを待つ必要があります。 メッセージが到着する時間は通常、単純な命令を完了するのに必要な時間よりも数桁長いため、この間、プロセッサはアイドル状態になります。これは非常に悲しいことです。 このような無意味で容赦のないプロセッサー時間の損失を避けるために、彼らはStore Buffersを思いつきました。 プロセッサは、書き込みたい値をこのバッファに配置し、命令の実行を続けます。 そして、必要なInvalidate Acknowledgeが受信されると、データは最終的にメインメモリに送信されます。
もちろん、いくつかの水中熊手があります。 これらの最初のものは非常に明白です:値がバッファを出る前に、同じプロセッサがそれを読み込もうとすると、それは書いたものを受け取りません。 これは、 ストアフォワーディングを使用して解決されます。要求された値がバッファ内にあるかどうかを常に確認します。 そこにある場合、意味はそこから取られます。
しかし、2番目のレーキはすでにはるかに興味深いものです。 セルがストアバッファ内で同じ順序で配置された場合、セルが同じ順序で書き込まれることを保証するものはありません。 次の擬似コードを検討してください。
void executedOnCpu0() { value = 10; finished = true; } void executedOnCpu1() { while(!finished); assert value == 10; }
何がうまくいかないように思えますか? あなたが思うかもしれないことに反して、たくさん。 たとえば、コードの実行の開始までにCpu0がExclusive状態にあり、
value
が無効な状態にあることが判明した場合、
value
はbufferを
finished
よりも後に残します。 そして、Cpu1が
true
として
finished
し、
value
が10に等しくない可能性があり
value
。この現象は、 並べ替えと呼ばれます。 もちろん、並べ替えはこの場合だけでなく行われます。 たとえば、コンパイラは、何らかの理由で、いくつかの命令を交換する可能性があります。
キューを無効にする
簡単に推測できるように、ストアバッファは無限ではないため、オーバーフローする傾向があります。その結果、Invalidate Acknowledgeを待たなければならないことがよくあります。 また、プロセッサとキャッシュがビジーの場合、非常に時間がかかる場合があります。 この問題を解決するには、 Invalidate Queueという新しいエンティティを導入します。 メモリセルの無効化の要求はすべてこのキューに入れられ、確認応答が即座に送信されます。 実際、プロセッサーが快適に動作すると、値は無効になります。 同時に、プロセッサは適切に動作することを約束し、このセルが無効になるまでメッセージを送信しません。 キャッチを感じますか? コードに戻りましょう。
void executedOnCpu0() { value = 10; finished = true; } void executedOnCpu1() { while(!finished); assert value == 10; }
私たちが幸運だった(またはいくつかの秘密の知識を使用した)と仮定し、Cpu0は必要な順序でメモリセルを書き留めました。 これにより、同じ順序でCpu1キャッシュに落ちることが保証されますか? すでに理解できたように、いいえ。 また、
value
セルがExclusive状態のCpu1キャッシュにあると仮定します。 アクションの順序は次のようになります。
# | Cpu0 | Cpu0:値 | CPU0:終了 | CPU1 | Cpu1:値 | CPU1:終了 |
0 | (...) | 0(共有) | false(排他的) | (...) | 0(共有) | (無効) |
1 | | 0(共有)
(ストアバッファーに10個) | false(排他的) | |||
2 | | 0(共有) | (無効) | |||
3 | | 0(共有)
(ストアバッファーに10個) | true(変更済み) | |||
4 |
| 0(共有)
(無効化キュー内) | (無効) | |||
5 |
| 0(共有)
(ストアバッファーに10個) | true(共有) | |||
6 |
| 0(共有)
(無効化キュー内) | true(共有) | |||
7 | | 0(共有)
(無効化キュー内) | true(共有) | |||
アサーションが失敗する | ||||||
N |
| (無効) | true(共有) |
マルチスレッドは単純で簡単ですよね? 問題はステップ(4)-(6)にあります。 (4)で
invalidate
を受け取ったので、それを実行するのではなく、キューに書き込みます。 そして、ステップ(6)で、(2)よりも前に送信された
read
要求の
read_response
を取得します。 ただし、これは
value
を無効にすることを強制するものではないため、アサーションが低下します。 操作(N)が以前に実行された場合、まだチャンスがありますが、このいまいましい最適化はすべてを壊してしまいました! しかし、一方で、彼女はとても速く、私たちに超低遅延™を提供します! それがジレンマです。 鉄の開発者は、最適化の使用がいつ許可され、いつそれが何かを壊す可能性があるのかを魔法のように事前に知ることはできません。 そして、彼らは問題を私たちに伝え、「一人で行くのは危険です。 これを持って!」
ハードウェアメモリモデル
ドラゴンと戦うために行った開発者によって提供された魔法の剣は、実際にはまったく剣ではなく、ゲームのルールです。 それらは、プロセッサまたは別のプロセッサが特定のアクションを実行するときにプロセッサが見ることができる値を記述します。 しかし、メモリーバリアはすでに剣のようなものです。 検討しているMESIの例では、次のような剣があります。
ストアメモリバリア (ST、SMB、smp_wmbも)-この命令の後に続くストアを実行する前に、既にバッファ内にあるすべてのストアをプロセッサに実行させる命令
Load Memory Barrier (LD、RMB、smp_rmb)-プロセッサに、ロード命令を実行する前にキュー内のすべての無効化を強制的に適用する命令
新しい武器を自由に使用できるので、簡単に例を修正できます。
void executedOnCpu0() { value = 10; storeMemoryBarrier(); finished = true; } void executedOnCpu1() { while(!finished); loadMemoryBarrier(); assert value == 10; }
素晴らしい、すべてが機能し、満足しています! クールで生産的で正しいマルチスレッドコードを作成できます。 停止しますが...
Javaはどこにあるのでしょうか?
一度書くだけでどこでも実行
理論的には、これらのさまざまなキャッシュコヒーレンスプロトコル、膜、フラッシュされたキャッシュ、およびその他のプラットフォーム固有のものはすべて、Javaコードを書く人を心配するべきではありません。 Javaはプラットフォームに依存しませんよね? 実際、Java Memory Modelには並べ替えの概念はありません。
NB :このフレーズがあなたを混乱させるなら、その理由を理解するまで記事を読み続けないでください。 そして、例えば、 これを読んでください 。一般的に、面白いですね。 「並べ替え」の概念はありませんが、並べ替え自体はそうです。 当局は明らかに何かを隠しています! しかし、周囲の現実の陰謀評価を放棄したとしても、私たちは好奇心と知りたいという欲求のままです。 消しましょう! 最近の例を示す簡単なクラスを見てみましょう: [ github ]
public class TestSubject { private volatile boolean finished; private int value = 0; void executedOnCpu0() { value = 10; finished = true; } void executedOnCpu1() { while(!finished); assert value == 10; } }
伝統的に、そこで何が起こっているかを知るためのいくつかのアプローチがあります。
PrintAssembly
を楽しん
PrintAssembly
、インタープリターの機能を確認し
PrintAssembly
、既に知っている人から秘密の知識を絞り出したりできます。 不思議な表情で、キャッシュがそこに捨てられて落ち着いていると言うことができます 。
前回は、実際には運用環境では使用されないインタープリターを見ました。 今回は、クライアントコンパイラ(C1)の動作を見ていきます。 私は目的のためにopenjdk-7u40-fcs-src-b43-26_aug_2013を使用しました。
OpenJDKのソースコードを以前に開いていない人(およびそれを開いた人)にとって、必要なアクションがどこで実行されているかを見つけるのは困難な作業になる可能性があります。 これを行う最も簡単な方法の1つは、バイトコードを調べて目的の命令の名前を見つけ、それを検索することです。
$ javac TestSubject.java && javap -c TestSubject void executedOnCpu0(); Code: 0: aload_0 // this 1: bipush 10 // 10 3: putfield #2 // this (value) (10) 6: aload_0 // this 7: iconst_1 // 1 8: putfield #3 // this (finished) (1) 11: return void executedOnCpu1(); Code: 0: aload_0 // this 1: getfield #3 // this (finished) 4: ifne 10 // , 10( ) 7: goto 0 // 10: getstatic #4 // $assertionsDisabled:Z 13: ifne 33 // assertions , 33() 16: aload_0 // this 17: getfield #2 // this (value) 20: bipush 10 // 10 22: if_icmpeq 33 // , 33() 25: new #5 // java/lang/AssertionError 28: dup // 29: invokespecial #6 // ( <init>) 32: athrow // , 33: return
注意 :バイトコードを使用して、実行時のプログラムの正確な動作を決定しようとしないでください。 JITコンパイラーがその仕事をした後、すべてが大きく変わる可能性があります。ここでどんな面白いことがわかりますか? 多くの人が忘れている最初の小さなことは、アサーションがデフォルトでオフになっていることです。
-ea
を使用して、
-ea
有効にすることができ
-ea
。 しかし、これはそう、ナンセンスです。 ここに来たのは
getfield
と
putfield
名前です。 私が話しているのと同じことだと思いますか? (もちろん、Gleb!ベーコン、プランジャー、 2つのブラジャーからDyson Sphereをどのように構築するのですか?!!)
ウサギの穴を下る
同じ命令が両方のフィールドに使用されることに注意して、フィールドが
volatile
であるという情報がどこに含まれているか見てみましょう。 クラス
share/vm/ci/ciField.hpp
フィールドデータを格納するために使用されます。 方法に興味があります
| |
volatile
フィールドへのアクセスでC1が何をするかを調べるために、このメソッドのすべての使用法を見つけることができます。 ダンジョンを少し
share/vm/c1/c1_LIRGenerator.cpp
、古代の知識を持ついくつかの巻物を収集した後、ファイル
share/vm/c1/c1_LIRGenerator.cpp
自分自身を見つけます。 彼の名前が示唆するように、彼は私たちのコードの低レベル中間表現( LIR 、低レベル中間表現)を生成しています。
例としてputfield
を使用したC1中間表現
C1でIRを作成すると、ここで
putfield
命令が最終的に処理されます。
volatile
フィールドに対して実行される特別なアクションを検討し、すぐに使い慣れた言葉に出くわします。
| |
__
は
gen()->lir()->
展開されるマクロです。 そして、
membar_release
メソッド
membar_release
share/vm/c1/c1_LIR.hpp
定義されてい
share/vm/c1/c1_LIR.hpp
:
| |
| |
volatile_field_store
の実装
volatile_field_store
すでにプラットフォームに依存しています。 たとえば、x86(
cpu/x86/vm/c1_LIRGenerator_x86.cpp
)では、アクションは非常に簡単です。フィールドが64ビットかどうかをチェックし、そうであれば、Black Magicを使用して記録の原子性を保証します。
volatile
ない場合、
long
型と
double
型のフィールドは非原子的に記述できることをまだ覚えていますか?
そして最後に、最後に別のmembarが配置されます。今回はリリースなしです。
| |
| |
NB :もちろん、私は知らぬ間に進行中の活動のいくつかを隠しました。 たとえば、GCに関連する操作。 読者は独立した演習としてそれらを研究するように招待されています。
IRをアセンブラーに変換
STとLDのみを通過しましたが、ここには新しいタイプの障壁があります。 実際、以前見たものは低レベルのMESIの障壁の一例です。 そして、すでにより高いレベルの抽象化に移行しており、用語は多少変更されています。 ストアとロードの2種類のメモリ操作があるとします。 次に、ロードとロード、ロードとストア、ストアとロード、ストアとストアの2つの操作の4つの順序付き組み合わせがあります。 StoreStoreとLoadLoadの 2つのカテゴリを調査しました-MESIについて話しているときに見たのと同じ障壁があります。 他の2つも非常に簡単に消化できるはずです。 LoadStoreの前に生成されたすべてのロードは、ストアの前に完了する必要があります。 StoreLoadの場合 、それぞれ逆がtrueです。 これについては、たとえばJSR-133 Cookbookで詳しく読むことができます。
さらに、 Acquireセマンティクスを使用した操作とReleaseセマンティクスを使用した操作の概念は区別されます。 後者は書き込み操作に適用可能であり、この操作の前に実行されるメモリ操作は、開始する前に完了する必要があります。 言い換えると、書き込みと解放のセマンティクスを使用した操作は、プログラムテキスト内でその前にあるメモリを使用した操作で並べ替えることはできません。 LoadStore + StoreStoreメモリバリアの組み合わせにより、このようなセマンティクスを提供できます。 ご想像のとおり、Acquireは反対のセマンティクスを持ち、LoadStore + LoadLoadの組み合わせを使用して表現できます。
これで、JVMが配置する膜がわかりました。 ただし、LIRでのみ見ましたが、低レベルではありますが、JITが生成するネイティブコードではありません。 C1がLIRをネイティブコードに正確に変換する方法の研究はこの記事の範囲を超えているため、これ以上苦労することなく、ファイル
share/vm/c1/c1_LIRAssembler.cpp
ます。 そこで、IRからアセンブラーコードへの変換がすべて行われます。 たとえば、非常に不吉な行で
lir_membar_release
、
lir_membar_release
考慮されます。
| |
cpu/x86/vm/c1_LIRAssembler_x86.cpp
ます。
| |
| |
ここで
__
マクロは
_masm->
に展開され、
membar
メソッドは
cpu/x86/vm/assembler_x86.hpp
あり、次のようになります。
| |
volatile
変数のレコードに
lock addl $0x0,(%rsp)
の形式で高価なStoreLoadバリアを配置することがわかりました。この操作は、バッファ内のすべてのストアを強制的に実行するため、費用がかかります。しかし、それは私たちが期待するのと同じ効果を与えます
volatile
-他のすべてのスレッドは、少なくともその実行時に関連していた値を見るでしょう。
x86での読み取りが最も一般的な読み取りであることが判明しました。メソッドを簡単に調べると、
LIRGenerator::do_LoadField
予想したとおり、読み取り後にmembar_acquireが設定されていることがわかります。x86では次のようになります。
| |
volatile read
通常のオーバーヘッドと比較してオーバーヘッドが発生しないことを意味しません
read
。たとえば、ネイティブコードには何も追加されませんが、IR自体にバリアがあるため、コンパイラは特定の命令を再配置できません。(そうでなければ、面白いバグをキャッチできます)。volatileを使用すると、他にも多くの効果があります。これについては、たとえばこの記事で読むことができます。
シラミの確認
プリントアッセンブリー
ソースに座って推測することは、自尊心のある哲学者にふさわしい高貴な職業です。ただし、念のため、まだを見てください
PrintAssembly
。これを行うには、ループ内の実験ウサギの必要なメソッドに多くの呼び出しを追加し、インライン化をオフにして(生成されたコード内をナビゲートしやすくするため)、アサーションをオンにすることを忘れずにクライアントVMで開始します。
$ java -client -ea -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:MaxInlineSize=0 TestSubject ... # {method} 'executedOnCpu0' '()V' in 'TestSubject' ... 0x00007f6d1d07405c: movl $0xa,0xc(%rsi) 0x00007f6d1d074063: movb $0x1,0x10(%rsi) 0x00007f6d1d074067: lock addl $0x0,(%rsp) ;*putfield finished ; - TestSubject::executedOnCpu0@8 (line 15) ... # {method} 'executedOnCpu1' '()V' in 'TestSubject' ... 0x00007f6d1d061126: movzbl 0x10(%rbx),%r11d ;*getfield finished ; - TestSubject::executedOnCpu1@1 (line 19) 0x00007f6d1d06112b: test %r11d,%r11d ...
それは素晴らしい、すべてが私たちが予測したとおりに見えます。不在の場合
volatile
、何かが本当にうまくいかないかどうかを確認することは残っています。記事の前半で、TheShadeは壊れたDouble-Checked Lockingを示しましたが、少し歪曲したいので、自分ですべてを壊そうとします。まあ、またはほとんどあなた自身。
揮発性のない障害のデモンストレーション
このような再レンダリングを実証する問題は、一般的なケースでのその発生の可能性がそれほど高くないことであり、個々のHMMアーキテクチャではまったく許可されません。したがって、アルファを取得するか、コンパイラでの再レンダリングに依存する必要があります。さらに、すべてを何度も何度も実行します。このために自転車を発明する必要がないのは良いことです。素晴らしいjstressユーティリティを使用します。簡単に言えば、いくつかのコードを繰り返し実行し、実行結果の統計を収集して、すべての汚い仕事をしてくれます。多くの人が疑いさえしない必要性についてのものを含む。
さらに、必要なテストはすでに書かれています。より正確には、もう少し複雑ですが、何が起こっているかを完全に示しています:
static class State { int x; int y; // acq/rel var } @Override public void actor1(State s, IntResult2 r) { sx = 1; sx = 2; sy = 1; sx = 3; } @Override public void actor2(State s, IntResult2 r) { r.r1 = sy; r.r2 = sx; }
2つのスレッドがあります。1つは状態を変更し、2つ目は状態を読み取り、その結果を保存します。フレームワークは結果を集計し、いくつかのルールに従って結果をチェックします。 2番目のストリームが見ることができる2つの結果に興味があります:
[1, 0]
と
[1, 1]
。これらのケースでは、我々は読んで
y == 1
、私たちはどちらかのレコードの任意の並べ替え表示されていない
x
(し
x == 0
)または最新の記録時には見ていない
y
です、
x == 1
。私たちの理論によると、そのような結果が生じるはずです。これを確認してください:
$ java -jar tests-all/target/jcstress.jar -v -t ".*UnfencedAcquireReleaseTest.*" ... Observed state Occurrence Expectation Interpretation [0, 0] 32725135 ACCEPTABLE Before observing releasing write to, any value is OK for $x. [0, 1] 15 ACCEPTABLE Before observing releasing write to, any value is OK for $x. [0, 2] 36 ACCEPTABLE Before observing releasing write to, any value is OK for $x. [0, 3] 10902 ACCEPTABLE Before observing releasing write to, any value is OK for $x. [1, 0] 65960 ACCEPTABLE_INTERESTING Can read the default or old value for $x after $y is observed. [1, 3] 50929785 ACCEPTABLE Can see a released value of $x if $y is observed. [1, 2] 7 ACCEPTABLE Can see a released value of $x if $y is observed.
ここでは、83731840のうち65960件(約0.07%)のケース
y == 1 && x == 0
で、再発生が明確に語られていることがわかりました。やれやれ。
読者は、記事の冒頭で尋ねられた質問に答えるために何が起こっているのかを十分に理解しているはずです。思い出させてください:
- 仕事前の仕組みは?
-
volatile
これがキャッシュをフラッシュしているのは本当ですか? - なぜある種のメモリモデルがあったのですか?
さて、すべてが所定の位置に落ちましたか?そうでない場合は、記事の適切なセクションをもう一度掘り下げてみてください。これで解決しない場合は、コメントを歓迎します!
そしてもう一つ©
ハードウェアだけでなく、ランタイム環境全体がソースコードの変換を実行できます。 JMM要件に準拠するために、何かが変更される可能性があるすべてのコンポーネントに制限が課されます。たとえば、一般的な場合、コンパイラは一部の命令を再配置できる場合がありますが、JMMの実行から多くの最適化が禁止される場合があります。もちろん、サーバーコンパイラ(C2)は私たちが調べたC1よりもかなり賢く、その中のいくつかは非常に異なっています。たとえば、メモリを操作するセマンティクスはまったく異なります。
OpenJDKマルチスレッドのガットは多くの場所でチェックを使用します
os::is_MP()
これにより、いくつかの操作を実行することなく、シングルプロセッサマシンのパフォーマンスを向上させることができます。Forbidden Artsを使用して、起動時にJVMが1つのプロセッサで実行されていると判断するように強制する場合、JVMは長く存続しません。出版前に記事を読ん
でくれた勇敢なTheShade、cheremin、artyushovに感謝します。