Barrieren und atomare Smart Pointers in C++20
Barriers sind die groĂen BrĂŒder von Latches. Sie lassen sich mehrmals verwenden. In diesem Artikel beschĂ€ftige ich mit Barrieren und atomaren Smart Pointers.
In meinem letzten Artikel [1] habe ich Latches zur Thread-Koordination in C++20 vorgestellt. Latch besitzt einen groĂen Bruder: Barrier. Diese können mehrmals verwendet werden. In diesem Artikel beschĂ€ftige ich mit Barrieren und atomaren Smart Pointers.
std::barrier
Es gibt zwei Unterschiede zwischen Latches und Barriers. Ein std::latch lĂ€sst sich nur einmal verwenden, ein std::barrier hingegen mehrmals. ZusĂ€tzlich erlaubt es ein std::barrier, eine Funktion im sogenannten Completion-Step auszufĂŒhren. Dieser ist der Zustand, wenn der ZĂ€hler den Wert null besitzt. Unmittelbar dann, wenn der ZĂ€hler den Wert null hat, wird eine aufrufbare Einheit ausgefĂŒhrt. Die Barrier erhĂ€lt ihre aufrufbare Einheit im Konstruktor. Eine aufrufbare Einheit (callable) ist eine Einheit, die sich wie eine Funktion verhĂ€lt. Dies können Funktionen, Funktionsobjekte oder Lambda-AusdrĂŒcke sein.
Der Completion-Step fĂŒhrt die folgenden Schritte aus:
- Alle Threads werden blockiert.
- Ein beliebiger Thread wird entblockt und fĂŒhrt die aufrufbare Einheit aus.
- Wenn der Completion-Step fertig ist, werden alle Threads entblockt.
Die folgende Tabelle stellt das Interface einer std::barrier bar dar.
Der Aufruf bar.arrive_and_drop() bewirkt, dass der ZÀhler um 1 in der nÀchsten Phase dekrementiert wird. Das folgende Programm fullTimePartTimeWorkers.cpp halbiert die Anzahl der Arbeiter in der zweiten Phase:
// fullTimePartTimeWorkers.cpp
#include <iostream>
#include <barrier>
#include <mutex>
#include <string>
#include <thread>
std::barrier workDone(6);
std::mutex coutMutex;
void synchronizedOut(const std::string& s) noexcept {
std::lock_guard<std::mutex> lo(coutMutex);
std::cout << s;
}
class FullTimeWorker { // (1)
public:
FullTimeWorker(std::string n): name(n) { };
void operator() () {
synchronizedOut(name + ": " + "Morning work done!\n");
workDone.arrive_and_wait(); // Wait until morning work is done (3)
synchronizedOut(name + ": " + "Afternoon work done!\n");
workDone.arrive_and_wait(); // Wait until afternoon work is done (4)
}
private:
std::string name;
};
class PartTimeWorker { // (2)
public:
PartTimeWorker(std::string n): name(n) { };
void operator() () {
synchronizedOut(name + ": " + "Morning work done!\n");
workDone.arrive_and_drop(); // Wait until morning work is done // (5)
}
private:
std::string name;
};
int main() {
std::cout << '\n';
FullTimeWorker herb(" Herb");
std::thread herbWork(herb);
FullTimeWorker scott(" Scott");
std::thread scottWork(scott);
FullTimeWorker bjarne(" Bjarne");
std::thread bjarneWork(bjarne);
PartTimeWorker andrei(" Andrei");
std::thread andreiWork(andrei);
PartTimeWorker andrew(" Andrew");
std::thread andrewWork(andrew);
PartTimeWorker david(" David");
std::thread davidWork(david);
herbWork.join();
scottWork.join();
bjarneWork.join();
andreiWork.join();
andrewWork.join();
davidWork.join();
}
Dieser Arbeitsablauf besitzt zwei Klassen von Arbeitern: Ganztagsarbeiter (1) und Halbtagsarbeiter (2). Die Halbtagsarbeiter arbeiten am Morgen und die Ganztagsarbeiter am Morgen und am Nachmittag. Konsequenterweise rufen die Ganztagsarbeiter wordDone.arrive_and_wait() (Zeilen (3) und (4)) zweimal auf. Im Gegensatz dazu rufen die Halbtagsarbeiter wordDone.arrive_and_drop() (5) genau einmal auf. Der Aufruf workDone.arrive_and_drop() bewirkt, dass die Halbtagsarbeiter die Arbeit am Nachmittag nicht ausfĂŒhren. Entsprechend besitzt der ZĂ€hler in der ersten Phase (Morgen) den Wert 6 und in der zweiten Phase (Nachmittag) den Wert 3.
Nun möchte ich ein Feature in C++20 vorstellen, das ich in meinen Artikeln zu Atomics ĂŒbersehen habe.
Atomare Smart Pointers
Der Proposal N4162 [2] fĂŒr atomare Smart Pointers bringt die UnzulĂ€nglichkeit der bisherigen Implementierung direkt auf den Punkt. Die UnzulĂ€nglichkeiten werden an den drei Punkten Konsistenz (consistency), Korrektheit (correctness) und Performanz (performance) festgemacht. Hier die Punkte kurz und knapp zusammengefasst. Die Details lassen sich im Proposal nachlesen.
- Konsistenz: Die atomaren Operationen fĂŒr den
std::shared_ptrsind die einzigen Operationen, die fĂŒr einen nichtatomaren Datentyp angeboten werden. - Korrektheit: Die Verwendung der freien atomaren Operationen ist sehr fehleranfĂ€llig, da sie auf der Disziplin der Anwender basiert. Wie schnell wird statt einem
std::atomic_store(&ptr, localPtr)ein einfachesptr= localPtrverwendet. Das Ergebnis ist ein undefiniertes Verhalten. Ist hingegen der Smart Pointer ein atomarer Datentyp, verbietet dies der Compiler. - Performanz: Die
std::atomic_shared_prtundstd::atomic_weak_ptrbesitzen einen deutlichen Vorteil gegenĂŒber den freienatomic_*-Funktionen. Sie sind fĂŒr den speziellen Anwendungsfall Multithreading konzipiert und können zum Beispiel einstd::atomic_flagbesitzen, um einen billigen Spinlock [3] zu verwenden. Auf Verdacht macht es natĂŒrlich im Gegensatz dazu keinen Sinn, in jeden allgemein einsetzbarenstd::shared_ptroderstd::weak_ptreinstd::atomic_flagzu verpacken. Das wĂ€re aber die Konsequenz, wenn beide einen Spinlock im Multithreading-Anwendungsfall verwenden wollten und es keine atomare Smart Pointers gĂ€be. Damit wĂ€restd::shared_ptrundstd::weak_ptrfĂŒr den speziellen Anwendungsfall Multithreading optimiert.
Persönlich finde ich das Korrektheitsargument mit Abstand das wichtigste. Warum? Genau darauf gibt das Proposal eine sehr schöne Antwort. Es stellte eine Thread-sichere einfach verkette Liste vor, die das EinfĂŒgen, Löschen und Finden von Elementen unterstĂŒtzt. Diese ist lock-frei mit atomaren Smart Pointers implementiert.
Eine Thread-sichere einfach verkettete Liste
Alle Modifikationen, die notwendig sind, um den Code mit einem C++11-Compiler zu ĂŒbersetzen, sind in Rot angedeutet. Die Implementierung mit expliziten, atomaren Datentypen ist deutlich einfacher und damit weniger fehleranfĂ€llig.
Das Proposal N4162 [4] schlÀgt die zwei neuen Datentypen std::atomic_shared_ptr und std::atomic_weak_ptr vor. Durch die Aufnahme dieser neuen Datentypen in den ISO-C++-Standard wurden sie zu partiellen Template-Spezialisierungen von std::atomic: std::atomic<std::shared_ptr> und std::atomic<std::weak_ptr>.
Konsequenterweise sind die atomaren Operationen auf std::shared_ptr<T> mit C++20 "deprecated".
Wie geht's weiter?
Mit C++20 lassen sich Threads kooperativ unterbrechen. In meinem nÀchsten Artikel zeige ich, was das bedeutet. ( [5])
URL dieses Artikels:
https://www.heise.de/-5041229
Links in diesem Artikel:
[1] https://heise.de/-5033716
[2] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4162.pdf
[3] https://de.wikipedia.org/wiki/Spinlock
[4] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4162.pdf
[5] mailto:rainer@grimm-jaud.de
Copyright © 2021 Heise Medien