Über unsMediaKontaktImpressum
Rainer Grimm 28. März 2017

Gleichzeitigkeit in C++17 und C++20

Im ersten Teil dieser Serie zu Gleichzeitigkeit in modernem C++ standen die Parallelen Features von C++17 und C++20 im Fokus. Dies waren die parallelen Algorithmen der Standard Template Library und Task-Blöcke. In diesem Artikel geht unsere Reise weiter in die C++20-Zukunft. Unsere Reise zu den erhofften Features startet bei atomaren Smart Pointern, geht weiter mit den Erweiterten Futures, beschäftigt sich mit Latches und Barrieren und auch Coroutinen und schließt mit Transaction Memory ab.

Atomare Smart Pointer mit C++20

Zuerst einmal der Überblick für die zeitliche Einordnung der vorgestellten Features, auf die wir mit C++20 hoffen können.

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. Jetzt ist ein kleiner Einschub notwendig.

Dieser Einschub soll nur unterstreichen, wie wichtig es ist, dass einerseits std::shared_ptr eine klar definierte Multithreading-Semantik besitzen und dass andererseits der Programmierer diese Semantik kennt und richtig einsetzt. Aus der Multithreading-Perspektive betrachtet, ist std::shared_ptr die Datenstruktur, die nach Problemen in Multithreading-Programmen schreit. Denn sie sind einerseits geteilte, veränderliche Daten. Damit sind sie ideale Kandidaten für kritische Wettläufe [1] und damit 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.

Nun aber zum eigentlichen Problem von Smart Pointern in C++11: Smart Pointer sind halb thread-sicher. Ein Smart Pointer wie 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. Daher besitzen sie eine Sonderstellung. Sie sind die einzigen nicht-atomaren Datentypen, für die es atomare Operationen in C++11 gibt [2]. Jetzt ist es doch ein leichtes, einen std::shared_ptr thread-sicher zu modifizieren. Leider nein, denn bei jeder Operation auf einem Smart Pointer ist es notwendig, atomare Operationen zu verwenden. Der Proposal N4162 [3] 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 (engl.: consistency), Korrektheit (engl.: correctness) und Performanz (engl.: performance) festgemacht. In diesem Artikel sind 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 atomaren 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 des Anwenders basiert. Ist hingegen der Smart Pointer ein atomarer Datentyp, unterbindet der Compiler deren falschen Einsatz.
  • 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 optimiert.

Ähnlich wie Smart Pointer waren Futures auch eine der großen Neuerungen in C++11.

C++20 - std::future-Erweiterungen

Tasks in der Form von Promises und Futures besitzen einen ambivalenten Ruf. Zum einen sind sie deutlich leichter und weniger fehleranfällig zu verwenden als Threads oder Bedingungsvariablen, zum anderen besitzen sie eine große Unzulänglichkeit. Sie können nicht komponiert werden. Mit dieser Unzulänglichkeit räumt C++20 auf.

Bevor erweiterte Futures Thema dieses Artikel sein werden, noch ein paar Worte zu den Vorteilen von Tasks gegenüber Threads im Schnelldurchlauf.

Die Vorteile von Tasks

Der entscheidende Vorteil von Tasks gegenüber Threads ist es, dass sich der Programmierer bei Tasks nur Gedanken darum machen muss, was berechnet wird und nicht, wie bei Threads, wie es berechnet wird. Der Programmierer gibt dem System eine zu berechnende Aufgabe und das System sorgt dafür, dass diese Aufgabe möglichst intelligent von der C++-Laufzeit ausgeführt wird. Das kann bedeuten, dass die Aufgabe im gleichen Prozess ausgeführt oder in einem separaten Thread gestartet wird. Das kann aber auch bedeuten, dass sich ein Thread automatisch eine Aufgabe von einem anderen Thread schnappt, falls er gerade nichts zu tun hat. Unter der Decke wartet ein Threadpool, der die Aufgaben annimmt und intelligent ausführt. Wenn das kein Fortschritt ist...

Die Details zu Tasks gibt es hier [4]. Nun aber zur Zukunft von Tasks in C++.

std::future fut wurde um neue Methoden erweitert. So kann fut.is_ready abfragen, ob ein gemeinsamer Zustand vorliegt oder fut.then Futures komponieren. Genau dies zeigt Listing 1.

Listing 1: Ein Future nach dem anderen

#include 
using namespace std;
int main() {

  future f1 = async([]() { return 123; });
  future f2 = f1.then([](future f) {
    return to_string(f.get());      // here .get() won’t block
  });

  auto myResult= f2.get();

}

Es gibt einen feinen Unterschied zwischen dem to_string(f.get())-Aufruf (Zeile 7) und dem f2.get()-Aufruf in Zeile 10. Wie bereits in dem Code angedeutet, ist der erste Aufruf nicht blockierend oder auch asynchron, hingegen ist der zweite Aufruf blockierend oder auch synchron. Der f2.get()-Aufruf wartet, bis das Ergebnis der verketteten Futures zur Verfügung steht. Diese Aussage trifft nicht nur auf Future-Kompositionen wie f1.then(...).then(...).then(...).then(...), sondern auf alle Kompositionen von erweiterten Futures zu. Der finale f2.get()-Aufruf ist blockierend.

C++20 erhält 2 weitere Funktionen, die das Erzeugen von besonderen Futuren erlaubt. Diese sind std::when_all und std::when_any. Beide Funkionen geben einen Future zurück, der automatisch startet. Im Falle von when_any startet der Future, wenn einer seiner Vorgänger seine Arbeit vollzogen hat. Dies zeigt Listing 2.

Listing 2: std::when_any


vector> v{ .... };
auto future_any = when_any(v.begin(), v.end());

when_any_result>> result= future_any.get();

future& ready_future = result.futures[result.index];

auto myResult= ready_future.get();

future_any ist der Future, der fertig ist, wenn einer seiner Futures fertig ist. future_any.get() in Zeile 4 gibt den Future result zurück. Mittels der Abfrage result.futures[result.index] (Zeile 6) steht der ready_future zur Verfügung und das Ergebnis der Berechnung kann dank ready_future.get() abgefragt werden.

Wie unschwer zu vermuten, startet der von when_all zurückgegebene Future genau dann, wenn alle seine Vorgänger ihre Arbeit vollzogen haben (Listing 3).

Listing 3: std::when_all


shared_future shared_future1 = async([] { return intResult(125); });
future future2 = async([]() { return stringResult("hi"); });

future, future>> all_f = when_all(shared_future1, future2);

future result = all_f.then([(future,
                                               future>> f){ return doWork(f.get()); });

auto myResult= result.get();

Der Future all_f (Zeile 4) komponiert die beiden Futures shared_future1 (Zeile 1) und future2 (Zeile 2). Der Future result in Zeile 6 wird dann ausgeführt, wenn beide Future fertig sind. In diesem Fall wird auf dem Future all_f get() in Zeile 7 ausgeführt. Das Ergebnis steht in dem Future result zur Verfügung und kann in Zeile 9 abgefragt werden.

Während die C++20-Futures die C++11-Futures nur erweitern und damit komponierbar machen, sind Latches und Barriers in C++20 vollkommen neu.

C++20: Latches und Barriers

Latches und Barriers sind einfache Thread-Synchronisierungsmechanismen, die es erlauben, mehrere Threads warten zu lassen, bis der Zähler den Wert 0 besitzt. Latches und Barriers soll es in drei "Geschmacksrichtungen" in C++20 geben: std::latch, std::barrier und std::flex_barrier.

Zuerst einmal stellen sich zwei Fragen:

  • Worin unterscheiden sich die drei Mechanismen um Threads zu synchronisieren? Während ein std::latch nur einmal verwendet werden kann, können std::barrier und std::flex_barrier mehrmals verwendet. std::flex_barrier bietet mehr Flexibilität als std::barrier. Ein std::flex_barrier erlaubt es, eine Aktion zu hinterlegen, die ausgeführt wird, wenn der Zähler den Wert 0 besitzt.
  • Was können Latches und Barriers, was Koordinationsmechanismen in C++11 und C++14 wie Futures und Bedingungsvariablen in Kombination mit Locks nicht konnten? Latches und Barriers können nicht mehr. Sie sind aber wesentlich einfacher in der Anwendung und performanter, da sie oft intern lockfreie Mechanismen verwenden.

std::latch

std::latch ist ein Abwärtszähler. Sein Wert wird im Konstruktor gesetzt. Ein Thread thread kann mit der Methode thread.count_down_and_wait den Zähler um 1 heruntersetzen und warten, bis dieser 0 erreicht oder er kann mit thread.count_down nur den Zähler um 1 heruntersetzen. Neben diesen beiden Methoden besitzt der std::latch die Methode thread.is_ready, um zu testen, ob der Zähler 0 ist und die Methode thread.wait. Mit thread.wait wartet (blockiert) er, bis der Zähler den Wert 0 besitzt. Da std::latch nicht erlaubt, den Zähler zu inkrementieren oder auch zurücksetzen, lässt er sich nur einmal verwenden. Listing 4 zeigt ihn in der Anwendung.

Listing 4: std::latch


void DoWork(threadpool* pool){
  latch completion_latch(NTASKS);
  for (int i = 0; i < NTASKS; ++i) {
    pool->add_task([&]{
      // perform work
      ...
      completion_latch.count_down();
    }));
  }
  // Block until work is done
  completion_latch.wait();
}

Der std::latch completion_latch wird im Konstruktor auf NTASKS (Zeile 2) gesetzt. Der Threadpool führt die NTASKS-Aufgaben (Zeile 4 - 8) aus. Am Ende jeder Aufgabe (Zeile 7) wird der Zähler dekrementiert. Zeile 11 stellt die Barriere für den Erzeuger-Thread dar, denn hier muss er warten, bis der Zähler den Wert 0 erreicht hat.

std::barrier und std::flex_barrier

Ein std::barrier ist einem std::latch sehr ähnlich. Der feine Unterschied ist aber, dass ein std::barrier mehrmals verwendet werden kann und der Zähler auf den alten Wert zurückgesetzt wird. std::barrier besitzt zwei interessante Methoden: std::arrive_and_wait und std::arrive_and_drop. Während std::arrive_and_wait am Synchronisationspunkt wartet, entfernt sich std::arrive_and_drop aus dem Synchronisationsmechanismus.

Der std::flex_barrier besitzt im Gegensatz zum std::barrier einen zusätzlichen Konstruktor. Dieser kann mit einer aufrufbaren Einheit parametrisiert werden. Diese aufrufbare Einheit wird genau dann ausgeführt, wenn der Zähler den Wert 0 erreicht. Eine aufrufbare Einheit ist alles, was sich wie eine Funktion anfühlt. Das kann eine Funktion, ein Funktionsobject oder eine Lambda-Funktion sein. Diese aufrufbare Einheit muss einen Wert zurückgeben, der den Wert des Zähler neu setzt, wenn dieser den Wert 0 erreicht. Ein Wert von -1 bedeutet, dass in der nächsten Iteration der Zähler unverändert bleibt. Kleinere Werte wie -1 sind nicht zulässig.

Eine Besonderheit besitzt std::flex_barrier gegenüber dem std::barrier und std::latch: Er kann als einziger der drei seinen Zähler erhöhen.

Mit Couroutinen erhält C++ Funktionen, die ihren Ablauf unterbrechen und wieder aufnehmen können.

Coroutinen

Was in diesem Artikel als neues Konzept in C++20 verkauft wird, ist tatsächlich ein alter Hut. Der Begriff "Couroutine" stammt von Melvin Conway [5], der ihn 1963 in einer Veröffentlichung über Compilerbau verwendete. Donald Knuth [6] bezeichnet Prozeduren als Spezialfall von Coroutinen. Manchmal dauert es einfach ein bisschen länger...

Mit den neuen Schlüsselwörtern co_await und co_yield verallgemeinert C++20 den Ablauf einer Funktion um zwei neue Aspekte. Dank co_await expression ist es möglich, die Ausführung des Ausdrucks expression zu unterbrechen und später wieder aufzunehmen. Wird co_await expression in einer Funktion func verwendet, ist ein Aufruf der Form auto getResult= func() nicht zwangsläufig blockierend, wenn das Ergebnis der Funktion noch nicht zur Verfügung steht. Ein ressourcenintensives Blockieren lässt sich in ein ressourcenschonendes Warten umformulieren.

co_yield expression erlaubt es, eine Generator-Funktion zu schreiben. Diese Generator-Funktion liefert auf jede Anfrage den nächsten Wert. Eine Generator-Funktion verhält sich wie ein Datenstrom, aus dem sukzessive die Werte abgefragt werden können. Dabei kann der Datenstrom auch unendlich sein. Damit sind wir mitten in der Bedarfsauswertung (lazy evaluation) mit C++.

co_yield

Ein einfaches Beispiel bringt den Unterschied einer gewöhnlichen Funktion und einer Generator-Funkion auf den Punkt.

Die Funktion getNumbers in Listing 5 gibt alle ganzen Zahlen von begin bis end um inc inkrementiert zurück. Dabei muss begin kleiner als end und inc positiv sein.

Listing 5: Ein gieriger Zahlenerzeuger


#include 
#include 

std::vector getNumbers(int begin, int end, int inc= 1){
  
  std::vector numbers;
  for (int i= begin; i < end; i += inc){
    numbers.push_back(i);
  }
  
  return numbers;
  
}

int main(){

  std::cout << std::endl;

  auto numbers= getNumbers(-10, 11);
  
  for (auto n: numbers) std::cout << n << " ";
  
  std::cout << "\n\n";

  for (auto n: getNumbers(0,101,5)) std::cout << n << " ";

  std::cout << "\n\n";

}

Mit getNumbers wird in dem Beispiel das Rad natürlich neu erfunden, denn für diesen Job gibt es seit C++11 std::iota. Nur zur Vervollständigung die Ausgabe des Programms in Abb.2, das nicht umsonst greedyGenerator heißt.

Das Entscheidende an dem Programm ist natürlich nicht das Ergebnis. Zwei Punkte sind besonders wichtig. Zum einen wird der Vektor numbers in Zeile 6 immer vollständig gefüllt. Das trifft auch zu, wenn nur die ersten fünf Elemente eines 1.000-elementigen Vektors nachgefragt werden. Zum anderen lässt sich der getNumbers direkt in Listing 6 in eine Generator-Funktion umschreiben.

Listing 6: Ein lazy Zahlenerzeuger


#include 
#include 

generator generatorForNumbers(int begin, int inc= 1){
  
  for (int i= begin;; i += inc){
    co_yield i;
  }
  
}

int main(){

  std::cout << std::endl;

  auto numbers= generatorForNumbers(-10);
  
  for (auto n: numbers) std::cout << n << " ";
  
  std::cout << "\n\n";

  for (auto n: generatorForNumbers(0,5)) std::cout << n << " ";

  std::cout << "\n\n";

}

Während die Funktion getNumbers in Listing 1 einen std::vector<int> zurückgibt, gibt die Coroutine getGeneratorNumbers in Listing 6 einen Generator zurück. Der Generatoren numbers in Zeile 16 oder getForNumbers(0,5) in Zeile 22 geben auf Anfrage eine neue Zahl zurück. Diese Anfrage wird durch die Range-based for-Schleife in Zeile 18 und 22 angestoßen. Dabei stößt die Anfrage die Coroutine an, gibt den aktuellen Wert i mittels co_yield i (Zeile 7) zurück und pausiert anschließend. Wird der nächste Wert angefragt, setzt die Coroutine genau an dieser Stelle ihre Arbeit fort. Der Ausdruck getForNumbers(0,5) in Zeile 22 mag befremdlich wirken. Dadurch wird ein Generator an Ort und Stelle instanziiert.

Die Besonderheit in Listing 6 ist sehr erwähnenswert. Die Coroutine generatorForNumbers in Zeile 4 erzeugt einen unendlichen Datenstrom, denn die for-Schleife in Zeile 6 besitzt keine Endbedingung. Alles kein Problem, solange nur endlich viele Werte von dem Generator wie in Zeile 18 angefordert werden. Das gilt natürlich nicht für die Zeile 22. Hier kommt keine Endbedingung zum Einsatz.

co_await

In welchen Anwendungsfällen werden Coroutinen eingesetzt? Coroutinen sind ein natürliche Art, Event-getriebene Applikationen zu schreiben. Das können Algorithmen, Simulationen, Spiele, GUIs oder auch Server sein. Gerade an einem Server lässt sich schön verdeutlichen, welche Vorteile Coroutinen besitzen. Listing 7 stellt einen einfachen sequentiellen Server vor, der jede seiner Anfragen auf Port 443 in dem gleichen Thread beantwortet.

Listing 7: Ein blockierender, sequentieller Server


Acceptor acceptor{443};
while (true){
  Socket socket= acceptor.accept();              // blocking
  auto request= socket.read();                   // blocking
  auto response= handleRequest(request);    
  socket.write(response);                        // blocking  
}
Die blocking-Kommentare bringen es deutlich auf den Punkt. Sowohl die Annahme des Clientsockets (Zeile 3), als auch das Lesen der Clientanfrage (Zeile 4) und die Beantwortung der Clientanfrage (Zeile 6) sind blockierende Aktionen. Das ressourcenintensive Blockieren lässt sich mit co_await einfach in ein ressourcenschonendes Warten in Listing 8 umformulieren. Listing 8: Ein wartender, sequentieller Server

Acceptor acceptor{443};
while (true){
  Socket socket= co_await acceptor.accept();   // waiting        
  auto request= co_await socket.read();        // waiting      
  auto response= handleRequest(request);     
  co_await socket.write(responste);            // waiting     
}
Mit C++20 nimmt C++ – wie viele moderne Programmiersprachen – die Idee der Transaktion aus der Datenbanktheorie an und wendet sie unter dem Name "Transactional Memory" für Multithreading-Applikationen an.

C++: Transactional Memory

Doch was sind die Vorteile von Transactional Memory? Zum einen verhindern Transaktionen kritische Wettläufe und Verklemmungen, zum anderen können sie komponiert werden.

ACID ohne D

Was bedeutet Atomicity, Consistency und Isolation für einen atomaren Block wie in Listing 9, der aus mehreren Anweisungen besteht? Listing 9: Ein atomare Block

atomic{
  statement1;
  statement2;
  statement3;
}
  • Atomicity: Die Anweisungen des Blocks werden entweder alle oder gar nicht ausgeführt.   
  • Consistency: Das System ist immer in einem konsistenten Zustand. Entweder besitzen alle Werte am Ende der Transaktion die entsprechenden Werte oder kein Wert wurde geändert.  
  • Isolation: Jede Transaktion läuft in vollkommener Isolation von allen anderen Transaktionen ab.
  • Durability, oder auch dass die Aktion eines atomaren Blocks gespeichert wird, trifft auf Programmiersprachen nicht zu.
Wie werden die Garantien zugesichert? Eine Transaktion merkt sich ihren Anfangszustand. Dann wird die Transaktion ohne Synchronisation ausgeführt. Tritt ein Konflikt während der Ausführung der Transaktion auf, wird die Transaktion abgebrochen und auf ihren Anfangszustand gesetzt. Dieser Rollback führt dazu, dass die Transaktion nochmals ausgeführt wird. Gelten die Anfangsbedingungen selbst am Ende der Transaktion noch, wird sie veröffentlicht. Eine Transaktion ist in diesem Sinne ein spekulative Aktion, die nur dann veröffentlicht wird, wenn ihre Anfangsbedingungen noch gelten. Bisher wurde in diesem Artikel von einer Transaktion gesprochen. C++20 wird zwei Variationen anbieten: Synchronized-Blöcke und Atomic-Blöcke. Beide können ineinander verschachtelt werden. Genau genommen sind Synchronized-Blöcke keine atomaren Blöcke, da sie transaction-unsafe Code erlauben. Das sind zum Beispiel Aktionen wie die Ausgabe an die Konsole, die nicht vollständig rückgängig gemacht werden können.

Synchronized-Blöcke

Synchronized-Blöcke verhalten sich, wie wenn diese durch ein einziges, globales Lock synchronisiert werden. Das heißt, dass alle Synchronized-Blöcke einer totalen Ordnung folgen. Somit stehen alle Änderungen am Ende eines Synchronized-Blocks dem nächsten Synchronized-Block zu Verfügung. Da Synchronized-Blöcke durch ein einziges, globales Lock synchronisiert werden, können sie, falls sie nicht mit anderen Synchronisationsmechanismen verwendet werden, keine Verklemmung (eng. Deadlock) verursachen. Während ein klassisches Lock eine einzelne Speicherstelle schützt, schützt das einzige, globale Lock in Synchronized-Blöcken das ganze Programm. Daher ist das Beispiel in Listing 10 wohldefiniert: Listing 10: Thread-sicher mit Synchronized-Blöcken

#include 
#include 
#include 

int i= 0;

void increment(){
  synchronized{ 
    std::cout << ++i << " ,";
  }
}

int main(){
  
  std::cout << std::endl;
    
  std::vector vecSyn(10);
  for(auto& thr: vecSyn)
    thr = std::thread([]{ for(int n = 0; n < 10; ++n) increment(); });
  for(auto& thr: vecSyn) thr.join();
  
  std::cout << "\n\n";
  
}
Auch wenn i (Zeile 5) und std::cout (Zeile 9) zwei globale Variablen sind, finden die Zugriffe auf beide Variablen in einer totalen Ordnung statt. Dies sichert das Schlüsselwort synchronized in Zeile 8 zu. Die Ausgabe des Programms ist natürlich nicht sehr spannend. Die Werte für die Zahl i werden aufsteigend, durch ein Komma getrennt, ausgegeben. Der Vollständigkeit halber folgt die Ausgabe in Abb.3. Der aktuelle GCC 6.3 Compiler konnte das Programm mit Hilfe des -fconcepts-Flags bereits übersetzen. Wie sieht es mit kritischen Wettläufen aus? Die sind natürlich bei Synchronized-Blöcken möglich, da jede in einem Sychronized-Block verwendete Variable auch außerhalb eines Synchronized-Blocks verwendet werden kann. Ein kleine Modifikation des Programms in Listing 11 macht dies möglich. Listing 11: Ein kritischer Wettlauf mit Synchronized-Blöcken

#include 
#include 
#include 
#include 

using namespace std::chrono_literals;

int i= 0;

void increment(){
  synchronized{ 
    std::cout << ++i << " ,";
    std::this_thread::sleep_for(1ns);
  }
}

int main(){
  
  std::cout << std::endl;
    
  std::vector vecSyn(10);
  std::vector vecUnsyn(10);
    
  for(auto& thr: vecSyn)
    thr = std::thread([]{ for(int n = 0; n < 10; ++n) increment(); });
  for(auto& thr: vecUnsyn)
    thr = std::thread([]{ for(int n = 0; n < 10; ++n) std::cout << ++i << " ,"; });
    
  for(auto& thr: vecSyn) thr.join();
  for(auto& thr: vecUnsyn) thr.join();
  
  std::cout << "\n\n";
  
}

Ein kurzes Schlafen von einer Nanosekunde in Zeile 13 reicht bereits aus, um einen kritischen Wettlauf beobachten zu können. Während die Threads des Vektors vecSyn (Zeile 25) mit Hilfe eines Synchronized-Blocks auf die globalen Variablen i und std::cout zugreifen, agieren die Threads des Vektors vecUnsyn (Zeile 27) ohne Synchronisationsmechanismus. Die Ausgabe des Programms bringt es deutlich auf den Punkt. Gleich mehrmals lässt sich der unsynchronisierte Zugriff in Abb.4 beobachten.

Die Unregelmäßigkeiten sind in der Ausgabe rot hervorgehoben. Dies sind die Stellen, wo std::cout von mindestens zwei Threads gleichzeitig verwendet wird. Da der C++-Standard zusichert, dass jeder einzelne Buchstabe atomar ausgegeben wird, ist das nur ein optisches Problem. Viel schlimmer ist aber, dass die Variable i gleichzeitig von mindestens zwei Threads geschrieben wird. Ein klassischer kritischer Wettlauf. Damit ist das Programmverhalten undefiniert.

Atomic-Blöcke

Atomic-Blöcke wird es in den drei Variationen atomic_noexcept, atomic_commit und atomic_cancel geben. Die drei Anhängsel _noexcept, _commit und _cancel legen fest, wie ein Atomic-Block auf Ausnahmen zu reagieren hat.

  • atomic_noexcept: Falls eine Ausnahme auftritt, wird std::abort aufgerufen und damit das Programm beendet.
  • atomic_cancel: Im Standardfall wird std::abort aufgerufen. Dies gilt aber nicht, wenn eine transaction-safe-Ausnahme auftrat, die für das Beenden einer Transaktion verwendet wird. In diesem Fall wird die Transaktion beendet, auf ihren Anfangszustand gesetzt und die Ausnahme geworfen.
  • atomic_commit: Die Transaktion wird veröffentlicht und die Ausnahme geworfen.

Während in Synchronized-Blöcken transaction-unsafe-Code ausgeführt werden kann, ist dies in einem Atomic-Block nicht erlaubt. Doch was ist transaction-safe- bzw. transaction-unsafe-Code.

Eine Funktion kann als transaction_safe deklariert werden oder das transaction_unsafe-Attribut verwenden. Dieses kurze Listing stellt die Syntax genauer vor:

Listing 12: transaction_safe versus transaction_unsafe 

int transactionSafeFunction() transaction_safe;

[[transaction_unsafe]] int transactionUnsafeFunction();

transaction_safe ist Bestandteil des Typs einer Funktion. Doch was ist eine transaction_safe-Funktion? Eine transaction_safe-Funktion ist laut Proposal eine Funktion, die eine transaction_safe-Definition besitzt. Das heißt im Wesentlichen, dass sie keine volatile-Variablen und keine transaction_unsafe-Anweisungen enthalten darf.

Die Assemblersprache für modernes Multithreading

Der Abstraktionssprung von den bestehenden zu den neuen Multithreading-Features, die in diesem Artikel vorgestellten wurden, ist riesig. Damit werden atomare Variablen, Threads, Mutexe und Locks auf die Rolle reduziert, die sie langfristig einnehmen sollten: eine Multithreading-Assemblersprache für die Implementierer von portablen Bibliotheken und Frameworks. Für den Anwendungsentwickler stellen die bestehenden Multithreading-Features in C++11/C++14 eine viel zu große Herausforderung dar. Mit C++17 und C++20 stehen aber die richtige Abstraktionen für den Anwendungsentwickler vor der Tür.

Quellen
  1. Wikipedia: Kritischer Wettlauf
  2. Atomic operations library
  3. Atomic Smart Pointers, rev. 1: Proposal n4162
  4. Tasks
  5. Wikipedia: Melvin Conway

Autor

Rainer Grimm

Rainer Grimm ist Trainer und Coach für C++ und Python. Seine Bücher "C++11 für Programmierer", "C++", "C++ Standardbibliothek" und "The C++ Standard Library" sind bei O'Reilly und Leanpub erschienen.
>> Weiterlesen
Kommentare (0)

Neuen Kommentar schreiben