C++20 でのアトミックとの同期

送信側/受信側のワークフローは、スレッドでは非常に一般的です。このようなワークフローでは、受信者は送信者の通知を待ってから作業を続行します。これらのワークフローを実装するには、さまざまな方法があります。 C++11 では、条件変数または promise/future のペアを使用できます。 C++20 では、アトミックを使用できます。

スレッドを同期するには、さまざまな方法があります。それぞれの方法には長所と短所があります。そのため、それらを比較したいと思います。条件変数や約束と先物についての詳細を知らないと思います。したがって、簡単に復習します。

条件変数

条件変数は、送信者または受信者の役割を果たすことができます。送信者として、1 人以上の受信者に通知できます。

// threadSynchronisationConditionVariable.cpp

#include <iostream>
#include <condition_variable>
#include <mutex>
#include <thread>
#include <vector>

std::mutex mutex_;
std::condition_variable condVar;

std::vector<int> myVec{};

void prepareWork() { // (1)

 {
 std::lock_guard<std::mutex> lck(mutex_);
 myVec.insert(myVec.end(), {0, 1, 0, 3}); // (3)
 }
 std::cout << "Sender: Data prepared." << std::endl;
 condVar.notify_one();
}

void completeWork() { // (2)

 std::cout << "Worker: Waiting for data." << std::endl;
 std::unique_lock<std::mutex> lck(mutex_);
 condVar.wait(lck, [] { return not myVec.empty(); });
 myVec[2] = 2; // (4)
 std::cout << "Waiter: Complete the work." << std::endl;
 for (auto i: myVec) std::cout << i << " ";
 std::cout << std::endl;
 
}

int main() {

 std::cout << std::endl;

 std::thread t1(prepareWork);
 std::thread t2(completeWork);

 t1.join();
 t2.join();

 std::cout << std::endl;
 
}

プログラムには 2 つの子スレッドがあります:t1t2 .ペイロード prepareWork を取得します そして completeWork 行 (1) および (2) で。関数 prepareWork 作業の準備が完了したことを通知します:condVar.notify_one() .ロックを保持している間、スレッド t2 通知を待っています:condVar.wait(lck, []{ return not myVec.empty(); }) .待機中のスレッドは常に同じ手順を実行します。起こされると、ロックを保持しながら述語をチェックします ([]{ return not myVec.empty(); )。述語が成り立たない場合は、スリープ状態に戻ります。述語が成立する場合、その作業を続行します。具体的なワークフローでは、送信スレッドが初期値を std::vector に入れます (3)、受信スレッドが完了する (4)。

条件変数には多くの固有の問題があります。たとえば、受信者は通知なしで目覚めたり、通知を失ったりする可能性があります。最初の問題はスプリアス ウェイクアップと呼ばれ、2 つ目はロスト ウェイクアップです。述語は両方の欠陥から保護します。受信者が待機状態になり、述語を使用しない前に、送信者が通知を送信すると、通知が失われます。その結果、受信者は決して起こらない何かを待ちます。これはデッドロックです。プログラムの出力を調べると、述語を使用しないと、毎秒実行するとデッドロックが発生することがわかります。もちろん、述語なしで条件変数を使用することも可能です。

送信者/受信者のワークフローと条件変数のトラップの詳細を知りたい場合は、以前の投稿「C++ コア ガイドライン:条件変数のトラップに注意する」をお読みください。

前のプログラムのように 1 回限りの通知が必要な場合は、条件変数よりも promise と future の方が適しています。 Promise と Future は、偽のまたは失われた wakeup の犠牲者になることはできません。

約束と未来

Promise は、値、例外、または通知を関連する Future に送信できます。 promise と future を使用して、前のワークフローをリファクタリングしましょう。これは、promise/future ペアを使用した同じワークフローです。

// threadSynchronisationPromiseFuture.cpp

#include <iostream>
#include <future>
#include <thread>
#include <vector>

std::vector<int> myVec{};

void prepareWork(std::promise<void> prom) {

 myVec.insert(myVec.end(), {0, 1, 0, 3});
 std::cout << "Sender: Data prepared." << std::endl;
 prom.set_value(); // (1)

}

void completeWork(std::future<void> fut){

 std::cout << "Worker: Waiting for data." << std::endl;
 fut.wait(); // (2)
 myVec[2] = 2;
 std::cout << "Waiter: Complete the work." << std::endl;
 for (auto i: myVec) std::cout << i << " ";
 std::cout << std::endl;
 
}

int main() {

 std::cout << std::endl;

 std::promise<void> sendNotification;
 auto waitForNotification = sendNotification.get_future();

 std::thread t1(prepareWork, std::move(sendNotification));
 std::thread t2(completeWork, std::move(waitForNotification));

 t1.join();
 t2.join();

 std::cout << std::endl;
 
}

ワークフローを調べると、同期がその重要な部分に削減されていることがわかります:prom.set_value() (1) と fut.wait() (2). ロックやミューテックスを使用する必要も、スプリアスまたは失われたウェイクアップから保護するために述語を使用する必要もありません。この実行のスクリーンショットは省略します。これは、条件変数を使用した前回の実行の場合と本質的に同じであるためです。

promise と future を使用することの唯一の欠点は、一度しか使用できないことです。これは、Promise と Future に関する以前の投稿で、単にタスクと呼ばれることがよくあります。

複数回通信したい場合は、条件変数またはアトミックを使用する必要があります。

std::atomic_flag

C++11 の std::atomic_flag には単純なインターフェイスがあります。そのメンバー関数 clear を使用すると、値を false に設定し、test_and_set を true に設定できます。 test_and_set を使用すると、古い値が返されます。 ATOMIC_FLAG_INIT std::atomic_flag を初期化できるようにします false へ . std::atomic_flag には 2 つの非常に興味深いプロパティがあります。

std::atomic_flag です

  • 唯一のロックフリーのアトミック
  • 高度なスレッドの抽象化のための構成要素

残りのより強力なアトミックは、ミューテックスを使用して機能を提供できます。これは、C++ 標準に従っています。したがって、これらのアトミックにはメンバー関数 is_lock_free があります。一般的なプラットフォームでは、常に true という答えが得られます。 .しかし、あなたはそれを認識している必要があります。 std::atomic_flag の機能の詳細はこちら C++11.

ここで、C++11 から C++20 に直接ジャンプします。 C++20 では、 std::atomic_flag atomicFlag 新しいメンバー関数をサポート:atomicFlag.wait( )、atomicFlag.notify_one() 、および atomicFlag.notify_all() .メンバー関数 notify_one または notify_all 待機中のアトミック フラグの 1 つまたはすべてに通知します。 atomicFlag.wait(boo) ブール値の boo が必要です .呼び出し atomicFlag.wait(boo) 次の通知または偽のウェイクアップまでブロックします。次に、値 atomicFlag かどうかをチェックします boo に等しい そうでない場合はブロックを解除します。値 boo 一種の述語として機能します。

C++11 に加えて、std::atomic_flag のデフォルト構築 false に設定します 状態と std::atomic flag の値を求めることができます atomicFlag.test()経由 .この知識があれば、std::atomic_flag を使用して以前のプログラムにリファクタリングするのは非常に簡単です。 .

// threadSynchronisationAtomicFlag.cpp

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

std::vector<int> myVec{};

std::atomic_flag atomicFlag{};

void prepareWork() {

 myVec.insert(myVec.end(), {0, 1, 0, 3});
 std::cout << "Sender: Data prepared." << std::endl;
 atomicFlag.test_and_set(); // (1)
 atomicFlag.notify_one(); 

}

void completeWork() {

 std::cout << "Worker: Waiting for data." << std::endl;
 atomicFlag.wait(false); // (2)
 myVec[2] = 2;
 std::cout << "Waiter: Complete the work." << std::endl;
 for (auto i: myVec) std::cout << i << " ";
 std::cout << std::endl;
 
}

int main() {

 std::cout << std::endl;

 std::thread t1(prepareWork);
 std::thread t2(completeWork);

 t1.join();
 t2.join();

 std::cout << std::endl;
 
}

作業を準備するスレッド (1) は atomicFlag を設定します true へ 通知を送信します。作業を完了したスレッドは、通知を待ちます。 atomicFlag の場合にのみブロックが解除されます true に等しい .

Microsoft Compiler を使用したプログラムの実行例をいくつか示します。

future/promise のペアを使用するか、std::atomic_flag を使用するかはわかりません このような単純なスレッド同期ワークフローの場合。どちらも設計上スレッドセーフであり、これまでのところ保護メカニズムは必要ありません。 Promise と promise の方が使いやすいですが、std::atomic_flag おそらくより高速です。可能であれば、条件変数を使用しないことだけは確かです.

次は?

ピンポン ゲームなどのより複雑なスレッド同期ワークフローを作成する場合、promise/future のペアはオプションではありません。複数の同期には、条件変数またはアトミックを使用する必要があります。次の投稿では、条件変数と std::atomic_flag を使用してピンポン ゲームを実装します。 パフォーマンスを測定します。

小休憩

私は短いクリスマス休暇を取り、1 月 11 日に次の投稿を公開します。 C++20 について詳しく知りたい場合は、Leanpub にある私の新しい本を C++20 まで読んでください。