C++20 wird atomare Smart Pointer erhalten. Ganz genau wird es ein std::atomic_shared_ptr und ein std::atomic_weak_ptr sein. Warum eigentlich, std::shared_ptr und std::weak_ptr sind doch schon thread-sicher. Jein. Da muss ich ein wenig ausholen.
Bevor ich aushole, noch eine kleine Bemerkung. Diese soll nur unterstreichen, wie wichtig es ist, dass einerseits std::shared_ptr eine klar definierte Multithreading Semantik besitzen und dass du andererseits diese Semantik kennst und richtig anwendest. Aus der Multithreading-Perspektive betrachtet, sind std::shared_ptr die Datenstruktur, die du nach Problemen in Multithrading Programmen schreien. Denn sie sind geteilte, veränderliche Daten. Damit sind sie ideale Kandidaten für kritische Wettläufe und somit von undefiniertem Programmverhalten. Andererseits gilt natürlich die einfache Richtlinie in modernem C++: Fasse Speicher nicht direkt an. Das heißt, Smart Pointer sollen in Multithreading Programmen eingesetzt werden.
Halb thread-sicher
In meinen C++ Schulungen taucht immer wieder die Frage auf: Sind die Smart Pointer thread-sicher? Meine eindeutige Antwort ist jein. Warum? Ein std::shared_ptr besteht aus einem Kontrollblock und seiner Ressource. Der Kontrollblock ist thread-sicher, die Ressource jedoch nicht. Das heißt, dass das Ändern des Referenzzählers eine atomare Operation ist und dass die Ressource genau einmal freigegeben wird. Mehr Garantie gibt der std::shared_ptr nicht.
Die Zusicherung, die ein std::shared_ptr liefert, fasst Boost schön zusammen.
- A shared_ptr instance can be "read" (accessed using only const operations) simultaneously by multiple threads.
- Different shared_ptr instances can be "written to" (accessed using mutable operations such as operator= or reset) simultaneosly by multiple threads (even when these instances are copies, and share the same reference count underneath.)
Dies lässt sich einfach an zwei Beispielen verdeutlichen. Wird eine std::shared_ptr in einen Thread kopiert, ist alles gut.
std::shared_ptr<int> ptr = std::make_shared<int>(2011);
for (auto i= 0; i<10; i++){
std::thread([ptr]{ (1)
std::shared_ptr<int> localPtr(ptr); (2)
localPtr= std::make_shared<int>(2014); (3)
}).detach();
}
Zuerst zu (2). Durch die Copy-Konstruktion des std::shared_ptr localPtr wird nur der Kontrollblock modifiziert. Dies ist thread-sicher. (3) ist da schon ein wenig spannender. Das localPtr (3) auf einen neuen std::shared_ptr gesetzt wird, ist aus der Multithreading Perspektive betrachtet kein Problem: Die Lambda-Funktion (1) bindet ptr per Kopie. Damit finden die Modifikationen auf localPtr auf einer Kopie statt.
Das gilt aber nicht, wenn der Thread den std::shared_ptr per Referenz annimmt.
std::shared_ptr<int> ptr = std::make_shared<int>(2011);
for (auto i= 0; i<10; i++){
std::thread([&ptr]{ (1)
ptr= std::make_shared<int>(2014); (2)
}).detach();
}
Nun bindet die Lambda-Funktion den std::shared_ptr ptr per Referenz (1). Damit stellt die Zuweisung (2) einen kritischen Wettlauf um die Ressource dar und das Programm ist undefiniert.
Zugegeben, das war einige Gehirngymnastik. std::shared_ptr verlangen besondere Aufmerksamkeit in Mulitthreading Umgebung. Daher besitzen sie eine Sonderstellung. Sie sind die einzigen nicht-atomaren Datentypen, für die es atomare Operationen gibt.
Atomare Operationen für std::shared_ptr
Die bekannten freien atomaren Operationen wie load, store, compare und exchange sind für std::shared_ptr spezialisiert. Natürlich lässt sich in der explicit Variante das Speichermodell direkt spezifizieren. Hier sind alle freien atomaren Operationen für std::shared_ptr.
std::atomic_is_lock_free(std::shared_ptr)
std::atomic_load(std::shared_ptr)
std::atomic_load_explicit(std::shared_ptr)
std::atomic_store(std::shared_ptr)
std::atomic_store_explicit(std::shared_ptr)
std::atomic_exchange(std::shared_ptr)
std::atomic_exchange_explicit(std::shared_ptr)
std::atomic_compare_exchange_weak(std::shared_ptr)
std::atomic_compare_exchange_strong(std::shared_ptr)
std::atomic_compare_exchange_weak_explicit(std::shared_ptr)
std::atomic_compare_exchange_strong_explicit(std::shared_ptr)
Die genaue Übersicht liefert cppreference.com. Jetzt ist es ein leichtes, einen per Referenz gebundenen std::shared_ptr thread-sicher zu modifizieren.
std::shared_ptr<int> ptr = std::make_shared<int>(2011);
for (auto i =0;i<10;i++){
std::thread([&ptr]{
auto localPtr= std::make_shared<int>(2014);
std::atomic_store(&ptr, localPtr); (1)
}).detach();
}
Jetzt ist das Update des std::shared_ptr ptr (1) thread-sicher. Ist jetzt alles gut? Nein. Jetzt sind wir endlich bei den neuen atomaren Smart Pointern mit C++20 gelandet.
Atomare Smart Pointer
Der Proposal N4162 für atomare Smart Pointer 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 nicht explizit durch einen atomaren Typ angeboten werden.
Korrektheit: Die Verwendung der freien atomaren Operationen ist sehr fehleranfällig, da sie auf der Disziplin der Anwender basiert. Wie schnell wird (wie im letzten Beispiel (1)) statt einem std::atomic_store(&ptr, localPtr) ein einfaches ptr= localPtr verwendet. Das Ergebnis ist ein kritischer Wettlauf und damit undefiniertes Verhalten. Ist hingegen der Smart Pointer ein atomarer Datentyp, unterbindet dies der Compiler.
Performanz: Die std::atomic_shared_ptr 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. (Die Details zu std::atomic_flag und einem Spinlock basierend auf std::atomic_flag kannst du im Artikel Das Atomare Flag nachlesen). Auf Verdacht macht es natürlich im Gegensatz dazu wenig Sinn, in jeden allgemein einsetzbaren std::shared_ptr oder std::weak_ptr ein std::atomic_flag zu verpacken. Dies wäre aber die Konsequenz, wenn beide einen Spinlock im Multithreading Anwendungsfall verwenden wollen und es keine atomare Smart Pointer gäbe. Damit wäre std::shared_ptr und std::weak_ptr für den speziellen Anwendungsfall optimiert.
Für mich ist das Korrektheit Argument mit Abstand das wichtigste. Warum? Genau darauf gibt das Proposal eine sehr schöne Antwort. Das Proposal stellte eine thread-sichere einfach verkette Liste vor, die das Einfügen, Löschen und Finden von Elementen unterstützt. Diese ist lockfrei mit atomaren Smart Pointern implementiert.
Eine thread-sichere einfach verkettete Liste
template<typename T> class concurrent_stack {
struct Node { T t; shared_ptr<Node> next; };
atomic_shared_ptr<Node> head;
// in C++11: remove “atomic_” and remember to use the special
// functions every time you touch the variable
concurrent_stack( concurrent_stack &) =delete;
void operator=(concurrent_stack&) =delete;
public:
concurrent_stack() =default;
~concurrent_stack() =default;
class reference {
shared_ptr<Node> p;
public:
reference(shared_ptr<Node> p_) : p{p_} { }
T& operator* () { return p->t; }
T* operator->() { return &p->t; }
};
auto find( T t ) const {
auto p = head.load(); // in C++11: atomic_load(&head)
while( p && p->t != t )
p = p->next;
return reference(move(p));
}
auto front() const {
return reference(head); // in C++11: atomic_load(&head)
}
void push_front( T t ) {
auto p = make_shared<Node>();
p->t = t;
p->next = head; // in C++11: atomic_load(&head)
while( !head.compare_exchange_weak(p->next, p) ){ }
// in C++11: atomic_compare_exchange_weak(&head, &p->next, p);
}
void pop_front() {
auto p = head.load();
while( p && !head.compare_exchange_weak(p, p->next) ){ }
// in C++11: atomic_compare_exchange_weak(&head, &p, p->next);
}
};
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. C++20 erlaubt es einfach nicht, eine nicht-atomare Datenoperation auf einem std::atomic_shared_ptr anzuwenden.
Wie geht's weiter?
Mit Tasks in der Form von Promisen und Futuren führte C++11 ein neues Multithreading Konzept in C++ ein. Trotz ihres großen Mehrwertes besitzen sie eine große Unzulänglichkeit. Futures in C++11 können nicht komponiert werden. Erweiterte Futures in C++20 räumen mit dieser Unzulänglichkeit auf. Wie? Das zeigt der nächste Artikel.
Go to Leanpub/cpplibrary "What every professional C++ programmer should know about the C++ standard library". Hole dir dein E-Book. Unterstütze meinen Blog.
Weiterlesen...