std :: atomicとは正確には何ですか?


172

私はそれstd::atomic<>がアトミックオブジェクトであることを理解しています。しかし、どの程度アトミックですか?私の理解では、操作はアトミックである可能性があります。オブジェクトをアトミックにすることは正確にはどういう意味ですか?たとえば、次のコードを同時に実行する2つのスレッドがあるとします。

a = a + 12;

次に、操作全体(たとえばadd_twelve_to(int))はアトミックですか?または、変数atomic(so operator=())に変更が加えられていますか?


9
a.fetch_add(12)アトミックRMW が必要な場合のようなものを使用する必要があります。
Kerrek SB、2015

うん、それは私にはわからない。オブジェクトをアトミックにすることの意味 インターフェースがあった場合、それは単にミューテックスまたはモニターでアトミックにされた可能性があります。

2
@AaryamanSagarそれは効率の問題を解決します。 mutexとモニターは、計算オーバーヘッドを伴います。を使用std::atomicすると、標準ライブラリで原子性を実現するために必要なものを決定できます。
Drew Dormann、2015

1
@AaryamanSagar:アトミック操作std::atomic<T>可能にするタイプです。それは魔法のようにあなたの人生を良くするわけではありません、あなたはそれでもあなたがそれで何をしたいのかを知る必要があります。これは非常に特定のユースケースのためのものであり、(オブジェクトに対する)アトミック操作の使用は一般に非常に微妙であり、非ローカルな観点から考える必要があります。したがって、そのこと、およびアトミック操作が必要な理由をすでに知っているのでない限り、型はおそらくあまり役​​に立ちません。
Kerrek SB、2015

回答:


188

std :: atomic <>の各インスタンス化と完全な特殊化は、未定義の動作を発生させることなく、さまざまなスレッドが(それらのインスタンスで)同時に操作できる型を表します。

アトミックタイプのオブジェクトは、データ競合のない唯一のC ++オブジェクトです。つまり、あるスレッドがアトミックオブジェクトに書き込みを行っている間に別のスレッドがアトミックオブジェクトに書き込む場合、動作は明確に定義されています。

さらに、アトミックオブジェクトへのアクセスは、スレッド間同期を確立し、で指定されてstd::memory_orderいる非アトミックメモリアクセスを順序付けます。

std::atomic<>C ++ 11より前のバージョンで、(たとえば)GVCの場合はMSVCまたはアトミックブルティン連動した関数を使用して実行する必要があった操作をラップします。

また、同期と順序の制約を指定std::atomic<>するさまざまなメモリ順序を許可することで、より多くの制御を提供します。C ++ 11のアトミックとメモリモデルの詳細については、次のリンクが役立つ場合があります。

一般的な使用例では、オーバーロードされた算術演算子またはそれらの別のセットを使用することに注意してください。

std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this

演算子の構文ではメモリの順序を指定できないため、これらの操作はstd::memory_order_seq_cstC ++ 11のすべてのアトミック操作のデフォルトの順序であるため、で実行されます。これにより、すべてのアトミック操作間のシーケンシャルな一貫性(全体的なグローバル順序)が保証されます。

ただし、場合によっては、これは必要ない場合があり(無料で提供されるものもない)、より明示的な形式を使用することもできます。

std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation

今、あなたの例:

a = a + 12;

単一原子OPに評価されません:それがもたらすであろうa.load()(それ自体原子である)、この値との間の付加12及びa.store()最終結果の(もアトミック)。前述したように、std::memory_order_seq_cstここで使用されます。

ただし、と書くa += 12と、アトミック操作(前述のとおり)となり、とほぼ同等になりa.fetch_add(12, std::memory_order_seq_cst)ます。

あなたのコメントについて:

レギュラーにintはアトミックなロードとストアがあります。それをラッピングする意味はatomic<>何ですか?

あなたのステートメントは、ストアやロードの原子性のそのような保証を提供するアーキテクチャにのみ当てはまります。これを行わないアーキテクチャがあります。また、通常、ワード/ dword境界整列されたアドレスで操作をアトミックに実行する必要std::atomic<>があることは、追加の要件なしに、すべてのプラットフォームでアトミックであることが保証されているものです。さらに、次のようなコードを記述できます。

void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;

// Thread 1
void produce()
{
    sharedData = generateData();
    ready_flag.store(1, std::memory_order_release);
}

// Thread 2
void consume()
{
    while (ready_flag.load(std::memory_order_acquire) == 0)
    {
        std::this_thread::yield();
    }

    assert(sharedData != nullptr); // will never trigger
    processData(sharedData);
}

アサーション条件は常にtrueであるため(トリガーしないため)、whileループの終了後にデータが準備できていることを常に確認できます。その理由は:

  • store()フラグのsharedData設定は、が設定された後で実行され(generateData()常に何か有用なものを返すと仮定します。特に、決して戻らないと仮定しますNULL)、std::memory_order_release順序を使用します。

memory_order_release

このメモリ順序のストア操作は解放 操作を実行します。このストアので、現在のスレッドの読み取りまたは書き込みを並べ替えることはできません 。現在のスレッドでのすべての書き込みは、同じアトミック変数を取得する他のスレッドで見ることができます

  • sharedDatawhileループの終了後に使用されるため、load()from fromフラグはゼロ以外の値を返します。順序をload()使用std::memory_order_acquire

std::memory_order_acquire

このメモリ順序でのロード操作は、影響を受けるメモリ位置で取得操作を実行します。このロードのに、現在のスレッドでの読み取りまたは書き込みを並べ替えることはできません。同じアトミック変数を解放する他のスレッドでのすべての書き込みは、現在のスレッドに表示されます

これにより、同期を正確に制御でき、コードがどのように動作するか、動作しないか、動作しないかを明示的に指定できます。保証のみが原子性そのものだった場合、これは不可能です。それはのような非常に興味深い同期モデルに来る場合は特に順序をリリース、消費


2
intsのようなプリミティブのアトミックロードおよびストアを持たないアーキテクチャは実際にありますか?

7
それは原子性だけではありません。また、注文、マルチコアシステムでの動作などについてもです。この記事を読むことをお勧めします。
Mateusz Grzejek

4
@AaryamanSagar私が誤解していない場合でも、x86でさえ、読み取りと書き込みは、単語の境界に整列されている場合にのみアトミックです。
v.shashenko 2016年

@MateuszGrzejekアトミック型への参照を取得しました。次の方法でもオブジェクト割り当てideone.com/HpSwqoの
xAditya3393

3
@TimMBはい、通常、実行の順序が変更される可能性のある(少なくとも)2つの状況があります。 (CPUレジスタ、予測などの使用に基づく)および(2)CPUは、たとえば、キャッシュ同期点の数を最小限に抑えるために、異なる順序で命令を実行できます。std::atomicstd::memory_order)に提供されている順序付けの制約は、発生する可能性のある再順序付けを制限する目的を正確に果たします。
Mateusz Grzejek

20

std::atomic<>オブジェクトがアトミックになることを理解しています。

それは視点の問題です...任意のオブジェクトに適用してその操作をアトミックにすることはできませんが、(ほとんどの)整数型とポインターに提供されている特殊化を使用できます。

a = a + 12;

std::atomic<>これを(テンプレート式を使用して)単一のアトミック操作に単純化せず、代わりにoperator T() const volatile noexceptメンバーがアトミックload()ofを実行してaから、12が追加され、をoperator=(T t) noexcept実行しstore(t)ます。


それが私が聞きたかったことです。通常のintには、アトミックなロードとストアがあります。これをatomic <>でラップする意味は

8
@AaryamanSagarは、単に通常の修正はint、あなたが他のスレッドの変更を確認し、いくつかのものが好きな確保が可搬性の変更が他のスレッドから見える保証するものではない、またそれを読んでないmy_int += 3あなたが使用しない限り、アトミックに行われることが保証されていないstd::atomic<>、彼らは関与かもしれません-フェッチ、追加、保存のシーケンス。同じ値を更新しようとする他のスレッドが、フェッチの後でストアの前に到着し、スレッドの更新を破壊する可能性があります。
Tony Delroy、2015

通常のintを単に変更するだけでは、他のスレッドから変更が見えるように移植性が保証されるわけではありません」それはさらに悪いことです。
curiousguy

8

std::atomic 多くのISAがハードウェアを直接サポートしているために存在します

C ++標準について述べstd::atomicていることは、他の回答で分析されています。

それではstd::atomic、別の種類の洞察を得るために何がコンパイルされるかを見てみましょう。

この実験の主な要点は、最近のCPUがアトミック整数演算(x86のLOCKプレフィックスなど)を直接サポートし、std::atomic基本的にそれらの命令へのポータブルインターフェイスとして存在するということです:x86アセンブリでの "lock"命令の意味は?aarch64では、LDADDが使用されます。

このサポートにより、などのより一般的なメソッドのより高速な代替が可能になりstd::mutex、より複雑なマルチ命令セクションをアトミックにすることができますが、Linuxでシステムコールを実行するstd::atomicためにかかる速度よりも遅くなります。参照:std :: mutexはフェンスを作成しますか?std::mutexfutexstd::atomic

複数のスレッドにわたってグローバル変数をインクリメントする次のマルチスレッドプログラムを考えてみましょう。使用されるプリプロセッサ定義に応じて異なる同期メカニズムが使用されます。

main.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

size_t niters;

#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
#if LOCK
        __asm__ __volatile__ (
            "lock incq %0;"
            : "+m" (global),
              "+g" (i) // to prevent loop unrolling
            :
            :
        );
#else
        __asm__ __volatile__ (
            ""
            : "+g" (i) // to prevent he loop from being optimized to a single add
            : "g" (global)
            :
        );
        global++;
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    uint64_t expect = nthreads * niters;
    std::cout << "expect " << expect << std::endl;
    std::cout << "global " << global << std::endl;
}

GitHubアップストリーム

コンパイル、実行、逆アセンブル:

comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out                    $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out       -DLOCK       $common

./main_fail.out       4 100000
./main_std_atomic.out 4 100000
./main_lock.out       4 100000

gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out

非常に可能性の高い「間違った」競合状態出力main_fail.out

expect 400000
global 100000

他の決定論的な「正しい」出力:

expect 400000
global 400000

の分解main_fail.out

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 <niters>
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 <threadMain()+52>
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 <global>
   0x0000000000002797 <+23>:    xor    %eax,%eax
   0x0000000000002799 <+25>:    nopl   0x0(%rax)
   0x00000000000027a0 <+32>:    add    $0x1,%rax
   0x00000000000027a4 <+36>:    add    $0x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq

の分解main_std_atomic.out

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a6 <threadMain()+38>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq $0x1,0x299f(%rip)        # 0x5138 <global>
   0x0000000000002799 <+25>:    add    $0x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 <niters>
   0x00000000000027a4 <+36>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a6 <+38>:    retq   

の分解main_lock.out

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a5 <threadMain()+37>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 <global>
   0x0000000000002798 <+24>:    add    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq

結論:

  • 非アトミックバージョンはグローバルをレジスタに保存し、レジスタをインクリメントします。

    したがって、最後に、同じ「間違った」値の4つの書き込みがグローバルに発生する可能性が非常に高くなります100000

  • std::atomicにコンパイルされlock addqます。LOCKプレフィックスは、次のincフェッチ、変更、および更新をアトミックに行います。

  • 明示的なインラインアセンブリのLOCKプレフィックスは、の代わりに使用されるstd::atomicことを除いて、とほぼ同じようにコンパイルされます。私たちのINCが1バイト小さいデコードを生成したことを考えると、GCCがを選択した理由がわかりません。incaddadd

ARMv8では、新しいCPUでLDAXR + STLXRまたはLDADDを使用できます。プレーンCでスレッドを開始するにはどうすればよいですか?

Ubuntu 19.10 AMD64、GCC 9.2.1、Lenovo ThinkPad P51でテスト済み。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.