num ++は 'int num'に対してアトミックですか?


153

一般ためにint numnum++(又は++num)、リード・モディファイ・ライト動作として、ある原子ではありません。しかし、GCCなどのコンパイラが次のコードを生成することがよくあります(ここで試してください)。

ここに画像の説明を入力してください

に対応する5行目num++は1つの命令なので、この場合num++ はアトミックである結論付けることできますか?

もしそうなら、それは、そのように生成num++されたデータレースの危険なしに同時(マルチスレッド)シナリオで使用できることを意味します(つまり、たとえば、それを作成する必要がなくstd::atomic<int>、関連するコストを課します。とにかくアトミック)?

更新

この質問はインクリメントアトミックであるかどうかではないことに注意してください(そうではなく、それが問題の最初の行でした)。それはかどうかだことができ、特定の場合で1命令自然の缶はのオーバーヘッドを回避するために悪用されるかどうか、すなわち、特定のシナリオでも接頭辞を。そして、受け入れられた回答がユニプロセッサマシンに関するセクションで言及しているように、この回答と同様に、そのコメントでの会話や他の人が説明しているように、それは可能です(ただし、CまたはC ++ではできません)。lock


65
誰があなたにそれaddが原子的だと言ったのですか?
Slava

6
アトミックの機能の1つは、実際の操作のアトミック性に関係なく、最適化中に特定の種類の並べ替えを防止することです
jaggedSpire

19
またこれがプラットフォームでアトミックである場合、別のプラットフォームで実行される保証はないことも指摘しておきます。プラットフォームに依存せず、を使用して意図を表明しますstd::atomic<int>
NathanOliver 2016

8
そのadd命令の実行中に、別のコアがこのコアのキャッシュからそのメモリアドレスを盗み、変更する可能性があります。x86 CPUでは、操作中にアドレスをキャッシュにロックする必要がある場合は、add命令にlockプレフィックスが必要です。
David Schwartz

21
すべての操作が「アトミック」である可能性があります。あなたがしなければならないのは幸運になることであり、それが不可分でないことを明らかにするようなことを決して実行しないことです。アトミックは保証としてのみ価値があります。アセンブリコードを検討している場合、問題はその特定のアーキテクチャが保証を提供しているかどうか、およびコンパイラが選択したアセンブリレベルの実装であることを保証が提供しているかどうかです。
Cort Ammon 2016

回答:


197

これは、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、同じキャッシュラインの競合するコピーを取得しないと主張しています。これは、一貫性のあるキャッシュを備えたシステムでは発生しません。

locked命令が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の命令テーブル/マイクロアーキテクチャガイドを参照してください。 多くの便利なリンクのタグ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 ++は、volatileSTDに便利代わるものではありません::アトミック。の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

1
「もう一度、少なくとも効率的としてRMW操作を処理後に、より効率的に使用される[個別の指示に従って使用して]を...しかし、現代のx86 CPU」 -それはまだ更新された値は、同じ機能で、後に使用される場合に、より効率的ですそして、コンパイラーがそれを格納できる無料のレジスターがあります(もちろん、変数は揮発性とマークされていません)。これは、コンパイラが操作に対して単一の命令を生成するか複数の命令を生成するかは、問題の単一行だけでなく、関数内の残りのコードに依存する可能性が高いことを意味します。
Periata Breatta 16

@PeriataBreatta:はい、良い点です。asmでは、mov eax, 1 xadd [num], eax(ロック接頭辞なしで)post-incrementを実装するために使用できますがnum++、それはコンパイラーが行うことではありません。
Peter Cordes

3
@ DavidC.Rankin:編集を加えたい場合は、お気軽に。ただし、このCWは作成しません。それはまだ私の仕事です(そして私の混乱:P)。アルティメット[フリスビー]ゲームの後で片付けます:)
Peter Cordes

1
コミュニティwikiでない場合は、おそらく適切なタグwiki上のリンクです。(x86とアトミックタグの両方?)SOでの一般的な検索による期待できるリターンよりも、追加のリンクの価値があります(その点でどこに当てはまるかがよく分かっている場合は、それを行います。doとdoのタグについてさらに掘り下げる必要がありますwikiリンケージ)
David

1
いつものように-素晴らしい答えです!コヒーレンスと
アトミシティの

39

...そして今最適化を有効にしましょう:

f():
        rep ret

では、チャンスを与えましょう。

void f(int& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

結果:

f(int&):
        mov     DWORD PTR [rdi], 0
        ret

別の監視スレッド(キャッシュ同期の遅延を無視していても)には、個々の変更を監視する機会がありません。

次と比較:

#include <atomic>

void f(std::atomic<int>& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

結果は次のとおりです。

f(std::atomic<int>&):
        mov     DWORD PTR [rdi], 0
        mfence
        lock add        DWORD PTR [rdi], 1
        lock sub        DWORD PTR [rdi], 1
        lock add        DWORD PTR [rdi], 6
        lock sub        DWORD PTR [rdi], 5
        lock sub        DWORD PTR [rdi], 1
        ret

今、各変更は次のとおりです。

  1. 別のスレッドで観察可能、そして
  2. 他のスレッドで発生する同様の変更を尊重します。

アトミック性は命令レベルだけでなく、プロセッサからキャッシュ、メモリ、そしてその逆のパイプライン全体を含みます。

詳細情報

std::atomics の更新の最適化の影響について。

c ++標準には「as if」ルールがあります。これにより、コンパイラーがコードを並べ替えたり、結果がまったく同じように観察可能な影響(副作用を含む)である場合にコードを書き換えたりすることもできます。コード。

as-ifルールは保守的で、特にアトミックが関係しています。

検討してください:

void incdec(int& num) {
    ++num;
    --num;
}

スレッド間シーケンスに影響を与えるミューテックスロック、アトミック、またはその他の構成要素がないため、コンパイラーはこの関数をNOPとして自由に書き換えることができると主張します。

void incdec(int&) {
    // nada
}

これは、c ++メモリモデルでは、別のスレッドが増分の結果を監視する可能性がないためです。もちろん、異なる場合numもありますvolatile(ハードウェアの動作に影響を与える可能性があります)。しかし、この場合、この関数はこのメモリを変更する唯一の関数になります(それ以外の場合は、プログラムの形式が正しくありません)。

ただし、これは別の球技です。

void incdec(std::atomic<int>& num) {
    ++num;
    --num;
}

numアトミックです。これに対する変更は、監視している他のスレッドから監視できる必要あります。これらのスレッド自体が行う変更(インクリメントとデクリメントの間に値を100に設定するなど)は、numの最終的な値に非常に広範囲の影響を及ぼします。

ここにデモがあります:

#include <thread>
#include <atomic>

int main()
{
    for (int iter = 0 ; iter < 20 ; ++iter)
    {
        std::atomic<int> num = { 0 };
        std::thread t1([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                ++num;
                --num;
            }
        });
        std::thread t2([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                num = 100;
            }
        });
        
        t2.join();
        t1.join();
        std::cout << num << std::endl;
    }
}

出力例:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99

5
これは、それadd dword [rdi], 1がアトミックでないlockプレフィックスがない)ことを説明できません。ロードはアトミックであり、ストアはアトミックですが、別のスレッドがロードとストアの間でデータを変更することを妨げるものはありません。したがって、ストアは別のスレッドによって行われた変更を踏むことができます。jfdube.wordpress.com/2011/11/30/understanding-atomic-operationsを参照してください。また、 Jeff Preshingのロックフリー記事は非常に優れており、彼はその紹介記事で基本的なRMW問題について言及しています。
Peter Cordes

3
ここで実際に起こっていることは、誰もこの最適化をgccに実装していないということです。これはほとんど役に立たず、おそらく役立つよりも危険であるためです。(少なくとも驚きの原理。たぶん誰かがされて見える時々する一時的な状態を期待して、統計的probabiltyでOKです。それとも、彼らがされている修正に中断するために、ハードウェアウォッチポイントを使用して。)ロックフリーのコードは、慎重に細工する必要がありますしたがって、最適化するものは何もありません。それを探して警告を出力することは、コードが彼らの考えを意味しないかもしれないことをコーダーに警告するために役立つかもしれません!
Peter Cordes

2
これがおそらく、コンパイラがこれを実装しない理由です(最小の驚きなどの原則)。実際のハードウェアでは実際にそれが可能であることを観察します。ただし、C ++のメモリ順序付けルールでは、1つのスレッドの負荷がC ++抽象マシン内の他のスレッドの演算と「均等に」混合するという保証については何も述べられていません。私はまだそれは合法だと思いますが、プログラマーに敵対的です。
Peter Cordes

2
思考実験:協調型マルチタスクシステムでのC ++実装を検討します。デッドロックを回避するために必要な場所に降伏点を挿入することでstd :: threadを実装しますが、すべての命令の間ではありません。C ++標準の何かには、num++との間の降伏点が必要だとあなたは主張するでしょうnum--あなたがそれを必要とする標準のセクションを見つけることができれば、それはこれを解決するでしょう。 オブザーバーが間違った並べ替えを見ることができないことだけが必要だと確信しています。ですから、それは単なる実装の品質の問題だと思います。
Peter Cordes

5
最後に、私はstdディスカッションメーリングリストで質問しました。この質問により、Peterと一致するように見える2つの論文が見つかり、そのような最適化に関して私が抱えている懸念に対処しました。wg21.link / p0062およびwg21.link/n4455 これらに注目してくれたAndyに感謝します。
Richard Hodges

38

多くの合併症がなければ、のような指示add DWORD PTR [rbp-4], 1は非常にCISCスタイルです。

3つの操作を実行します。メモリからオペランドをロードし、それをインクリメントし、メモリにオペランドを格納します。
これらの操作中に、CPUはバスを2回取得および解放します。その間、他のエージェントもバスを取得でき、これはアトミック性に違反します。

AGENT 1          AGENT 2

load X              
inc C
                 load X
                 inc C
                 store X
store X

Xは1回だけインクリメントされます。


7
@LeoHeinsaarそのためには、各メモリチップに独自の算術論理演算ユニット(ALU)が必要です。実際には、各メモリチッププロセッサである必要あります。
Richard Hodges

6
@LeoHeinsaar:メモリ宛先の命令は、読み取り-変更-書き込み操作です。アーキテクチャレジスタは変更されませんが、CPUは、ALUを介してデータを送信する間、内部でデータを保持する必要があります。実際のレジスタファイルは、ラッチ等など他のステージのための入力として一段階の出力を保持して、データストレージ内の最も単純なCPUのほんの一部である
ピーターコルド

@PeterCordesあなたのコメントはまさに私が探していた答えです。マーガレットの答えは私にそのような何かが内部で続けなければならないことを疑わせました。
Leo Heinsaar 2016

そのコメントを、質問のC ++部分への対処を含む完全な回答に変えました。
Peter Cordes

1
@PeterCordesありがとう、非常に詳細で、すべての点で。それは明らかにデータ競合であり、したがってC ++標準では未定義の動作でした。生成されたコードが私が投稿したものである場合に、それがアトミックであると想定できるかどうかなど、私はちょうど興味を持っていました。マニュアルは、私が仮定したように、命令の不可分性ではなく、メモリ操作に関して原子性を非常に明確に定義しています。
Leo Heinsaar 16

11

add命令はアトミックではありません。メモリを参照し、2つのプロセッサコアがそのメモリの異なるローカルキャッシュを持っている可能性があります。

IIRC追加命令のアトミックバリアントはロックxaddと呼ばれます


3
lock xaddC ++ std :: atomicを実装しfetch_add、古い値を返します。それが必要ない場合、コンパイラーはlock接頭部付きの通常のメモリー宛先命令を使用します。 lock addまたはlock inc
Peter Cordes

1
add [mem], 1キャッシュのないSMPマシンではまだアトミックではありません。他の回答に関する私のコメントを参照してください。
Peter Cordes 2016

アトミックではない方法の詳細については、私の回答を参照してください。また、この関連質問に対する私の回答の終わりです。
Peter Cordes

10

num ++に対応する5行目は1つの命令なので、この場合、num ++はアトミックであると結論付けられますか?

「リバースエンジニアリング」で生成されたアセンブリに基づいて結論を出すのは危険です。たとえば、最適化を無効にしてコードをコンパイルしたように見えます。そうでない場合、コンパイラーはその変数を破棄するか、を呼び出さずに1を直接その変数にロードしますoperator++。生成されたアセンブリは、最適化フラグ、ターゲットCPUなどに基づいて大幅に変更される可能性があるため、結論は砂に基づいています。

また、1つのアセンブリ命令が操作がアトミックであることを意味するという考えも間違っています。これaddは、x86アーキテクチャであっても、マルチCPUシステムではアトミックではありません。


9

コンパイラが常にこれをアトミック操作として発行したとしてnumも、他のスレッドから同時にアクセスすると、C ++ 11およびC ++ 14標準に従ってデータ競合が発生し、プログラムの動作が未定義になります。

しかし、それよりも悪いです。まず、前述のように、変数をインクリメントするときにコンパイラーによって生成される命令は、最適化レベルに依存する場合があります。次に、アトミックでない場合、コンパイラは他のメモリアクセスを並べ替えます。たとえば、++numnum

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  int ready = 0;
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

それ++readyが「アトミック」であると楽観的に仮定し、コンパイラーが必要に応じてチェックループを生成すると仮定しても(前述のように、それはUBであるため、コンパイラーは自由に削除して無限ループに置き換えるなど)、コンパイラーは引き続きポインター割り当てを移動する可能性があり、さらに悪いことに、初期化vector操作後のポイントの初期化により、新しいスレッドに混乱が生じる可能性があります。実際には、最適化コンパイラーがready変数とチェックループを完全に削除したとしても、驚くことはありません。

実際、昨年のミーティングC ++カンファレンスで、2つのコンパイラ開発者から、言語ルールで許可されている限り、小さなパフォーマンスの改善さえ見られたとしても、ナイーブに書かれたマルチスレッドプログラムが誤動作するような最適化を非常に喜んで実装していると聞きました正しく書かれたプログラムで。

最後に、さえあれば、あなたは移植性を気にしませんでした、そして、あなたのコンパイラが魔法よかった、使用しているCPUは非常に可能性の高いスーパースカラCISC型であるとのマイクロopに命令を打破し、再注文および/または投機的にそれらを実行LOCK1秒あたりの操作を最大化するために、プレフィックスまたはメモリフェンス(Intel上)などのプリミティブを同期することによってのみ制限される範囲。

簡単に言えば、スレッドセーフプログラミングの自然な責任は次のとおりです。

  1. あなたの義務は、言語規則(特に言語標準メモリモデル)の下で明確に定義された動作を持つコードを記述することです。
  2. コンパイラの義務は、ターゲットアーキテクチャのメモリモデルで明確に定義された(観測可能な)動作と同じマシンコードを生成することです。
  3. CPUの役割は、このコードを実行して、観察された動作が独自のアーキテクチャのメモリモデルと互換性を持つようにすることです。

あなたがそれをあなた自身の方法でしたい場合、それはいくつかのケースではうまくいくかもしれませんが、保証は無効であり、あなたが望まない結果に対して単独で責任を負うことを理解してください。:-)

PS:正しく書かれた例:

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  std::atomic<int> ready{0}; // NOTE the use of the std::atomic template
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

次の理由により、これは安全です。

  1. のチェックはready、言語規則に従って最適化することはできません。
  2. ++ready 前に、たまたま見てチェックreadyゼロではないとして、および他の操作は、これらの操作の周りに並べ替えることができません。これは++ready、チェックが順次一貫しているためです。これは、C ++メモリモデルで説明されている別の用語であり、この特定の順序変更を禁止しています。したがって、コンパイラは命令の順序を変更してはならず、たとえばvecのインクリメント後に書き込みを延期してはならないことをCPUに通知する必要がありますreadyシーケンシャルに一貫性は、言語標準におけるアトミックに関する最も強力な保証です。より少ない(そして理論的にはより安い)保証は、例えば他の方法で利用可能ですstd::atomic<T>ですが、これらは間違いなく専門家向けであり、めったに使用されないため、コンパイラ開発者によってあまり最適化されない場合があります。

1
コンパイラがのすべての使用法を認識できなかった場合、readyおそらくwhile (!ready);より似if(!ready) { while(true); }たものにコンパイルされます。賛成:std :: atomicの重要な部分は、任意の時点で非同期の変更を想定するようにセマンティクスを変更することです。通常はUBにすることで、コンパイラーがループを外してロードを引き上げ、ストアをシンクできるようになります。
Peter Cordes

9

シングルコアx86マシンでは、add命令は通常、CPU 1上の他のコードに対してアトミックです。割り込みは、単一の命令を途中で分割することはできません。

単一のコア内で一度に1つずつ順番に実行される命令の錯覚を維持するには、アウトオブオーダー実行が必要です。そのため、同じCPUで実行されるすべての命令は、追加の前または後に完全に発生します。

最新のx86システムはマルチコアであるため、ユニプロセッサーの特殊なケースは適用されません。

小さな組み込みPCを対象としていて、コードを他のどこかに移動する予定がない場合、「add」命令のアトミックな性質が悪用される可能性があります。一方、操作が本質的にアトミックであるプラットフォームは、ますます不足しています。

もしC ++にいる書き込みます。コンパイラが必要とするオプションはありませんが(これは、あなたを助けていないnum++メモリ先の追加にコンパイルするかXADDすることなしにlock接頭辞を。彼らは負荷に選ぶことができるnumレジスタと店に別の命令を使用してインクリメントの結果を返します。結果を使用する場合は、そうする可能性があります。)


脚注1:lockI / OデバイスはCPUと同時に動作するため、元の8086にもプレフィックスが存在していました。シングルコアシステムのドライバーは、lock addデバイスが値を変更できる場合、またはDMAアクセスに関して、デバイスメモリの値をアトミックにインクリメントする必要があります。


一般的にアトミックでもありません。別のスレッドが同時に同じ変数を更新でき、1つの更新のみが引き継がれます。
fuz 2016

1
マルチコアシステムについて考えてみましょう。もちろん、1つのコア内では、命令はアトミックですが、システム全体に関してはアトミックではありません。
fuz

1
@FUZxxl:私の回答の4番目と5番目の単語は何でしたか?
スーパーキャット2016

1
@supercatあなたの答えは非常に誤解を招くものです。なぜなら、それは今日のまれなシングルコアのケースのみを考慮し、OPに誤った安心感を与えているからです。そのため、マルチコアのケースについても検討するようコメントしました。
fuz 2016

1
@FUZxxl:これが通常の最新のマルチコアCPUについて話しているのではないことに気付かなかった読者の潜在的な混乱を解消するために編集を行いました。(そして、supercatが確信していなかったいくつかの事柄についてより具体的にします)。ところで、この回答のすべてはすでに私のものですが、read-modify-writeが「無料で」アトミックであるプラットフォームについての最後の文はまれです。
Peter Cordes

7

x86コンピューターにCPUが1つ搭載されていた当時は、単一の命令を使用することで、割り込みによって読み取り/変更/書き込みが分割されず、メモリーもDMAバッファーとして使用されない場合、実際にはアトミックでした(そしてC ++は標準でスレッドに言及していなかったため、これは対処されませんでした)。

お客様のデスクトップにデュアルプロセッサ(デュアルソケットのPentium Proなど)を配置するのはまれでしたが、これを効果的に使用して、シングルコアマシンのLOCKプレフィックスを回避し、パフォーマンスを向上させました。

今日、それはすべて同じCPUアフィニティに設定された複数のスレッドに対してのみ役立つため、心配されているスレッドは、同じCPU(コア)で他のスレッドを期限切れにして実行することによってのみ機能します。それは現実的ではありません。

最新のx86 / x64プロセッサでは、単一の命令がいくつかのマイクロopに分割され、さらにメモリの読み書きがバッファリングされます。したがって、異なるCPUで実行されている異なるスレッドは、これを非アトミックであると見なすだけでなく、メモリから読み取ったもの、および他のスレッドがその時点までに読み取ったと仮定したものに関して一貫性のない結果を表示する可能性があります。正​​常な状態に戻すには、メモリフェンスを追加する必要があります。動作。


1
彼らはので、割り込みはまだありませんスプリットRMW操作を行うはまだシグナルハンドラで単一のスレッドを同期させることと同じスレッドで実行されます。もちろん、これは、asmが単一の命令を使用する場合にのみ機能し、個別のロード/変更/ストアではありません。C ++ 11はこのハードウェア機能を公開できましたが、公開しませんでした(おそらく、シグナルハンドラーを備えたユーザー空間ではなく、割り込みハンドラーと同期することがユニプロセッサーカーネルでのみ本当に役立つためです)。また、アーキテクチャーには、読み取り、変更、書き込みのメモリー宛先命令はありません。それでも、x86以外でのリラックスしたアトミックRMWのようにコンパイルできます
Peter Cordes

私が覚えているように、スーパースケーラーが登場するまでは、Lockプレフィックスを使用することは、それほど高価ではありませんでした。そのため、486プログラムの重要なコードの速度が低下していることに気づく必要はありませんでした。
JDługosz

うん、ごめん!実際には注意深く読みませんでした。私は段落の始まりをuopsへのデコードについての赤いニシンで見て、あなたが実際に言ったことを見るために読み終えていませんでした。re:486:最初のSMPはある種のCompaq 386であると読んだと思いますが、そのメモリ順序付けのセマンティクスは、x86 ISAが現在言っているものと同じではありませんでした。現在のx86マニュアルはSMP 486についてさえ言及しているかもしれません。しかし、それらはHPC(Beowulfクラスター)でもPPro / Athlon XPの日まで一般的ではなかったと思います。
Peter Cordes

1
@PeterCordesわかりました。確かに、DMA /デバイスオブザーバーも想定していないため、コメントエリアに含めることもできませんでした。JDługoszの優れた追加(回答とコメント)に感謝します。本当に議論を終えました。
Leo Heinsaar 16

3
@Leo:言及されていない重要なポイントの1つ:アウトオブオーダーのCPUは内部で順序を変更しますが、ゴールデンルールは、シングルコアの場合、一度に1つずつ実行される命令のような錯覚を保持することです。(これには、コンテキストスイッチをトリガーする割り込みが含まれます)。値は順不同で電気的にメモリに格納される可能性がありますが、すべてが実行されている単一のコアは、錯覚を保存するために、それ自体が行うすべての並べ替えを追跡します。これが、a = 1; b = a;格納した1を正しく読み込むためにのasm相当のメモリバリアを必要としない理由です。
Peter Cordes

4

いいえ 。https://www.youtube.com/watch?v = 31g0YE61PLQ (これは「オフィス」の「いいえ」シーンへのリンクにすぎません)

これがプログラムの可能な出力であることに同意しますか?

出力例:

100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100

その場合、コンパイラーは、コンパイラーが望む方法で、プログラムの唯一の可能な出力を自由に作成できます。つまり、100を出力するだけのmain()です。

これは「as-if」ルールです。

出力に関係なく、スレッドの同期は同じように考えることができます。スレッドAが繰り返しnum++; num--;スレッドBをnum繰り返し読み取る場合、有効なインターリーブの可能性は、スレッドBがとの間num++を読み取らないことnum--です。そのインターリーブは有効であるため、コンパイラーはそれを唯一の可能なインターリーブにすることができます。そして、単に増分/減分を完全に削除します。

ここにいくつかの興味深い影響があります:

while (working())
    progress++;  // atomic, global

(つまり、他のスレッドがに基づいてプログレスバーUIを更新するとしますprogress

コンパイラはこれを次のように変えることができますか?

int local = 0;
while (working())
    local++;

progress += local;

おそらくそれは有効です。しかし、おそらくプログラマが望んでいたものとは異なります:-(

委員会はまだこの問題に取り組んでいます。コンパイラーはアトミックをあまり最適化しないため、現在は「機能」します。しかし、それは変化しています。

またprogress、揮発性であっても、これは有効です。

int local = 0;
while (working())
    local++;

while (local--)
    progress++;

:-/


この答えは、リチャードと私が考えていた副次的な質問にのみ答えているようです。私たちは最終的にそれを解決しました:はい、C ++標準でvolatile他のルールに違反しない場合、非アトミックオブジェクトに対する操作のマージ許可されています。2つの標準ディスカッションドキュメントがこれについて正確に説明しています(リチャードのコメントのリンク)。1つは同じ進行状況カウンターの例を使用しています。したがって、C ++がそれを防ぐ方法を標準化するまでは、実装の品質の問題です。
Peter Cordes

ええ、私の「いいえ」は、推論の全行への回答です。質問が単に「一部のコンパイラ/実装でnum ++をアトミックにすることができる」というものであれば、答えは確かです。たとえば、コンパイラはlockすべての操作に追加することを決定できます。または、コンパイラとユニプロセッサの組み合わせで、どちらも並べ替えを行わなかった場合(つまり、「良い時代」)はすべてアトミックです。しかし、それの意味は何ですか?あなたは本当にそれに頼ることはできません。それがあなたが書いているシステムであると知らない限り。(それでも、atomic <int>がそのシステムに余分な操作を追加しないほうがよいでしょう。そのため、標準コードを記述する必要があります...)
tony

1
And just remove the incr/decr entirely.正しくないことに注意してください。それはまだの取得および解放操作numです。x86では、num++;num--MFENCEだけにコンパイルできますが、間違いなく何もできません。(コンパイラーのプログラム全体の分析で、numの変更と同期しないものがあること、およびその前からの一部のストアがその後のロードの後まで遅延されるかどうかは問題ではないことが証明されない限り)。 -lock-right-awayの使用例では、1つの大きなセクションではなく、2つの個別のクリティカルセクション(おそらくmo_relaxedを使用)があります。
Peter Cordes

@PeterCordesああそう、同意。
トニー

2

はい、でも...

原子はあなたが言うつもりではありませんでした。あなたはおそらく間違ったことを尋ねているでしょう。

増分は確かにアトミックです。ストレージが正しく調整されていない限り(そして、コンパイラーに調整を残したため、そうではありません)、ストレージは必ず単一のキャッシュライン内で調整されます。特別な非キャッシュストリーミング命令が不足している場合、すべての書き込みはキャッシュを経由します。完全なキャッシュラインはアトミックに読み書きされ、何も変わりません。
もちろん、キャッシュラインより小さいデータもアトミックに書き込まれます(周囲のキャッシュラインがそうであるため)。

スレッドセーフですか?

これは別の質問です。明確な「いいえ」で答えるには、少なくとも2つの正当な理由があります。

まず、別のコアがL1のキャッシュラインのコピーを持っている可能性があります(L2以上は通常共有されますが、L1は通常コアごとです!)、同時にその値を変更します。もちろん、それはアトミックにも発生しますが、今では2つの「正しい」(正しく、アトミックに変更された)値があります。どちらが本当に正しい値ですか?
もちろん、CPUは何らかの方法でそれを分類します。しかし、結果はあなたが期待するものではないかもしれません。

第2に、メモリの順序付け、または別の言い方をすれば、発生前の保証が発生します。アトミック命令について最も重要なことは、それらがアトミックであることではありません。それは注文です。

メモリーごとに発生するすべてのことは、「前に起こった」保証がある、保証された明確な順序で実現されるという保証を強制する可能性があります。この順序は、「緩和」されている(まったく読み取られていない)か、必要に応じて厳密に設定できます。

たとえば、ポインターをデータのブロック(たとえば、計算の結果)に設定し、「データの準備ができている」フラグをアトミックに解放できます。これで、このフラグを取得した、ポインタが有効であると考えられます。そして実際、これは常に有効なポインタであり、何も変わらない。これは、ポインターへの書き込みがアトミック操作の前に発生したためです。


2
ロードとストアはそれぞれ個別にアトミックですが、全体としての読み取り-変更-書き込み操作全体は、明らかにアトミックではありませ。キャッシュは一貫しているため、同じ行の競合するコピーを保持することはできません(en.wikipedia.org/wiki/MESI_protocol)。別のコアは、このコアが変更状態にある間は、読み取り専用コピーすらできません。非アトミックなのは、RMWを実行するコアが、ロードとストアの間のキャッシュラインの所有権を失う可能性があることです。
Peter Cordes

2
また、いいえ、キャッシュライン全体が常にアトミックに転送されるとは限りません。参照してくださいこの答え、彼らがいても、実験的にマルチソケットOpteronプロセッサは、ハイパートランスと8Bのチャンクでキャッシュラインを転送することにより、非アトミック16B SSEストアを作ることが実証されています、ですので、負荷(同じタイプのシングルソケットCPU用原子/ストアハードウェアには、L1キャッシュへの16Bパスがあります。x86は、個別のロードまたは最大8Bのストアのアトミック性のみを保証します。
Peter Cordes

コンパイラーにアラインメントを残しても、メモリが4バイト境界でアラインメントされるわけではありません。コンパイラーは、整列境界を変更するためのオプションまたはプラグマを持つことができます。これは、たとえば、ネットワークストリームで密にパックされたデータを操作する場合に役立ちます。
Dmitry Rubanovich 2016

2
哲学、他には何もない。例に示すように、構造体の一部ではない自動ストレージを備えた整数は、確実に正しく整列されます。何か違うと主張するのは、まったく愚かなことです。キャッシュラインとすべてのPODは、PoT(2の累乗)のサイズで調整され、世界中の架空のアーキテクチャではありません。Mathは、適切に位置合わせされたPoTが、同じサイズまたはそれ以上の他のPoTの1つ(これ以上)にぴったりとはまらないとしています。したがって私の発言は正しい。
デイモン

1
@Damon、質問で与えられた例は構造体について言及していませんが、整数が構造体の一部ではない状況だけに問題を絞っていません。PODは、間違いなくPoTサイズを持つことができ、PoTに合わせることができません。構文例については、この回答をご覧ください:stackoverflow.com/a/11772340/1219722。したがって、このように宣言されたPODは実際のコードのかなりの部分でネットワーキングコードで使用されているため、これはほとんど「理論」ではありません。
Dmitry Rubanovich 2016年

2

特定のCPUアーキテクチャで、最適化が無効になっている単一のコンパイラの出力(gccはquick&dirtyの例で最適化する++とコンパイルされないため)は、この方法でインクリメントすることはアトミックであることを意味しているようではなく、これが標準に準拠しているとは限りません(x86ではアトミックではないため、スレッドでアクセスしようとすると未定義の動作が発生します)。addnumadd

アトミック(lock命令接頭辞を使用)はx86では比較的重い(この関連する回答を参照)が、ミューテックスよりも著しく少ないことに注意してください。これは、この使用例ではあまり適切ではありません。

以下の結果は、でコンパイルするときにclang ++ 3.8から取得され-Osます。

参照によるintの増分、「通常の」方法:

void inc(int& x)
{
    ++x;
}

これは次のようにコンパイルされます:

inc(int&):
    incl    (%rdi)
    retq

参照渡しされたintをインクリメントする、アトミックな方法:

#include <atomic>

void inc(std::atomic<int>& x)
{
    ++x;
}

この例は、通常の方法ほど複雑ではありませんlockが、incl命令にプレフィックスが追加されるだけですが、前述のとおり、これは安くはありません。アセンブリが短く見えるからといって、それが高速であるとは限りません。

inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq

-2

コンパイラがインクリメントに単一の命令のみを使用し、マシンがシングルスレッドである場合、コードは安全です。^^


-3

x86以外のマシンで同じコードをコンパイルしてみると、非常に異なるアセンブリ結果がすぐに表示されます。

その理由は、num++ 表示される原子であることが32ビット整数をインクリメントx86マシン上で、実際には、原子(なしメモリの取得が行われないと仮定して)されているためです。しかし、これはC ++標準では保証されておらず、x86命令セットを使用しないマシンでもそうであるとは限りません。そのため、このコードは、プラットフォーム間で競合状態から安全ではありません。

また、特に指示されない限り、x86はメモリへのロードとストアを設定しないため、x86アーキテクチャ上でもこのコードが競合状態から安全であるという強力な保証はありません。したがって、複数のスレッドがこの変数を同時に更新しようとすると、キャッシュされた(古い)値が増加する可能性があります。

その理由は、それから、我々が持っているstd::atomic<int>ようにしてますので、基本的な計算のアトミック性が保証されていないアーキテクチャで作業しているとき、あなたはアトミックコードを生成するコンパイラを強制するメカニズムを持っているということです。


「x86マシンでは、32ビット整数の増分は実際にはアトミックだからです。」それを証明するドキュメントへのリンクを提供できますか?
Slava

8
x86でもアトミックではありません。シングルコアセーフですが、複数のコアがある場合(ある場合)、まったくアトミックではありません。
ハロルド

x86はadd実際にアトミックに保証されていますか?レジスタのインクリメントがアトミックであったとしても驚かないでしょうが、それはほとんど役に立ちません。レジスタのインクリメントを別のスレッドから見えるようにするには、メモリ内にある必要があります。これには、レジスタをロードして格納するための追加の命令が必要であり、アトミック性を削除します。私が理解しているのは、これがlock命令のプレフィックスが存在する理由です。唯一の有用なアトミックaddは逆参照されたメモリに適用されlock、操作中にキャッシュラインが確実にロックされるようにプレフィックスを使用します
ShadowRanger 2016

@Slava @Harold @ShadowRanger回答を更新しました。addはアトミックですが、変更がすぐにグローバルに表示されることはないため、コードが競合状態に対して安全であるとは限りません。
Xirema 2016

3
ただし、定義上「アトミックではない」@Xirema
ハロルド
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.