Synchronisation mit atomaren Variablen in C++20

Modernes C++ Rainer Grimm  –  2 Kommentare

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.