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.
![Barrieren und atomare Smart Pointer in C++20](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/4/8/1/1/8/TimelineCpp20-f8ff98fa80e1e537.png)
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.
![Barrieren und atomare Smart Pointer in C++20](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/4/8/1/1/8/barrier-2676ef7533191bec.png)
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.
![Barrieren und atomare Smart Pointer in C++20](https://heise.cloudimg.io/width/409/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/4/8/1/1/8/fullTimePartTimeWorkers-ee587b932975d1ea.png)
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_ptr
sind 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= localPtr
verwendet. Das Ergebnis ist ein undefiniertes Verhalten. Ist hingegen der Smart Pointer ein atomarer Datentyp, verbietet dies der Compiler. - Performanz: Die
std::atomic_shared_prt
undstd::atomic_weak_ptr
besitzen einen deutlichen Vorteil gegenüber den freienatomic_
*-Funktionen. Sie sind für den speziellen Anwendungsfall Multithreading konzipiert und können zum Beispiel einstd::atomic_flag
besitzen, um einen billigen Spinlock [3] zu verwenden. Auf Verdacht macht es natürlich im Gegensatz dazu keinen Sinn, in jeden allgemein einsetzbarenstd::shared_ptr
oderstd::weak_ptr
einstd::atomic_flag
zu 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_ptr
undstd::weak_ptr
fü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
![Barrieren und atomare Smart Pointer in C++20](https://heise.cloudimg.io/width/515/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/4/8/1/1/8/AtomicSinglyLinkedList-622f8489c3bc3ef4.png)
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