これは、C ++がデータレースとして定義するものであり、あるターゲットマシンで期待どおりの動作をするコードを1つのコンパイラが生成したとしても、未定義の動作を引き起こします。std::atomic
信頼できる結果を得るにはを使用する必要がありますがmemory_order_relaxed
、並べ替えを気にしない場合は、を使用できます。を使用したコードとasm出力の例については、以下を参照してくださいfetch_add
。
しかし、最初に、質問のアセンブリ言語の部分:
num ++は1つの命令(add dword [num], 1
)なので、この場合、num ++はアトミックであると結論付けることができますか?
メモリ宛先命令(純粋なストア以外)は、複数の内部ステップで発生する読み取り-変更-書き込み操作です。アーキテクチャレジスタは変更されませんが、CPUはALUを介してデータを送信する間、データを内部で保持する必要があります。実際のレジスタファイルは、最も単純なCPU内のデータストレージのほんの一部にすぎません。あるステージの出力を別のステージの入力として保持するラッチなどがあります。
他のCPUからのメモリ操作は、ロードとストアの間でグローバルに見えるようになります。つまりadd dword [num], 1
、ループで実行されている2つのスレッドが互いのストアを踏みます。(素晴らしい図については@Margaret の回答を参照してください)。2つのスレッドのそれぞれから40kずつ増加した後、実際のマルチコアx86ハードウェアでは、カウンターが(60kではなく)〜60kだけ増加した可能性があります。
「不可分」とは、不可分という意味のギリシャ語から、操作を個別のステップとして見ることができないという意味です。すべてのビットを同時に物理的/電気的に瞬時に発生させることは、ロードまたはストアでこれを達成するための1つの方法にすぎませんが、ALU演算では不可能です。x86のAtomicityに対する私の回答では、純粋なロードと純粋なストアについてさらに詳しく説明しましたが、この回答は読み取り-変更-書き込みに焦点を当てています。
lock
プレフィックスは、システム(他のコアとDMAデバイスではなく、CPUのピンにフックアップオシロスコープ)における全ての可能な観察者に対する全体の動作をアトミックにする多くのリードモディファイライト(メモリ先)命令に適用することができます。それが存在する理由です。(このQ&Aも参照してください)。
だから、lock add dword [num], 1
あるアトミック。その命令を実行するCPUコアは、ロードがキャッシュからデータを読み取ってからストアが結果をキャッシュにコミットするまで、プライベートL1キャッシュでキャッシュラインを変更済み状態に固定し続けます。これは、MESIキャッシュコヒーレンシプロトコル(またはマルチコアAMD / Intel CPU)。したがって、他のコアによる操作は、実行中ではなく、前または後に発生するように見えます。
lock
プレフィックスがないと、別のコアがキャッシュラインの所有権を取得し、ロード後ストアの前に変更できるため、他のストアがロードとストアの間でグローバルに表示されるようになります。他のいくつかの答えはこれを間違っておりlock
、同じキャッシュラインの競合するコピーを取得しないと主張しています。これは、一貫性のあるキャッシュを備えたシステムでは発生しません。
(lock
ed命令が2つのキャッシュラインにまたがるメモリで動作する場合、オブジェクトの両方の部分への変更がすべてのオブザーバーに伝播するときにアトミックにとどまるようにするには、さらに多くの作業が必要になるため、オブザーバーはティアリングを確認できません。CPUがデータがメモリに到達するまでメモリバス全体をロックする必要があります。アトミック変数を調整しないでください!)
lock
また、プレフィックスは命令を完全なメモリバリア(MFENCEなど)に変え、すべての実行時の並べ替えを停止して、順次一貫性を与えることに注意してください。(Jeff Preshingの優れたブログ投稿を参照してください。彼の他の投稿もすべて優れており、x86やその他のハードウェアの詳細からC ++ルールまで、ロックフリープログラミングに関する多くの優れた点を明確に説明しています。)
ユニプロセッサマシンまたはシングルスレッドプロセスでは、単一のRMW命令は、実際にはlock
プレフィックスなしでアトミックです。他のコードが共有変数にアクセスする唯一の方法は、CPUがコンテキストの切り替えを行うことです。これは、命令の途中では実行できません。したがって、プレーンdec dword [num]
はシングルスレッドプログラムとそのシグナルハンドラーの間、またはシングルコアマシンで実行されているマルチスレッドプログラムで同期できます。別の質問に対する私の回答の後半と、その下のコメントを参照してください。ここで、これについて詳しく説明します。
C ++に戻る:
num++
単一の読み取り-変更-書き込みの実装にコンパイルする必要があることをコンパイラーに通知せずに使用するのは、まったく偽です。
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
num
後での値を使用する場合、これは非常に可能性が高くなります。インクリメント後、コンパイラはそれをレジスタに保持します。そのため、num++
コンパイル方法を独自に確認したとしても、周囲のコードを変更すると影響する可能性があります。
値は後で必要にされていない場合は(、inc dword [num]
好ましい;現代のx86 CPUは、少なくとも効率的に3つの別々の命令を使用するなどのようなメモリ先のRMW命令を実行する楽しい事実を:。gcc -O3 -m32 -mtune=i586
実際にこれを放出する、(ペンティアム)P5のスーパースカラパイプラインのdidnので、 P6以降のマイクロアーキテクチャが行うように、複雑な命令を複数の単純なマイクロオペレーションにデコードしません。詳細については、Agner Fogの命令テーブル/マイクロアーキテクチャガイドを参照してください。x86 多くの便利なリンクのタグwiki(PDFとして無料で入手可能なIntelのx86 ISAマニュアルを含む))。
ターゲットメモリモデル(x86)とC ++メモリモデルを混同しないでください。
コンパイル時の並べ替えが可能です。std :: atomicで得られるもう1つの部分は、コンパイル時の並べ替えを制御することnum++
です。これにより、他の操作を実行した後にのみグローバルに表示されるようになります。
古典的な例:別のスレッドが参照できるように一部のデータをバッファーに格納し、フラグを設定します。x86はロード/リリースストアを無料で取得しますが、コンパイラを使用して並べ替えを行わないようにする必要がありますflag.store(1, std::memory_order_release);
。
このコードが他のスレッドと同期することを期待しているかもしれません:
// flag is just a plain int global, not std::atomic<int>.
flag--; // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo); // doesn't look at flag, and the compilers knows this. (Assume it can see the function def). Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;
しかし、そうではありません。コンパイラーはflag++
、関数呼び出し全体を自由に移動できます(関数をインライン化する場合、または関数を調べないことがわかっている場合flag
)。次に、それflag
はでもないため、変更を完全に最適化しvolatile
ます。(そして、いや、C ++は、volatile
STDに便利代わるものではありません::アトミック。のstd ::原子コンパイラは、メモリ内の値は、と非同期に似て変更することができると仮定しますんvolatile
が、はるかにそれまでよりもあります。また、volatile std::atomic<int> foo
ではありませんstd::atomic<int> foo
@Richard Hodgesで説明したように、と同じです。)
非アトミック変数のデータ競合を未定義の動作として定義することで、コンパイラーはロードとシンクのストアをループ外に巻き上げ、複数のスレッドが参照する可能性のあるメモリーのその他の多くの最適化を行うことができます。(UBがコンパイラの最適化を有効にする方法の詳細については、このLLVMブログを参照してください。)
すでに述べたように、x86 lock
プレフィックスは完全なメモリバリアであるため、を使用num.fetch_add(1, std::memory_order_relaxed);
するとx86で同じコードが生成されnum++
ます(デフォルトは逐次一貫性です)が、他のアーキテクチャ(ARMなど)でははるかに効率的です。x86でも、relaxedを使用すると、コンパイル時の順序を変更できます。
これは、GCCが実際にx86で行うことstd::atomic
です。グローバル変数を操作するいくつかの関数に対してです。
Godboltコンパイラーエクスプローラーで適切にフォーマットされたソース+アセンブリ言語コードを参照してください。ARM、MIPS、PowerPCなどの他のターゲットアーキテクチャを選択して、これらのターゲットのアトミックからどのようなアセンブリ言語コードを取得するかを確認できます。
#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
num.fetch_add(1, std::memory_order_relaxed);
}
int load_num() { return num; } // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.
# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
lock add DWORD PTR num[rip], 1 #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
ret
inc_seq_cst():
lock add DWORD PTR num[rip], 1
ret
load_num():
mov eax, DWORD PTR num[rip]
ret
store_num(int):
mov DWORD PTR num[rip], edi
mfence ##### seq_cst stores need an mfence
ret
store_num_release(int):
mov DWORD PTR num[rip], edi
ret ##### Release and weaker doesn't.
store_num_relaxed(int):
mov DWORD PTR num[rip], edi
ret
順次整合性ストアの後にMFENCE(完全なバリア)がどのように必要になるかに注意してください。x86は一般に強く順序付けされていますが、StoreLoadの順序変更は許可されています。パイプライン化された順不同CPUで良好なパフォーマンスを得るには、ストアバッファーが不可欠です。Jeff Preshingの「メモリの並べ替えが法に引っかかる」は、MFENCEを使用しない場合の結果を示しています。実際のコードは、実際のハードウェアで行われる並べ替えを示しています。
Re:std :: atomic num++; num-=2;
オペレーションを1つのnum--;
命令にマージするコンパイラーに関する@Richard Hodgesの回答に関するコメントでの議論:
この同じテーマに関する別のQ&A:コンパイラーが冗長なstd :: atomic書き込みをマージしないのはなぜですか?、私の答えは私が以下に書いたものの多くを言い換えています。
現在のコンパイラは実際には(まだ)これを行いませんが、許可されていないためではありません。 C ++ WG21 / P0062R1:コンパイラーはいつアトミックを最適化する必要がありますか?コンパイラーが「驚くべき」最適化を行わないという多くのプログラマーの期待と、プログラマーに制御を与えるために標準が何ができるかについて説明します。 N4455は、これを含め、最適化できる多くの例について説明します。元のソースに明らかに冗長なアトミック操作がなかった場合でも、インライン化と一定の伝播fetch_or(0)
により、単にに変わる可能性のあるものload()
(ただし、取得と解放のセマンティクスはまだある)が導入される可能性があることを指摘しています。
コンパイラーが(まだ)実行しない本当の理由は次のとおりです。(1)コンパイラーが安全に(間違いなく)実行できるようにする複雑なコードを誰も作成していない、(2)最小の原則に違反している可能性がある驚き。ロックフリーのコードは、最初から正しく書くのに十分なほど難しいものです。ですから、核兵器の使用に無頓着にしないでください。それらは安価ではなく、あまり最適化されていません。std::shared_ptr<T>
ただし、非アトミックバージョンがないため、で冗長なアトミック操作を回避するのは必ずしも簡単ではありません(ただし、ここでの回答の1つは、shared_ptr_unsynchronized<T>
gccのを定義する簡単な方法を提供します)。
背を取得するnum++; num-=2;
ことであるかのようにコンパイルするnum--
コンパイラは:許可されていない限り、これを行うことnum
ですvolatile std::atomic<int>
。並べ替えが可能な場合、as-ifルールにより、コンパイラーはコンパイル時に常にそのように行われると判断できます。オブザーバーが中間値(num++
結果)を参照できることを保証するものはありません。
つまり、これらの操作の間でグローバルに見えなくなる順序がソースの順序付け要件と互換性がある場合(ターゲットアーキテクチャではなく、抽象マシンのC ++ルールに従って)、コンパイラーlock dec dword [num]
はlock inc dword [num]
/ ではなく単一を発行できlock sub dword [num], 2
ます。
num++; num--
を見る他のスレッドとのSynchronizes With関係がまだあり、num
このスレッド内の他の操作の並べ替えを許可しない取得ロードと解放ストアの両方であるため、消えることはありません。x86の場合、これはlock add dword [num], 0
(つまりnum += 0
)ではなくMFENCEにコンパイルできる場合があります。
PR0062で説明されているように、コンパイル時の非隣接アトミック操作のより積極的なマージは悪い場合があります(たとえば、進行カウンターはすべての反復ではなく最後に一度だけ更新されます)が、マイナス面(たとえば、アトミックinc / refのdecは、aのコピーshared_ptr
が作成および破棄されたときにカウントされます(コンパイラーがshared_ptr
一時オブジェクトの存続期間全体にわたって別のオブジェクトが存在することを証明できる場合)。
num++; num--
1つのスレッドがすぐにロックを解除して再度ロックすると、マージでさえロック実装の公平性を損なう可能性があります。それが実際にasmで解放されない場合、ハードウェアの調停メカニズムでさえ、その時点で別のスレッドにロックを取得する機会を与えません。
現在のgcc6.2とclang3.9では、最も明らかに最適化可能なケースでlock
あっても、個別のed操作を取得できますmemory_order_relaxed
。(Godboltコンパイラエクスプローラー。最新バージョンが異なるかどうかを確認できます。)
void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
num.fetch_add( 1, std::memory_order_relaxed);
num.fetch_add(-1, std::memory_order_relaxed);
num.fetch_add( 6, std::memory_order_relaxed);
num.fetch_add(-5, std::memory_order_relaxed);
//num.fetch_add(-1, std::memory_order_relaxed);
}
multiple_ops_relaxed(std::atomic<unsigned int>&):
lock add DWORD PTR [rdi], 1
lock sub DWORD PTR [rdi], 1
lock add DWORD PTR [rdi], 6
lock sub DWORD PTR [rdi], 5
ret
add
が原子的だと言ったのですか?