Performanzvergleich von Bedingungsvariablen und Atomics in C++20

Modernes C++ Rainer Grimm  –  52 Kommentare

Nach Einführen des std::atomic_flag in meinem letzten Artikel "Synchronisation mit atomaren Variablen in C++20" möchte ich nun tiefer eintauchen. Heute implementiere ich ein Ping-Pong-Spiel mit Bedingungsvariablen: std::atomic_flag und std::atomic<bool>. Los geht das Spiel!

Die zentrale Frage, die ich in diesem Artikel beantworten will, ist folgende: Auf welche Art lassen sich am schnellsten Threads in C++20 synchronisieren? Ich verwende dazu drei verschiedene Datentypen: std::condition_variable, std::atomic_flag und std::atomic<bool>.

Um vergleichbare Zahlen zu erhalten, implementiere ich ein Ping-Pong-Spiel. Ein Thread führt dazu eine ping-Funktion und ein anderer eine pong-Funktion aus. Der Einfachheit halber nenne ich den Thread, der die ping-Funktion ausführt Ping-Thread und den anderen Pong-Thread. Der Ping-Thread wartet bei dem Spiel auf die Benachrichtigung des Pong-Thread. Der Pong-Thread schickt wiederum die Benachrichtigung zurück. Das Spiel soll nach 1.000.000 Ballwechseln beendet sein. Jedes Spiel führe ich fünfmal aus, um vergleichbare Performanzzahlen zu erhalten.

Den Performanztest habe ich mit einem aktuellen Visual Studio Compiler durchgeführt, der bereits die Synchronisation mit Atomics unterstützt. Zusätzlich ist das Programm mit maximaler Optimierung (/Ox) übersetzt.

Los geht das Spiel mit einer Umsetzung in C++11.

Bedingungsvariablen

// 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;

}

Ich verwende in dem Beispiel zwei Bedingungsvariablen: condVar1 und condVar2 (Zeile 1 und 2). Der Ping-Thread wartet auf die Benachrichtigung durch convVar1 und schickt seine Benachrichtigung mit condVar2. dataReady ist der Schutz gegen spurious wakeups und lost wakeups (siehe "C++ Core Guidelines: Sei dir der Fallen von Bedingungsvariablen bewusst"). Das Ping-Pong-Spiel endet, wenn der counter countlimit erreicht. Die Aufrufe notification_one (Zeile 3) und der counter sind Thread-sicher und befinden sich daher außerhalb des kritischen Bereichs.

Hier sind die Zahlen:

Die durchschnittliche Ausführungszeit beträgt 0,52 Sekunden.

std::atomic_flag

Dieses Spiel lässt sich einfach mit dem std::atomic_flag in C++20 umsetzen. Zuerst implementiere ich das Spiel, das auf zwei std::atomic_flag basiert.

Spielvariante mit zwei atomaren Flags

Im folgenden Programm ersetze ich das Warten auf die Bedingungsvariable durch das Warten auf das atomare Flag und die Benachrichtigung durch die Bedingungsvariable durch das Setzen des atomaren Flags, auf das die Benachrichtigung unmittelbar folgt.

// 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;

}

Der Aufruf condAtomicFlag1.wait(false) (1) blockiert, falls der Wert des atomaren Flags false ist. Im Gegensatz dazu kommt der Aufruf zurück, falls das condAtomicFlag1 den Wert true besitzt. Der Wahrheitswert dient als ein Prädikat und muss daher auf false zurückgesetzt werden (2). Bevor die Benachrichtigung (3) an den Pong-Thread geschickt wird, wird condAtomicFlag1 auf true gesetzt. Das initiale Setzen von condAtomicFlag1 auf true (5) startet das Spiel

Dank std::atomic_flag ist das Spiel früher beendet:

Im Durchschnitt benötigt ein Spiel 0,32 Sekunden.

Wer das Programm genauer unter die Lupe nimmt, stellt fest, dass ein atomares Flag für das Spiel ausreichend ist.

Spielvariante mit einem atomaren Flag

Durch Verwenden nur eines atomaren Flags ist das Spiel einfacher verständlich.

// 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;

}

In diesem Fall blockiert der Ping-Thread beim Status true und der Pong-Thread beim Status false. Durch die Performanzbrille betrachtet, sind beide Varianten mit einem oder zwei atomaren Flags gleich schnell.

Die durchschnittliche Ausführungszeit beträgt 0,31 Sekunden.

In dem Beispiel habe ich das std::atomic_flag wie ein atomares Bool verwendet. Daher möchte ich noch ein std::atomic<bool> für die Implementierung des Spiels einsetzen.

std::atomic<bool>

Aus der Perspektive der Lesbarkeit betrachtet, präferiere ich die Umsetzung des Spiels in C++20 mit std::atomic<bool>.

// 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> kann intern einen Locking-Mechanismus wie ein Mutex verwenden. Wie ich es vermutet habe, trifft dies auf meiner Windows-Laufzeit nicht zu (1).

Im Durchschnitt beträgt die Ausführungszeit 0,38 Sekunden.

Alle Zahlen im Vergleich

Wie vermutet, stellen Bedingungsvariablen die langsamste und atomare Flags die schnellste Art dar, Threads zu synchronisieren. Die Performanz von std::atomic<bool> liegt dazwischen. Es gibt aber einen Nachteil von std::atomic<bool>. Nur für std::atomic_flag sichert der C++-Standard zu, dass sie Lock-frei sind.

Wie geht's weiter?

C++20 bietet weitere Mechanismen, um Threads zu koordinieren. Darunter finden sich Latches, Barriers und Semaphoren. Mit ihnen lässt sich auch ein schnelles Ping-Pong-Spiel umsetzen.