C++20 のラッチ

ラッチとバリアは、一部のスレッドがカウンターがゼロになるまで待機できるようにする調整タイプです。 std::latch を使用できます 一度だけですが、 std::barrier を使用できます 一回以上。今日は、ラッチを詳しく見ていきます。

std::latch のメンバー関数の同時呼び出し または std::barrier はデータ競合ではありません。データ競合は並行性において非常に重要な用語であるため、もっと多くの言葉を書きたいと思います.

データ競争

データ競合とは、少なくとも 2 つのスレッドが同時に共有変数にアクセスし、少なくとも 1 つのスレッドが変数を変更しようとする状況です。プログラムにデータ競合がある場合、その動作は未定義です。これは、すべての結果が可能であることを意味し、したがって、プログラムについての推論はもはや意味がありません.

データ競合のあるプログラムをお見せしましょう。

// addMoney.cpp

#include <functional>
#include <iostream>
#include <thread>
#include <vector>

struct Account{
 int balance{100}; // (3) 
};

void addMoney(Account& to, int amount){ // (2) 
 to.balance += amount; // (1) 
}

int main(){
 
 std::cout << '\n';

 Account account;
 
 std::vector<std::thread> vecThreads(100);
 
 for (auto& thr: vecThreads) thr = std::thread(addMoney, std::ref(account), 50); 
 
 for (auto& thr: vecThreads) thr.join();
 
 std::cout << "account.balance: " << account.balance << '\n'; // (4)
 
 std::cout << '\n';

}

関数 addMoney を使用して、同じアカウントに 50 ユーロを追加する 100 スレッド (1) (2)。初期アカウントは 100 (3) です。重要な観察は、アカウントへの書き込みが同期なしで行われることです。したがって、データ競合が発生し、その結果、未定義の動作が発生します。最終的な残高は 5000 から 5100 ユーロ (4) です。

何が起こっている?いくつかの追加が欠落しているのはなぜですか?更新プロセス to.balance += amount; 行 (1) は、いわゆる read-modify-write 操作です。そのため、最初に to.balance の古い値 読み取られ、更新され、最後に書き込まれます。ボンネットの下で何が起こるかは次のとおりです。議論をより明確にするために数字を使用します

  • スレッド A が 500 ユーロの値を読み取り、スレッド B が起動します。
  • スレッド B も値 500 ユーロを読み取り、それに 50 ユーロを追加して、to.balance を更新します。 550 ユーロまで。
  • スレッド A は to.balance に 50 ユーロを追加して実行を終了しました 550 ユーロも書いています。
  • 重要なのは、550 ユーロの値が 2 回書かれていることです。50 ユーロが 2 回追加されるのではなく、1 回だけ確認されます。
  • これは、1 つの変更が失われ、間違った最終的な金額が得られることを意味します。

まず、std::latch を提示する前に 2 つの質問に答えてください。 および std::barrier

2 つの質問

<オール>
  • スレッドを調整するこれら 2 つのメカニズムの違いは何ですか? std::latch を使用できます 一度だけですが、 std::barrier を使用できます 一回以上。 std::latch 複数のスレッドで 1 つのタスクを管理するのに役立ちます。 std::barrier 複数のスレッドで繰り返されるタスクを管理するのに役立ちます。さらに、std::barrier いわゆる完了ステップで機能を実行できます。完了ステップは、カウンターがゼロになったときの状態です。
  • C++11 ではフューチャ、スレッド、または条件変数をロックと組み合わせて実行できない、ラッチとバリアがサポートするユース ケースは何ですか? ラッチとバリアは新しいユース ケースに対応していませんが、はるかに使いやすくなっています。また、内部でロックフリー メカニズムを使用することが多いため、パフォーマンスも向上します。
  • 両方の単純なデータ型について投稿を続けましょう。

    std::latch

    では、std::latch のインターフェースを詳しく見てみましょう。 .

    upd のデフォルト値 1 です . updのとき がカウンタより大きいか負の場合、動作は未定義です。呼び出し lat.try_wait() その名前が示すように、決して待ちません。

    次のプログラム bossWorkers.cpp 2 つの std::latch を使用 上司と従業員のワークフローを構築します。出力を std::cout に同期しました 関数 synchronizedOut を使用します (1)。この同期により、ワークフローに従うことが容易になります。

    // bossWorkers.cpp
    
    #include <iostream>
    #include <mutex>
    #include <latch>
    #include <thread>
    
    std::latch workDone(6);
    std::latch goHome(1); // (4)
    
    std::mutex coutMutex;
    
    void synchronizedOut(const std::string s) { // (1)
     std::lock_guard<std::mutex> lo(coutMutex);
     std::cout << s;
    }
    
    class Worker {
     public:
     Worker(std::string n): name(n) { };
     
     void operator() (){
     // notify the boss when work is done
     synchronizedOut(name + ": " + "Work done!\n");
     workDone.count_down(); // (2)
    
     // waiting before going home
     goHome.wait(); // (5)
     synchronizedOut(name + ": " + "Good bye!\n");
     }
     private:
     std::string name;
    };
    
    int main() {
    
     std::cout << '\n';
    
     std::cout << "BOSS: START WORKING! " << '\n';
     
     Worker herb(" Herb");
     std::thread herbWork(herb);
     
     Worker scott(" Scott");
     std::thread scottWork(scott);
     
     Worker bjarne(" Bjarne");
     std::thread bjarneWork(bjarne);
     
     Worker andrei(" Andrei");
     std::thread andreiWork(andrei);
     
     Worker andrew(" Andrew");
     std::thread andrewWork(andrew);
     
     Worker david(" David");
     std::thread davidWork(david);
     
     workDone.wait(); // (3)
    
     std::cout << '\n';
    
     goHome.count_down();
    
     std::cout << "BOSS: GO HOME!" << '\n';
    
     herbWork.join();
     scottWork.join();
     bjarneWork.join();
     andreiWork.join();
     andrewWork.join();
     davidWork.join();
     
    }
    

    ワークフローの考え方は簡単です。 6人の労働者 herbscottbjarneandreiandrew 、および david main で -プログラムはその仕事を果たさなければなりません。彼らは仕事を終えると、std::latch workDone をカウントダウンします。 (2)。ボス (main -thread) はカウンターが 0 になるまで行 (3) でブロックされます。カウンターが 0 になると、ボスは 2 番目の std::latch goHome を使用します 従業員に帰宅するように合図する。この場合、初期カウンターは 1 です (4)。呼び出し goHome.wait (5) カウンターが0になるまでブロックする。

    このワークフローについて考えると、ボスがいなくても実行できることに気付くかもしれません。これが最新のバリアントです:

    // workers.cpp
    
    #include <iostream>
    #include <latch>
    #include <mutex>
    #include <thread>
    
    std::latch workDone(6);
    std::mutex coutMutex;
    
    void synchronizedOut(const std::string& s) {
     std::lock_guard<std::mutex> lo(coutMutex);
     std::cout << s;
    }
    
    class Worker {
     public:
     Worker(std::string n): name(n) { };
     
     void operator() () {
     synchronizedOut(name + ": " + "Work done!\n");
     workDone.arrive_and_wait(); // wait until all work is done (1)
     synchronizedOut(name + ": " + "See you tomorrow!\n");
     }
     private:
     std::string name;
    };
    
    int main() {
    
     std::cout << '\n';
    
     Worker herb(" Herb");
     std::thread herbWork(herb);
     
     Worker scott(" Scott");
     std::thread scottWork(scott);
     
     Worker bjarne(" Bjarne");
     std::thread bjarneWork(bjarne);
     
     Worker andrei(" Andrei");
     std::thread andreiWork(andrei);
     
     Worker andrew(" Andrew");
     std::thread andrewWork(andrew);
     
     Worker david(" David");
     std::thread davidWork(david);
    
     herbWork.join();
     scottWork.join();
     bjarneWork.join();
     andreiWork.join();
     andrewWork.join();
     davidWork.join();
     
    }
    

    この簡素化されたワークフローに追加することはあまりありません。呼び出し workDone.arrive_and_wait(1) (1) count_down(upd); wait(); の呼び出しと同等 .これは、以前のプログラム bossWorkers.cpp のように、労働者が自分自身を調整し、ボスが不要になったことを意味します。 .

    次は?

    std::barrier std::latch によく似ています . std::barrier の強みは、仕事を複数回行うことです。次回の投稿では、障壁について詳しく見ていきます。