C++20 における条件変数とアトミックのパフォーマンス比較

std::atomic_flag の導入後 前回の投稿 C++20 での Atomics との同期で、さらに深く掘り下げたいと思います。今日は、条件変数 std::atomic_flag を使用してピンポン ゲームを作成します。 、および std::atomic<bool> .遊びましょう。

この投稿で答えたい重要な質問は次のとおりです。C++20 でスレッドを同期する最速の方法は何ですか?この投稿では、3 つの異なるデータ型を使用しています:std::condition_variablestd::atomic_flag 、および std::atomic<bool> .

同等の数値を得るために、ピンポン ゲームを実装します。 1 つのスレッドが ping を実行します 関数と他のスレッド pong 関数。簡単にするために、ping を実行するスレッドを呼び出します。 ping スレッドを機能させ、もう一方のスレッドを pong スレッドとして機能させます。 ping スレッドは、pong スレッドの通知を待機し、通知を pong スレッドに送り返します。 1,000,000 ボールの変更後にゲームが停止します。各ゲームを 5 回実行して、同等のパフォーマンス数値を取得しています。

アトミックとの同期を既にサポートしているため、最新の Visual Studio コンパイラでパフォーマンス テストを行いました。さらに、最大限の最適化 (/Ox) でサンプルをコンパイルしました。 ).

C++11 から始めましょう。

条件変数

// pingPongConditionVariable.cpp

#include <condition_variable>
#include <iostream>
#include <atomic>
#include <thread>

bool dataReady{false};

std::mutex mutex_;
std::condition_variable condVar1; // (1)
std::condition_variable condVar2; // (2)

std::atomic<int> counter{};
constexpr int countlimit = 1'000'000;

void ping() {

 while(counter <= countlimit) {
 {
 std::unique_lock<std::mutex> lck(mutex_);
 condVar1.wait(lck, []{return dataReady == false;});
 dataReady = true;
 }
 ++counter; 
 condVar2.notify_one(); // (3)
 }
}

void pong() {

 while(counter < countlimit) { 
 {
 std::unique_lock<std::mutex> lck(mutex_);
 condVar2.wait(lck, []{return dataReady == true;});
 dataReady = false;
 }
 condVar1.notify_one(); // (3)
 }

}

int main(){

 auto start = std::chrono::system_clock::now(); 

 std::thread t1(ping);
 std::thread t2(pong);

 t1.join();
 t2.join();
 
 std::chrono::duration<double> dur = std::chrono::system_clock::now() - start;
 std::cout << "Duration: " << dur.count() << " seconds" << std::endl;

}

プログラムで 2 つの条件変数を使用します:condVar1condVar2 (1 行目と 2 行目)。 ping スレッドは condVar1 の通知を待ちます condVar2 で通知を送信します . dataReady スプリアスおよび失われたウェイクアップから保護します (「C++ コア ガイドライン:条件変数のトラップに注意する」を参照)。卓球ゲームは counter で終了します countlimit に達する . nofication_one 呼び出し (3 行目) とカウンターはスレッドセーフであるため、クリティカル領域の外にあります。

数字は次のとおりです:

平均実行時間は 0.52 秒です。

このプレイを std::atomic_flags に移植しています C++20 の は簡単です。

std::atomic_flag

2 つの原子フラグを使用したプレイは次のとおりです。

2 つの Atomic フラグ

次のプログラムでは、条件変数の待機をアトミック フラグの待機に置き換え、条件変数の通知をアトミック フラグの設定とそれに続く通知に置き換えます。

// pingPongAtomicFlags.cpp

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

std::atomic_flag condAtomicFlag1{};
std::atomic_flag condAtomicFlag2{};

std::atomic<int> counter{};
constexpr int countlimit = 1'000'000;

void ping() {
 while(counter <= countlimit) {
 condAtomicFlag1.wait(false); // (1)
 condAtomicFlag1.clear(); // (2)

 ++counter;
 
 condAtomicFlag2.test_and_set(); // (4)
 condAtomicFlag2.notify_one(); // (3)
 }
}

void pong() {
 while(counter < countlimit) {
 condAtomicFlag2.wait(false);
 condAtomicFlag2.clear();
 
 condAtomicFlag1.test_and_set();
 condAtomicFlag1.notify_one();
 }
}

int main() {

 auto start = std::chrono::system_clock::now(); 

 condAtomicFlag1.test_and_set(); // (5)
 std::thread t1(ping);
 std::thread t2(pong);

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

 std::chrono::duration<double> dur = std::chrono::system_clock::now() - start;
 std::cout << "Duration: " << dur.count() << " seconds" << std::endl;

}

コール condAtomicFlag1.wait(false) (1) 原子フラグの値が false の場合、ブロックします。 .逆に、condAtomicFlag1 の場合は返されます。 値は true です .ブール値は一種の述語として機能するため、false に戻す必要があります。 (2)。通知 (3) が pong スレッドに送信される前に、 condAtomicFlag1 true に設定されています (4)。 condAtomicFlag1の初期設定 true まで (5) ゲームを開始します。

std::atomic_flag に感謝 ゲームが早く終了します。

平均して、ゲームには 0.32 秒かかります。

プログラムを分析すると、このプレイには 1 つのアトミック フラグで十分であることがわかります。

1 つのアトミック フラグ

アトミック フラグを 1 つ使用すると、プレイが理解しやすくなります。

// pingPongAtomicFlag.cpp

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

std::atomic_flag condAtomicFlag{};

std::atomic<int> counter{};
constexpr int countlimit = 1'000'000;

void ping() {
 while(counter <= countlimit) {
 condAtomicFlag.wait(true);
 condAtomicFlag.test_and_set();
 
 ++counter;
 
 condAtomicFlag.notify_one();
 }
}

void pong() {
 while(counter < countlimit) {
 condAtomicFlag.wait(false);
 condAtomicFlag.clear();
 condAtomicFlag.notify_one();
 }
}

int main() {

 auto start = std::chrono::system_clock::now(); 

 
 condAtomicFlag.test_and_set();
 std::thread t1(ping);
 std::thread t2(pong);

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

 std::chrono::duration<double> dur = std::chrono::system_clock::now() - start;
 std::cout << "Duration: " << dur.count() << " seconds" << std::endl;

}

この場合、ping スレッドは true でブロックされます。 しかし、ポンスレッドは false でブロックされます .パフォーマンスの観点からは、1 つまたは 2 つのアトミック フラグを使用しても違いはありません。

平均実行時間は 0.31 秒です。

この例で使用した std::atomic_flag アトミックブール値など。 std::atomic<bool> でもう一度試してみましょう .

std::atomic<bool>

読みやすさの観点から、私は std::atomic<bool>. に基づく次の C++20 実装を好みます

// pingPongAtomicBool.cpp

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

std::atomic<bool> atomicBool{};

std::atomic<int> counter{};
constexpr int countlimit = 1'000'000;

void ping() {
 while(counter <= countlimit) {
 atomicBool.wait(true);
 atomicBool.store(true);

 ++counter;
 
 atomicBool.notify_one();
 }
}

void pong() {
 while(counter < countlimit) {
 atomicBool.wait(false);
 atomicBool.store(false);
 atomicBool.notify_one();
 }
}

int main() {

 std::cout << std::boolalpha << std::endl;

 std::cout << "atomicBool.is_lock_free(): " // (1)
 << atomicBool.is_lock_free() << std::endl; 

 std::cout << std::endl;

 auto start = std::chrono::system_clock::now();

 atomicBool.store(true);
 std::thread t1(ping);
 std::thread t2(pong);

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

 std::chrono::duration<double> dur = std::chrono::system_clock::now() - start;
 std::cout << "Duration: " << dur.count() << " seconds" << std::endl;

}

std::atomic<bool> ミューテックスなどのロック機構を内部的に使用できます。想定どおり、私の Windows ランタイムはロックフリーです (1)。

平均して、実行時間は 0.38 秒です。

すべての番号

予想どおり、条件変数は最も遅い方法であり、アトミック フラグはスレッドを同期する最も速い方法です。 std::atomic<bool> のパフォーマンス その中間です。しかし、std:.atomic<bool>. std::atomic_flag i には欠点が 1 つあります。 ロックフリーの唯一の原子データ型です。

次は?

C++20 では、スレッド調整のための新しいメカニズムがいくつかあります。次回の投稿では、ラッチ、バリア、セマフォについて詳しく見ていきます。また、ピンポンをプレイすることもできます。