web-dev-qa-db-ja.com

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

一般に、int numnum++(または++num)の場合、read-modify-write操作として、atomic。しかし、コンパイラをよく見ます。たとえば、 GCC は、次のコードを生成します( ここに試してください ):

Enter image description here

num++に対応する5行目は1つの命令であるため、この場合num++はatomicであると結論付けることができますか?

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

UPDATE

この質問はnot増分isアトミックであることに注意してください(そうではなく、それが質問の最初の行です)。特定のシナリオでcanであるかどうか、つまり、特定の場合にlockプレフィックスのオーバーヘッドを回避するために1命令の性質を利用できるかどうかです。そして、受け入れられた答えがユニプロセッサマシンに関するセクションで言及しているように、 この答え 、そのコメントおよび他の人の会話の説明、it can(ただし、CまたはC++では使用できません)。

148
Leo Heinsaar

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

f():
        rep ret

OK、チャンスを与えましょう:

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」ルールがあります。これにより、コンパイラはコードを並べ替えることができ、結果にexact same observable effect(副作用を含む)単にコードを実行したかのように。

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

考慮してください:

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

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

void incdec(int&) {
    // nada
}

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

ただし、これは別のボールゲームです。

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

numはアトミックです。それに対する変更must監視している他のスレッドから観察できるようにします。これらのスレッド自体が行う変更(増分と減分の間で値を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
39
Richard Hodges

多くの合併症がなければ、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回だけインクリメントされます。

37
Margaret Bloom

追加命令はnot atomicです。メモリを参照し、2つのプロセッサコアがそのメモリの異なるローカルキャッシュを持つ場合があります。

IIRC add命令のアトミックなバリアントはlock xaddと呼ばれます

11
Sven Nilsson

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

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

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

10
Slava

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

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

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

小型の組み込みPCをターゲットにしており、コードを他の何かに移動する計画がない場合、「追加」命令のアトミックな性質が悪用される可能性があります。一方、操作が本質的にアトミックであるプラットフォームはますます不足しています。

(ただし、C++で記述している場合は役に立ちません。コンパイラには、メモリ宛先addまたはxadd without aにコンパイルするためにnum++を要求するオプションがありません。 lockプレフィックス。numをレジスタにロードし、インクリメント結果を別の命令で保存することを選択できます。結果を使用する場合は、それを行う可能性があります。


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

9
supercat

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

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

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++カンファレンスで、言語ルールが許す限り、素朴に書かれたマルチスレッドプログラムを誤動作させる最適化を非常に喜んで実装しているとtwoコンパイラ開発者から聞いたことがあります。正しく書かれたプログラムでは、わずかなパフォーマンスの改善さえ見られます。

最後に、ifでも移植性は気にせず、コンパイラは魔法のように素晴らしかったです、使用しているCPUはスーパースカラーCISCタイプである可能性が非常に高く、命令をマイクロオペレーションに分解して並べ替えます1秒あたりの操作を最大化するために、(Intelで)LOCKプレフィックスやメモリフェンスなどのプリミティブを同期することによってのみ制限される範囲で、投機的にそれらを実行します。

簡単に言うと、スレッドセーフプログラミングの本来の責任は次のとおりです。

  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. ++readyhappens-beforeは、readyがゼロではないと見なすチェックであり、これらの操作の周りで他の操作を並べ替えることはできません。これは、++readyとチェックが連続的に一貫したであるためです。これは、C++メモリモデルで説明されている別の用語であり、この特定の並べ替えを禁止しています。したがって、コンパイラーは命令を並べ替えてはならず、CPUに指示してはいけません。 vecの増分後、readyへの書き込みを延期します。 連続一貫性は、言語標準のアトミックに関する最も強力な保証です。より少ない(そして理論的にはより安い)保証が利用可能です。 std::atomic<T>の他のメソッドを使用しますが、これらは間違いなく専門家専用であり、ほとんど使用されないため、コンパイラ開発者によってあまり最適化されない場合があります。
9
Arne Vogel

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

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

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

最新のx86/x64プロセッサでは、単一の命令が複数のmicro opsに分割され、さらにメモリの読み書きがバッファリングされます。したがって、異なるCPUで実行されている異なるスレッドは、これを非アトミックと見なすだけでなく、メモリから読み取るものと、その時点までに他のスレッドが読み取ったと仮定するものに関して一貫性のない結果を見る場合があります:memory fences正常な動作を復元します。

7
JDługosz

いいえ https://www.youtube.com/watch?v=31g0YE61PLQ (これは「The Office」の「No」シーンへの単なるリンクです)

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

サンプル出力:

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

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

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

また、出力に関係なく、スレッド同期を同じ方法で考えることができます-スレッドAがnum++; num--;を実行し、スレッドBがnumを繰り返し読み取る場合、有効なインターリーブは、スレッドBがnum++およびnum--。このインターリーブは有効であるため、コンパイラーはonly可能なインターリーブを自由に作成できます。そして、incr/decrを完全に削除します。

ここにはいくつかの興味深い意味があります。

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

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

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

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

progress += local;

おそらくそれは有効です。しかし、おそらくプログラマーが望んでいたことではありません:-(

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

progressも揮発性であったとしても、これは依然として有効です。

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

while (local--)
    progress++;

:-/

4
tony

特定のCPUアーキテクチャで、最適化を無効にした単一のコンパイラの出力(gccは最適化時に++addさえコンパイルしないため、 quick&dirtyの例 )このように増分することをアトミックであると暗示することは、これが標準準拠であることを意味せず(スレッドでnumにアクセスしようとすると未定義の動作を引き起こします)、addnotx86のアトミック。

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

-Osを使用してコンパイルする場合、clang ++ 3.8から次の結果が得られます。

参照によるintのインクリメント、「通常の」方法:

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

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

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

参照によって渡されたintのインクリメント、アトミックな方法:

#include <atomic>

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

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

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

はい、でも...

アトミックはあなたが言うつもりではありません。あなたはおそらく間違ったことを求めているでしょう。

増分は確かにatomicです。ストレージの位置がずれていない限り(そして、コンパイラーに合わせて配置したので、そうではない場合)、必ず1つのキャッシュライン内で位置合わせされます。特別な非キャッシュストリーミング命令を除いて、すべての書き込みはキャッシュを通過します。完全なキャッシュラインはアトミックに読み書きされており、決して異なるものではありません。
キャッシュラインよりも小さいデータは、もちろん、アトミックに書き込まれます(周囲のキャッシュラインがあるため)。

スレッドセーフですか?

これは別の質問であり、明確な"No!"で答えるには少なくとも2つの理由があります。

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

第二に、メモリの順序があります。つまり、発生前の保証が異なります。アトミック命令について最も重要なことは、それらがatomicであるということではありません。注文です。

あなたは、「賢明な前に」保証がある場合、メモリに関して起こるすべてが保証された、明確に定義された順序で実現されるという保証を実施する可能性があります。この順序は、「リラックス」(読み方:まったくなし)でも、必要に応じて厳密でもかまいません。

たとえば、データのブロック(計算の結果など)にポインターを設定し、アトミックにrelease "data is ready"を設定できます。国旗。さて、誰でもacquiresこのフラグは、ポインターが有効であると考えるようになります。そして確かに、alwaysは有効なポインターであり、決して異なるものではありません。これは、アトミック操作の前にポインターへの書き込みが発生したためです。

2
Damon