C++20: Überblick zur Concurrency

Modernes C++  –  297 Kommentare

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.