Synchronisation mit Atomics in C++20

Synchronisation mit Atomics in C++20

Sender/Empfänger-Workflows sind für Threads weit verbreitet. In einem solchen Workflow wartet der Empfänger auf die Benachrichtigung des Senders, bevor er weiterarbeitet. Es gibt verschiedene Möglichkeiten, diese Workflows zu implementieren. Mit C++11 können Sie Bedingungsvariablen oder Promise/Future-Paare verwenden; mit C++20 können Sie Atomic verwenden.

Es gibt verschiedene Möglichkeiten, Threads zu synchronisieren. Jeder Weg hat seine Vor- und Nachteile. Daher möchte ich sie vergleichen. Ich nehme an, Sie kennen die Details zu Bedingungsvariablen oder Versprechen und Futures nicht. Daher gebe ich eine kurze Auffrischung.

Bedingungsvariablen

Eine Bedingungsvariable kann die Rolle eines Senders oder eines Empfängers erfüllen. Als Sender kann er einen oder mehrere 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 hat zwei untergeordnete Threads:t1 und t2 . Sie erhalten ihre Nutzlast prepareWork und completeWork in den Zeilen (1) und (2). Die Funktion prepareWork teilt mit, dass die Vorbereitung der Arbeiten abgeschlossen ist:condVar.notify_one() . Während die Sperre gehalten wird, wird der Thread t2 wartet auf seine Benachrichtigung:condVar.wait(lck, []{ return not myVec.empty(); }) . Der wartende Thread führt immer die gleichen Schritte aus. Wenn es aufgeweckt wird, prüft es das Prädikat, während es die Sperre hält ([]{ return not myVec.empty(); ). Wenn das Prädikat nicht gilt, legt es sich wieder schlafen. Wenn das Prädikat gilt, fährt es mit seiner Arbeit fort. Im konkreten Workflow setzt der sendende Thread die Anfangswerte in den std::vector (3), die der empfangende Thread vervollständigt (4).

Bedingungsvariablen haben viele inhärente Probleme. Beispielsweise könnte der Empfänger ohne Benachrichtigung geweckt werden oder könnte die Benachrichtigung verlieren. Das erste Problem ist als falsches Aufwachen bekannt und das zweite ist verlorenes Aufwachen. Das Prädikat schützt vor beiden Fehlern. Die Benachrichtigung würde verloren gehen, wenn der Sender seine Benachrichtigung sendet, bevor sich der Empfänger im Wartezustand befindet und kein Prädikat verwendet. Folglich wartet der Empfänger auf etwas, das nie passiert. Dies ist eine Sackgasse. Wenn Sie die Ausgabe des Programms studieren, sehen Sie, dass jeder zweite Lauf einen Deadlock verursachen würde, wenn ich kein Prädikat verwenden würde. Natürlich ist es auch möglich Bedingungsvariablen ohne Prädikat zu verwenden.

Wenn Sie die Details des Sender/Empfänger-Workflows und der Traps von Bedingungsvariablen erfahren möchten, lesen Sie meine vorherigen Beiträge „C++ Core Guidelines:Be Aware of the Traps of Condition Variables“.

Wenn Sie nur eine einmalige Benachrichtigung benötigen, wie im vorherigen Programm, sind Zusagen und Futures eine bessere Wahl als Bedingungsvariablen. Versprechen und Zukünfte können nicht Opfer falscher oder verlorener Weckrufe werden.

Versprechen und Zukünfte

Ein Promise kann einen Wert, eine Ausnahme oder eine Benachrichtigung an die zugehörige Zukunft senden. Lassen Sie mich ein Versprechen und eine Zukunft verwenden, um den vorherigen Workflow zu überarbeiten. Hier ist derselbe Arbeitsablauf mit einem Promise/Future-Paar.

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

Wenn Sie den Workflow studieren, erkennen Sie, dass die Synchronisation auf ihre wesentlichen Teile reduziert ist:prom.set_value() (1) und fut.wait() (2). Es ist weder erforderlich, Sperren oder Mutexe zu verwenden, noch ist es erforderlich, ein Prädikat zu verwenden, um sich vor falschen oder verlorenen Wakeups zu schützen. Ich überspringe den Screenshot zu diesem Durchlauf, weil es im Wesentlichen dasselbe wie im Fall des vorherigen Durchlaufs mit Bedingungsvariablen ist.

Die Verwendung von Promises und Futures hat nur einen Nachteil:Sie können nur einmal verwendet werden. Hier sind meine früheren Posts zu Versprechungen und Zukünften, oft nur Aufgaben genannt.

Wenn Sie mehr als einmal kommunizieren möchten, müssen Sie Bedingungsvariablen oder Atomic verwenden.

std::atomic_flag

std::atomic_flag in C++11 hat eine einfache Schnittstelle. Ihre Member-Funktion clear ermöglicht es Ihnen, ihren Wert auf false zu setzen, mit test_and_set auf true. Falls Sie test_and_set verwenden, erhalten Sie den alten Wert zurück. ATOMIC_FLAG_INIT ermöglicht es, den std::atomic_flag zu initialisieren bis false . std::atomic_flag hat zwei sehr interessante Eigenschaften.

std::atomic_flag ist

  • das einzige sperrfreie Atomic.
  • der Baustein für höhere Thread-Abstraktionen.

Die verbleibenden leistungsfähigeren Atome können ihre Funktionalität bereitstellen, indem sie einen Mutex verwenden. Das entspricht dem C++-Standard. Diese Atomics haben also eine Member-Funktion is_lock_free . Auf den gängigen Plattformen bekomme ich immer die Antwort true . Aber dessen sollte man sich bewusst sein. Hier finden Sie weitere Details zu den Fähigkeiten von std::atomic_flag C++11.

Jetzt springe ich direkt von C++11 zu C++20. Mit C++20, std::atomic_flag atomicFlag neue Mitgliedsfunktionen unterstützen:atomicFlag.wait( ), atomicFlag.notify_one() , und atomicFlag.notify_all() . Die Memberfunktionen notify_one oder notify_all Benachrichtigen Sie einen oder alle der wartenden atomaren Flags. atomicFlag.wait(boo) benötigt einen booleschen boo . Der Aufruf atomicFlag.wait(boo) blockiert bis zur nächsten Benachrichtigung oder falschem Aufwecken. Es prüft dann, ob der Wert atomicFlag ist gleich boo und entsperrt, wenn nicht. Der Wert boo dient als eine Art Prädikat.

Zusätzlich zu C++11 Default-Konstruktion eines std::atomic_flag setzt es in seinem false Zustand und Sie können nach dem Wert des std::atomic flag fragen über atomicFlag.test() . Mit diesem Wissen ist es ziemlich einfach, mit einem std::atomic_flag auf frühere Programme umzugestalten .

// 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, der die Arbeit vorbereitet (1), setzt den atomicFlag zu true und sendet die Benachrichtigung. Der Thread, der die Arbeit abschließt, wartet auf die Benachrichtigung. Es wird nur entsperrt, wenn atomicFlag ist gleich true .

Hier sind ein paar Durchläufe des Programms mit dem Microsoft Compiler.

Ich bin mir nicht sicher, ob ich ein Future/Promise-Paar oder ein std::atomic_flag verwenden würde für einen so einfachen Thread-Synchronisations-Workflow. Beide sind Thread-sicher und benötigen bisher keinen Schutzmechanismus. Versprechen und Versprechen sind einfacher zu verwenden, aber std::atomic_flag ist wohl schneller. Ich bin mir nur sicher, dass ich möglichst keine Bedingungsvariable verwenden würde.

Was kommt als nächstes?

Wenn Sie einen komplizierteren Thread-Synchronisierungs-Workflow wie ein Ping/Pong-Spiel erstellen, ist ein Promise/Future-Paar keine Option. Sie müssen Bedingungsvariablen oder Atomic für mehrere Synchronisierungen verwenden. In meinem nächsten Beitrag implementiere ich ein Ping/Pong-Spiel mit Bedingungsvariablen und einem std::atomic_flag und ihre Leistung messen.

Kurze Pause

Ich mache eine kurze Weihnachtspause und veröffentliche den nächsten Beitrag am 11. Januar. Falls Sie mehr über C++20 wissen möchten, lesen Sie mein neues Buch bei Leanpub zu C++20.