Synchronisation mit atomaren Variablen in C++20
Sender/Empfänger-Arbeitsabläufe sind typisch für Threads. In solch einem Arbeitsablauf wartet der Empfänger auf die Benachrichtigung des Senders, bevor er seine Arbeit fortsetzt. Es gibt einige Möglichkeiten, diesen Arbeitsablauf umzusetzen. Mit C++11 bieten sich Bedingungsvariablen oder Promise/Future-Paare an, mit C++20 atomare Variablen.
- Rainer Grimm
Sender/Empfänger-Arbeitsabläufe sind typisch für Threads. In solch einem Arbeitsablauf wartet der Empfänger auf die Benachrichtigung des Senders, bevor er seine Arbeit fortsetzt. Es gibt einige Möglichkeiten, diesen Arbeitsablauf umzusetzen. Mit C++11 bieten sich Bedingungsvariablen oder Promise/Future-Paare an, mit C++20 atomare Variablen.
Es gibt mehrere Möglichkeiten, Threads zu synchronisieren, und jede besitzt ihre Vor- und Nachteile. Daher möchte ich die verschiedene Möglichkeiten gegenüberstellen. Wem die Details zur Bedingungsvariablen und Promises und Futures bekannt sind, der kann die zwei nächsten Abschnitte überspringen. Falls nicht, folgt ein kleiner Auffrischer.
Bedingungsvariablen
Eine Bedingungsvariable kann sowohl die Rolle es Senders als auch die des Empfängers annehmen. Als Sender kann sie eine oder alle Empfänger benachrichtigen.
// 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;
}
Das Programm besitzt zwei Threads t1
und t2
. Sie erhalten ihre Arbeitspakete prepareWork
und completeWork
in Zeile (1) und (3). Die Funktion prepareWork
schickt eine Benachrichtigung, wenn sie mit ihrer Arbeitsvorbereitung fertig ist: condVar.notify_one()
. Während t2
auf die Benachrichtigung wartet, hält er das Lock: condVar.wait(lck, []{ return not myVec.empty(); })
. Der wartende Thread führt immer die gleichen Schritte aus. Wenn er aufgeweckt wird, prüft er das Prädikat, während er das Lock hält ([]{ return not myVec.empty();
). Falls das Prädikat nicht true
ergibt, legt er sich wieder schlafen. Wenn das Prädikat true
ergibt, setzte er seine Arbeit fort. In dem konkreten Arbeitsablauf initialisiert der Sender den std::vector
(3), während der Empfänger die Arbeit fertigstellt (4).
Bedingungsvariablen habe viele inhärente Probleme. Zum Beispiel kann der Empfänger aufwachen, obwohl keine Benachrichtigung geschickt wurde, oder die Benachrichtigung kann verloren gehen. Das erste Phänomen nennt sich "spurious wakeup" und das zweite "lost wakeup". Das Prädikat ist der Schutz gegen beide Phänomene. Die Benachrichtigung würde verloren gehen, wenn der Sender die Benachrichtigung schickt, bevor der Empfänger im Wartezustand ist und kein Prädikat verwendet. Konsequenterweise wartet in diesem Fall der Empfänger auf ein Ereignis, das nicht auftritt. Dies ist eine Deadlock. Die Ausgabe des Programms zeigt, dass jede zweite Ausführung zum einem Deadlock geführt hätte, wenn ich kein Prädikat eingesetzt hätte. Natürlich ist es möglich, Bedingungsvariablen ohne Prädikat zu verwenden.
Wer mehr zu den Details zu dem Sender/Empfänger-Arbeitsauflauf und den Gefahren mit Bedingungsvariablen wissen möchte, sei auf meinen Artikel: "C++ Core Guidelines: Sei dir der Fallen von Bedingungsvariablen bewusst" verwiesen
Wenn lediglich eine einmalige Benachrichtigung wie in dem vorherigen Programm benötigt wird, sind Promise und Futures Bedingungsvariablen vorzuziehen. Promise und Future können keine Opfer von spurious oder lost wakeups werden.
Promise und Futures
Ein Promise kann einen Wert, eine Ausnahme oder eine Benachrichtigung an den assoziierten Future schicken. Daher werde ich das vorherige Programm auf Promise und Futures umstellen:
// 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;
}
Beim genaueren Blick auf den Programmfluss fällt auf, dass die Synchronisation auf ihre wesentlichen Komponenten reduziert ist: prom.set_value()
(1) und fut.wait()
(2). Weder ist es notwendig, Locks oder Mutexe einzusetzen noch ein Prädikat zum Schutz den spurious und lost wakeups zu verwenden. Die Ausgabe des Programms ignoriere ich, da sie sich von der vorherigen Ausgabe nicht unterscheidet.
Promise und Futures besitzen aber einen Nachteil: Sie lassen sich nur einmal verwenden. Hier sind meine bestehenden Artikel zu Promisen und Futures.
Um mehr als einmal zu kommunizieren, mĂĽssen Bedingungsvariablen oder atomare Variablen eingesetzt werden.
std::atomic_flag
std::atomic_flag
in C++11 besitzt ein einfaches Interface. Seine Funktion clear
erlaubt es, seinen Wert auf false
zu setzen. Dank der Funktion test_and_set
ist es möglich, ihn wieder auf true
zu setzen. Die Funktion test_and_set
gibt dabei den alten Wert zurĂĽck. Dank ATOMIC_FLAG_INIT
kann std::atomic_flag
auf false
initialisiert werden. std::atomic_flag
besitzt zwei sehr interessante Eigenschaften.
std::atomic_flag
ist
- die einzige lock-freie atomare Variable.
- der Baustein für höhere Thread-Abstraktionen.
Die anderen atomaren Variablen können ihre Funktionalität anbieten, indem sie intern einen Mutex verwenden. Dies entspricht dem C++-Standard. Daher besitzen diese atomaren Variablen eine Funktion is_lock_free
. Auf den populären Plattformen erhält man in der Regel true
. Hier sind noch ein paar Hintergrundinformationen zu std::atomic_flag.
Jetzt springe ich direkt von C++11 nach C++20. Mit C++20 bietet std::atomic_flag
neue Funktionen an: atomicFlag.wait()
, atomicFlag.notify_one()
und atomicFlag.notify_all()
. Die Funktionen notify_one
oder notify_all
benachrichtigen einen oder alle wartetenden Threads. atomicFlag.wait(boo)
benötigt eine Wahrheitswert boo
. Der Aufruf atomicFlag.wait(boo)
blockiert bis zur nächsten Benachrichtigung oder spurious wakup. Dann prüft er, ob der Wert des atomaren Flags den Wert boo
besitzt. Falls ja, blockiert der Aufruf weiter. Der Wert von boo
dient als eine Art Prädikat.
Zusätzlich zu C++11 erhält eine std::atomic_flag
den Wert false
, wenn er default-konstruiert wird. Darüber hinaus lässt sich sein Wert mit der Funktion atomicFlag.test()
abfragen. Mit diesem Wissen ist es relativ einfach, das vorherige Programm auf std::atomic_flag
umzustellen.
// 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;
}
Der Thread t1
(1), der die Arbeit vorbereitet, setzt atomicFlag
auf true
und schickt dann seine Benachrichtigung. Der Thread, der die Arbeit vollendet, wartet auf die Benachrichtigung (2) und wird freigegeben, wenn atomicFlag
den Wert true
besitzt.
Hier sind ein paar AusfĂĽhrungen des Programms mit dem Microsoft Compiler.
Ich bin mir nicht sicher, ob ich eine einfache Thread-Synchronisation mit einem Promise/Future-Paar oder einem std::atomic_flag
umsetzen wĂĽrde. Beide sind per Design Thread-sicher und verlangen keine Schutzmechanismen. Promise und Futures sind zwar einfacher zu verwenden, aber std::atomic_flag
ist wohl schneller. Ich bin mir nur sicher, dass ich Bedingungsvariablen vermeide, wenn es möglich ist.
Wie geht's weiter?
Wenn es gilt, einen deutlich anspruchsvolleren Arbeitsablauf wie ein Ping-Pong-Spiel mit 1.000.000 Ballwechsel umzusetzen, sind Futures und Promise keine Option. In meinem nächsten Artikel werde ich ein Ping-Pong-Spiel mit Bedingungsvariablen und atomaren Variablen implementieren und mir die Performanz genauer anschauen.
Ein kurze Pause
In den nächsten zwei Wochen lege ich eine kleine Weihnachtspause ein. Mein nächster Artikel wird am 11. Januar erscheinen. Für mehr Informationen zu C++20 möchte ich mein neues Buch auf LeanPub zu C++20 empfehlen. ()