C++20: Ăśberblick zur Concurrency
Im abschließenden Überblicksartikel zu C++20 geht es um die Concurrency-Features im nächsten C++-Standard.
- Rainer Grimm
Mit diesem Artikel schließe ich meinen Überblick zu C++20 ab. Heute geht es um die Concurrency-Features im nächsten C++ Standard.
C++20 besitzt einige Verbesserungen rund um Concurrency.
std::atomic_ref<T>
Das Klassen-Template std::atomic_ref
bietet atomare Operationen auf das referenzierte, nichtatomare Objekt an. Gleichzeitiges Lesen und Schreiben des referenzierten Objekts ist damit kein Data Race. Die Lebenszeit des referenzierten Objekts muss natürlich länger als die Lebenszeit des atomic_ref
-Objekts sein. Der Zugriff auf Unterobjekte des referenzierten Objekts ist nicht Thread-sicher.
Entsprechend zu std::atomic
lässt sich std::atomic_ref
spezialisieren und bietet auch die Spezialisierung fĂĽr benutzerdefinierte Datentypen an:
struct Counters {
int a;
int b;
};
Counter counter;
std::atomic_ref<Counters> cnt(counter);
std::atomic<std::shared_ptr<T>> und std::atomic<std::weak_ptr<T>>
std::shared_ptr
ist der einzige nichtatomare Datentyp, auf den atomare Operationen angewandt werden können. Zuerst möchte ich diese Ausnahme begründen. Das C++-Komitee sah es als Notwendigkeit an, das Instanzen von std::shared_ptr
minimale atomare Zusicherungen in Multithreading-Programmen anbieten sollten. Was ist nun diese minimale Garantie, die ein std::shared_ptr
anbietet? Der Kontrollblock eines std::shared_ptr
ist Thread-sicher. Das heißt, dass das Inkrementieren und das Dekrementieren des Referenzzählers eine atomare Operation ist. Das heißt zusätzlich, dass die Ressource genau einmal gelöscht wird.
Die Zusicherungen eines std::shared_ptr
bringt Boost exakt auf den Punkt:
- A
shared_ptr
instance can be "read" (accessed using only constant operations) simultaneously by multiple threads. - Different
shared_ptr
instances can be "written to" (accessed using mutable operations such as operator= or reset) simultaneously by multiple threads (even when these instances are copies, and share the same reference count underneath).
Mit C++20 erhalten wir zwei neue Smart Pointer: std::atomic<std::shared_ptr<T>>
und std::atomic<std::weak_ptr<T>>
.
Atomare Gleitkommazahlen
Zusätzlich zu atomaren Ganzzahlen in C++11 lassen sich mit C++20 atomare Gleitkommazahlen erzeugen. Dies ist sehr angenehm, wenn du eine Gleitkommazahl hast, die gleichzeitig von mehreren Threads inkrementiert wird. Mit einer atomaren Gleitkommazahl muss diese nicht mehr geschützt werden.
Warten mit atomaren Variablen
std::atomic_flag
ist ein einfacher atomarer Wahrheitswert. Er besitzt einen clear-
und einen set
-Zustand. Der Einfachheit halber bezeichne ich den clear
-Zustand als false
und den set
-Zustand als true
. Mit der clear
-Method ist es möglich, die Variable auf false
zu setzen. Die test_and_set
-Method erlaubt es dir hingegen, den Zustand auf true
zu setzen. Zusätzlich erhältst du noch den alten Wert. Das std::atomic_flag
besitzt keine Methode, um den aktuellen Wert abzufragen. Das ändert sich mit C++20. Mit C++20 besitzt std::atomic_flag
eine test
-Methode.
DarĂĽber hinaus unterstĂĽtzt std::atomic_flag
mit C++20 Thread-Synchronisation mittels der Methoden notify_one
, notify_all
und wait
. DarĂĽber hinaus ist das Benachrichtigen und Warten mit C++20 fĂĽr alle teilweise und partielle Spezialisierungen von std::atomic
(Wahrheitswerte, Ganzzahlen, Gleitkommazahlen und Zeiger) und fĂĽr std::atomic_ref
möglich.
Semaphoren, Latches und Barriers
Alle drei neue Datentypen helfen, Threads zu synchronisieren.
Semaphoren
Semaphoren werden typischerweise dazu verwendet, um den gleichzeitigen Zugriff auf eine geteilte Ressource zu koordinieren. Eine zählende Semaphore (counting semaphore) wie in C++20 ist eine spezielle Semaphore, die einen Zähler besitzt, der größer als null ist. Der Zähler wird im Konstruktor der Semaphore gesetzt. Das Anfordern der Semaphore reduziert den Zähler und die Freigabe der Semaphore erhöht den Zähler. Wenn ein Thread versucht, die Semaphore anzufordern, die den Wert null besitzt, wird dieser Thread geblockt. Dieser Thread bleibt so lange geblockt, bis ein anderer Thread die Semaphore wieder freigibt und damit den Zähler erhöht.
Latches und Barries
Latches und Barries sind einfache Synchronisationsmechanismen, die es erlauben, Threads zu blockieren bis ein Zähler den Wert null besitzt. Worin unterscheiden sich die beiden Mechanismen? Du kannst einen std::latch
nur einmal verwenden, ein std::barrier
lässt sich jedoch mehrmals verwenden. Daher ist der Einsatzbereich eines std::latch
dann gegeben, wenn eine Aufgabe genau einmal koordiniert werden muss; mit einem std::barrier
lassen sich hingegen wiederholende Aufgaben mehrerer Threads koordinieren. Zusätzlich erlaubt es std::barrier
, den Zähler in jeder Iteration anzupassen. Das folgende einfache Codebeispiel ist aus dem Proposal N4204:
void DoWork(threadpool* pool) {
latch completion_latch(NTASKS); // (1)
for (int i = 0; i < NTASKS; ++i) {
pool->add_task([&] { // (2)
// perform work
...
completion_latch.count_down();// (4)
})}; // (3)
}
// Block until work is done
completion_latch.wait(); // (5)
}
Der std::latch completion_latch
wird in seinem Konstruktor auf NTASK
(Zeile 1) gesetzt. Der Threadpool fĂĽhrt NTASKS
(Zeile 2 - 3) Arbeitspakete aus. Am Ende jedes Arbeitspakets (Zeile 4) wird der Zähler dekrementiert. Zeile 5 stellt die Barriere für die Threads, die die Funktion DoWork
ausfĂĽhren, dar. Dieser Thread wird geblockt, bis alle Arbeitspakete fertig sind.
std::jthread
std::jthread
steht für einen Joining-Thread. Zusätzlich zum std::thread
(C++11) joint std::jthread
(C++20) automatisch und er kann auch unterbrochen werden.
Dies ist das ĂĽberraschende Verhalten des std::thread
. Wenn ein std::thread
noch joinable ist, wird in seinem Destruktor std::terminate
aufgerufen. Ein Thread thr
ist joinable, wenn auf ihm weder thr.join()
noch thr.detach()
aufgerufen wurde:
// threadJoinable.cpp
#include <iostream>
#include <thread>
int main(){
std::cout << std::endl;
std::cout << std::boolalpha;
std::thread thr{[]{ std::cout << "Joinable std::thread" << std::endl; }};
std::cout << "thr.joinable(): " << thr.joinable() << std::endl;
std::cout << std::endl;
}
Wenn ich das Programm ausfĂĽhre, beendet es sich mit einer Ausnahme.
Beide AusfĂĽhrungen des Programms beenden sich mit einer Ausnahme. Im zweiten Fall besitzt der Thread thr
noch genĂĽgend Zeit um seine Ausgabe "Joinable std::thread" darzustellen.
Im nächsten Beispiel ersetze ich lediglich die Headerdatei <thread>
mit der Headerdatei "jthread.hpp" und verwende dadurch automatisch std::jthread
aus dem C++20-Standard:
// jthreadJoinable.cpp
#include <iostream>
#include "jthread.hpp"
int main(){
std::cout << std::endl;
std::cout << std::boolalpha;
std::jthread thr{[]{ std::cout << "Joinable std::jthread" << std::endl; }};
std::cout << "thr.joinable(): " << thr.joinable() << std::endl;
std::cout << std::endl;
}
Jetzt joint den Thread thr
dann automatisch in seinem Destruktor, wenn er noch joinable ist.
Wie geht's weiter?
In den letzten vier Artikeln habe ich einen Überblick zu den neuen Featuren in C++20 gegeben. Nach diesem Überblick will ich nun die Details vorstellen. Mein nächster Artikel beschäftigt sich mit Concepts.