Performanzvergleich von Bedingungsvariablen und Atomics in C++20
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
Nach Einführen des std::atomic_flag
in meinem letzten Artikel "Synchronisation mit atomaren Variablen in C++20 [1]" 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>
.
![Performanzvergleich von Bedingungsvariablen und Atomics in C++20](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/3/5/3/0/8/TimelineCpp20CoreLanguage-a773dbedd8e16060.png)
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.
![Performanzvergleich von Bedingungsvariablen und Atomics in C++20](https://heise.cloudimg.io/width/674/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/3/5/3/0/8/windowsCompiler-d14d2b62984f7a69.png)
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 [2]"). 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:
![Performanzvergleich von Bedingungsvariablen und Atomics in C++20](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/3/5/3/0/8/pingPongConditionVariable-3fed163a9a049821.png)
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:
![Performanzvergleich von Bedingungsvariablen und Atomics in C++20](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/3/5/3/0/8/pingPongAtomicFlags-1771db31c9edbf1c.png)
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.
![Performanzvergleich von Bedingungsvariablen und Atomics in C++20](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/3/5/3/0/8/pingPongAtomicFlag-c23f8c32d2b8f93f.png)
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).
![Performanzvergleich von Bedingungsvariablen und Atomics in C++20](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/3/5/3/0/8/pingPongAtomicBool-0085078fa716ed16.png)
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.
![Performanzvergleich von Bedingungsvariablen und Atomics in C++20](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/3/5/3/0/8/PerformanceComparisonGer-4f65d444ba233951.png)
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. ( [3])
URL dieses Artikels:
https://www.heise.de/-5019237
Links in diesem Artikel:
[1] https://www.heise.de/blog/Synchronisation-mit-atomaren-Variablen-in-C-20-4993560.html
[2] https://www.heise.de/blog/C-Core-Guidelines-Sei-dir-der-Fallen-von-Bedingungsvariablen-bewusst-4063822.html
[3] mailto:rainer@grimm-jaud.de
Copyright © 2021 Heise Medien