スレッド間で共有変数を変更するコードが競合状態の影響を受けないのはなぜですか?


107

私はCygwin GCCを使用しており、次のコードを実行します。

#include <iostream>
#include <thread>
#include <vector>
using namespace std;

unsigned u = 0;

void foo()
{
    u++;
}

int main()
{
    vector<thread> threads;
    for(int i = 0; i < 1000; i++) {
        threads.push_back (thread (foo));
    }
    for (auto& t : threads) t.join();

    cout << u << endl;
    return 0;
}

次の行でコンパイルされますg++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.o

1000を出力しますが、これは正しいです。ただし、以前にインクリメントされた値をスレッドが上書きするため、数は少なくなると予想していました。なぜこのコードは相互アクセスの影響を受けないのですか?

私のテストマシンには4つのコアがあり、私が知っているプログラムには制限を設けていません。

共有のコンテンツをfooもっと複雑なものに置き換えても問題は解決しません、例えば

if (u % 3 == 0) {
    u += 4;
} else {
    u -= 1;
}

66
Intel CPUには、SMPシステム(デュアルPentium Proマシンなど)で使用されている非常に初期のx86 CPUとの互換性を維持するために、いくつかの驚くべき内部「シュートダウン」ロジックがあります。私たちが教えている多くの障害状態は、x86マシンで実際に発生することはほとんどありません。つまり、コアがuメモリに書き戻すとしましょう。CPUは実際には、メモリラインuがCPUのキャッシュにないことに気づくなどの驚くべきことを行い、インクリメント操作を再開します。これが、x86から​​他のアーキテクチャーへの移行が目を見張るような体験になる理由です。
デビッドシュワルツ2017年

1
まだ速すぎるかもしれません。完了する前に他のスレッドが確実に起動されるようにするために、スレッドが何かを実行する前にスレッドが確実に譲れるようにするコードを追加する必要があります。
Rob K

1
他の場所で述べたように、スレッドコードは非常に短いため、次のスレッドがキューに入る前に実行される可能性があります。100カウントループにu ++を配置する10スレッドはどうでしょう。そして、ループの開始前のfor内の短い遅延(または、すべてを同時に開始するグローバルな「go」フラグ)
RufusVS

5
実際、ループでプログラムを繰り返しスポーンすると、最終的にはプログラムが壊れていることがわかりwhile true; do res=$(./a.out); if [[ $res != 1000 ]]; then echo $res; break; fi; done;ます。たとえば、私のシステムでは999や998が出力されます。
Daniel Kamil Kozar 2017年

回答:


266

foo()非常に短いため、各スレッドはおそらく次のスレッドが生成される前に終了します。のfoo()前にランダムな時間スリープを追加すると、u++期待どおりの結果が得られる場合があります。


51
これは確かに出力を予想通りに変更しました。
mafu 2017年

49
これは一般的に競合状態を示すためのかなり良い戦略であることに注意します。2つの操作の間に一時停止を挿入できるはずです。そうでなければ、競合状態があります。
Matthieu M.

最近、C#でこの問題が発生しました。通常、コードが失敗することはほとんどありませんが、最近のAPI呼び出しの追加により、一貫して変更を行うのに十分な遅延が生じました。
黒曜石のフェニックス

@MatthieuM。競合状態を検出して確実に再現できるようにするための方法として、Microsoftにはそれを正確に行う自動化ツールがないのですか?
メイソンウィーラー2017年

1
@MasonWheeler:私はLinuxで排他的に作業しているので... dunno :(
Matthieu M.

59

競合状態はコードが正しく実行されないことを保証するものではないことを理解することが重要です。これは、未定義の動作であるため、コードが何でも実行できるということです。期待どおりの実行を含みます。

特にX86およびAMD64マシンでは、命令の多くがアトミックであり、一貫性の保証が非常に高いため、競合状態がまれに問題を引き起こすことはほとんどありません。これらの保証は、多くの命令をアトミックにするためにロックプレフィックスが必要なマルチプロセッサシステムでは多少低下します。

マシンのインクリメントがアトミック操作である場合、言語標準によれば、未定義の動作であるにもかかわらず、これはおそらく正しく実行されます。

特にこの場合、コードはアトミックなFetch and Add命令(X86アセンブリではADDまたはXADD)にコンパイルされる可能性がありますが、シングルプロセッサシステムではアトミックですが、マルチプロセッサシステムでは、アトミックおよびロックであるとは限りません。そうすることを要求されるでしょう。マルチプロセッサシステムで実行している場合は、スレッドが干渉して誤った結果を生成する可能性のあるウィンドウが表示されます。

具体的には、httpsfoo()://godbolt.org/を使用してコードをアセンブリにコンパイルし、次のようにコンパイルします。

foo():
        add     DWORD PTR u[rip], 1
        ret

これは、シングルプロセッサではアトミックになる追加命令を単独で実行していることを意味します(マルチプロセッサシステムではそうではありません)。


41
「意図したとおりに実行する」ことは、未定義の動作の許容される結果であることを覚えておくことは重要です。
Mark

3
あなたが示したように、この命令はSMPマシン(すべての最近のシステムがそうである)ではアトミックではありません 。でもinc [u]原子的ではありません。LOCKプレフィックスは、命令が真にアトミックにするために必要とされます。OPは単に幸運になっています。CPUに「このアドレスのワードに1を加える」と伝えていても、CPUはその値をフェッチ、インクリメント、保存する必要があり、別のCPUが同じことを同時に実行できるため、結果が不正確になることを思い出してください。
Jonathon Reinhart 2017年

2
私は反対票を投じましたが、あなたの質問をもう一度読んだところ、原子性ステートメントは単一のCPUを想定していることがわかりました。これをより明確にするために質問を編集する場合(「アトミック」と言う場合、これは単一のCPUの場合にのみ当てはまることを明確にしてください)、私の反対投票を削除できます。
Jonathon Reinhart 2017年

3
反対票を投じた私は、この主張が少し不快だと感じました。「特にX86およびAMD64マシンでは、命令の多くがアトミックであり、コヒーレンシ保証が非常に高いため、競合状態がまれに問題を引き起こすことはめったにありません。」この段落では、シングルコアに焦点を当てていることを明確に想定する必要があります。それでも、今日のコンシューマデバイスでは、マルチコアアーキテクチャが事実上の標準となっているので、これを最初ではなく最後に説明するためのコーナーケースと考えます。
Patrick Trentin 2017年

3
ああ、間違いなく。x86には多数の下位互換性があります。誤って記述されたコードが可能な限り機能することを確認するためのものです。Pentium Proがアウトオブオーダー実行を導入したとき、それは本当に大きな問題でした。Intelは、新しいチップ用に再コンパイルする必要なしに、インストールされたコードベースが機能することを確認したいと考えていました。x86はCISCコアとして開始されましたが、内部的にはRISCコアに進化しましたが、プログラマーの観点から見ると、CISCとしてさまざまな方法で表示および動作します。詳しくは、ピーター・コーデスの回答をこちらでご覧ください
コーディグレイ

20

前後に睡眠をとれば、そんなに大したことはないと思いますu++。むしろ、操作u++は、呼び出しfooを行うスレッドの生成のオーバーヘッドと比較して、インターセプトされる可能性が低いように非常に迅速に実行されるコードに変換されます。ただし、操作を「延長」u++すると、競合状態が発生する可能性が高くなります。

void foo()
{
    unsigned i = u;
    for (int s=0;s<10000;s++);
    u = i+1;
}

結果: 694


ところで:私も試しました

if (u % 2) {
    u += 2;
} else {
    u -= 1;
}

そして、それはほとんどの1997場合私に与えましたが、時には1995


1
私は、漠然と健全なコンパイラでは、関数全体が同じものに最適化されることを期待します。そうではなかったのには驚きました。興味深い結果をありがとうございます。
Vality

これは正解です。次のスレッドが問題の小さな関数の実行を開始する前に、何千もの命令を実行する必要があります。関数の実行時間をスレッド作成オーバーヘッドに近づけると、競合状態の影響がわかります。
Jonathon Reinhart 2017年

@Vality:O3最適化で偽のforループが削除されることも期待していました。そうじゃない?
user21820 2017年

どのelse u -= 1ように実行できますか?並列環境であっても、値が%2合わないことはありませんか?
mafu 2017年

2
出力から見るelse u -= 1と、u == 0の場合、foo()が最初に呼び出されたときに1回実行されるように見えます。残りの999回uは奇数でu += 2実行され、u = -1 + 999 * 2 = 1997になります。つまり、正しい出力です。競合状態は時々 + = 2の1は、並列スレッドによって上書きされるように、あなたが1995年を取得する原因となる
ルーク

7

それは競合状態に苦しんでいます。usleep(1000);u++;に置くfooと、毎回異なる出力(<1000)が表示されます。


6
  1. 競合状態存在するにもかかわらず、競合状態が現れなかった理由として考えられる答えはfoo()、スレッドを開始するのにかかる時間に比べて非常に速いため、各スレッドが終了してから次のスレッドが開始されることさえあります。だが...

  2. 元のバージョンでも、結果はシステムによって異なります。私は(クアッドコア)Macbookで試してみましたが、10回の実行で、1000が3回、999が6回、998が1回取得されました。そのため、レースはややまれですが、明らかに存在します。

  3. でコンパイルしましたが'-g'、これにはバグをなくす方法があります。私はあなたのコードを再コンパイルしましたが、まだ変更されていませんが、はありませんでした。'-g'レースはさらに顕著になりました。1000を1回、999を3回、998を2回、997を2回、996を1回、992を1回取得しました。

  4. 再 スリープを追加することの提案-それは役立ちますが、(a)一定のスリープ時間は、スレッドが開始時間(タイマーの解決に応じて)によって歪められたままになります、そして(b)ランダムなスリープは、必要なときにスレッドを広げますそれらを互いに近づけます。代わりに、開始信号を待つようにコードを記述します。これにより、すべてを作成してから作業を開始できます。このバージョン(の有無にかかわらず'-g')では、974から998以下のすべての場所で結果が得られます。

    #include <iostream>
    #include <thread>
    #include <vector>
    using namespace std;
    
    unsigned u = 0;
    bool start = false;
    
    void foo()
    {
        while (!start) {
            std::this_thread::yield();
        }
        u++;
    }
    
    int main()
    {
        vector<thread> threads;
        for(int i = 0; i < 1000; i++) {
            threads.push_back (thread (foo));
        }
        start = true;
        for (auto& t : threads) t.join();
    
        cout << u << endl;
        return 0;
    }

ただのメモ。この-gフラグは、「バグをなくす」ことを意味するものではありません。-gGNUコンパイラとClangコンパイラの両方のフラグは、コンパイルされたバイナリにデバッグシンボルを追加するだけです。これにより、プログラムでGDBやMemcheckなどの診断ツールを人間が読める出力で実行できます。たとえば、メモリリークのあるプログラムでMemcheckを実行すると、プログラムが-gフラグを使用してビルドされていない限り、行番号がわかりません。
MS-DDOS 2017年

確かに、デバッガーから隠れているバグは通常、コンパイラーの最適化の問題です。私は試してみて、「の-O2 代わりに使用する」と言ったはず-gです。とはいえ、なしでコンパイルした場合にのみ現れるバグを探す楽しみがなかった場合は、幸運であると考えてください。これは、非常に厄介な微妙なエイリアシングバグによって発生する可能性があります。最近ではありませんが、私それ見たことがあります。おそらくそれは古い独自のコンパイラーの奇妙なことだったと思います。そのため、暫定的には、GNUとClangの最新バージョンについてあなたを信じます。 -g
dgould 2017年

-g最適化の使用を妨げるものではありません。たとえばgcc -O3 -gと同じasmを作成しますgcc -O3が、デバッグメタデータを使用します。ただし、いくつかの変数を出力しようとすると、gdbは「optimized out」と表示します。 -g追加したもののいずれかが.textセクションの一部である場合、メモリ内のいくつかのものの相対位置を変更する可能性があります。オブジェクトファイルには間違いなくスペースが必要ですが、リンクした後、すべてがテキストセグメント(セクションではなく)の一端、またはセグメントの一部ではなくなると思います。たぶん、物事が動的ライブラリにマップされる場所に影響を与える可能性があります。
Peter Cordes
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.