Barrieren und atomare Smart Pointers in C++20

Modernes C++ Rainer Grimm  –  15 Kommentare

In meinem letzten Artikel 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:

  1. Alle Threads werden blockiert.
  2. Ein beliebiger Thread wird entblockt und führt die aufrufbare Einheit aus.
  3. 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 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 einfaches ptr= 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 und std::atomic_weak_ptr besitzen einen deutlichen Vorteil gegenüber den freien atomic_*-Funktionen. Sie sind für den speziellen Anwendungsfall Multithreading konzipiert und können zum Beispiel ein std::atomic_flag besitzen, um einen billigen Spinlock zu verwenden. Auf Verdacht macht es natürlich im Gegensatz dazu keinen Sinn, in jeden allgemein einsetzbaren std::shared_ptr oder std::weak_ptr ein std::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äre std::shared_ptr und std::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

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 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.