C++11 beschäftigt sich als erster C++-Standard mit Concurrency – ein zweischneidiges Schwert: Einerseits winkt hohe Performance, andererseits lauern viele Gefahren. Wer sie vermeiden will, hält sich an die Concurrency-Regeln der C++-Core-Guidelines.
Der zentrale Baustein für Concurrency in modernem C++ ist ein Thread. Konsequenterweise beschäftigen sich die Guidelines mit Multithreading. Die erste Regel stellt eine Behauptung auf, die zum Widerspruch reizt: “CP.1: Assume that your code will run as part of a multi-threaded program” [1]. Sie besagt also, dass Code typischerweise gleichzeitig ausgeführt wird.
Zur gleichen Zeit
Die Code-Ausführung erfolgt immer gleichzeitig? Das gilt in der Regel nur für Code in Bibliotheken, nicht aber für den in Applikationen. Die Erfahrung zeigt, dass Bibliotheken deutlich häufiger als geplant wiederverwendet werden. Damit steigt die Wahrscheinlichkeit, dass sie dabei in ein Umfeld geraten, in dem sie nebenläufig (concurrent) laufen. Für den Concurrent-Einsatz ausgelegter Code ist automatisch auch für den allgemeinen Einsatz optimiert – und genau hier kommt die Gleichzeitigkeit ins Spiel.
Listing 1 zeigt ein kleines Code-Beispiel, das die Regel illustriert. Dabei erfüllt die Funktion »cached_computation()« zuverlässig ihre Aufgabe, sofern sie in einem Single-threaded-Programm ausgeführt wird. Das gilt aber nicht in einer Umgebung, in der mehrere Threads zum Einsatz kommen. In diesem Fall tritt das Problem auf, dass die statischen Variablen »cached_x« (Zeile 3) und »cached_result« (Zeile 4) gleichzeitig gelesen und verändert werden könnten.
Listing 1
Beispiel einer Cache-Funktion
double cached_computation(double x)
{
static double cached_x = 0.0;
static double cached_result = COMPUTATION_OF_ZERO;
double result;
if (cached_x == x)
return cached_result;
result = computation(x);
cached_x = x;
cached_result = result;
return result;
}
Der C++11-Standard erweitert statische Variablen mit Blockgültigkeit zwar um eine Multithreading-Semantik, die aber nur für ihre Initialisierung gilt: Statische Variablen mit Blockgültigkeit werden Thread-sicher initialisiert. Das ist sehr praktisch, hilft aber im konkreten Fall nicht weiter. Rufen mehrere Threads gleichzeitig die Funktion »cached_computation()« auf, kommt es zu einem sogenannten Data Race. Das ist ein zentraler Begriff in Multithreading-Programmen in C++.
Data Race
Bei einem Data Race handelt es sich um eine Konstellation, in der zumindest zwei Threads auf eine geteilte Variable gleichzeitig zugreifen. Wenigstens ein Thread versucht dabei, sie zu modifizieren. Kommt es in einem Programm zu einem Data Race, resultiert daraus ein undefiniertes Verhalten (undefined behavior). Undefiniertes Verhalten bedeutet, dass jede weitere Analyse des Programms hinfällig ist und das Programm alle möglichen Ergebnisse produzieren kann.
Ein entscheidender Aspekt lässt sich aus der Definition eines Data Race ableiten: Die notwendige Bedingung dafür ist ein veränderlicher und geteilter Zustand. Im Umkehrschluss heißt das: Falls eine der beiden Bedingungen nicht erfüllt ist, gibt es auch kein Data Race. Abbildung 1 bringt das auf den Punkt.
Was lässt sich sich tun, um das Data Race zu beseitigen? Die statischen Variablen »cached_x« (Zeile 3) und »cached_result« (Zeile 4) als konstant zu erklären, führt nicht zum Ziel. Damit bleibt nur noch eine Option offen: Beide Variablen sollen nicht geteilt werden. Dazu bieten sich drei Möglichkeiten an:
- Strategie 1: Mit einem Lock den ganzen kritischen Bereich schützen.
- Strategie 2: Der Aufrufer der Funktion »cached_computation« verwendet ein Lock.
- Strategie 3: Beide statischen Variablen zu Thread-lokalen Variablen erklären.
Thread-lokal sichert zu, dass jeder Thread seine eigene Kopie der Variablen »cached_x« und »cached_result« besitzt. Analog zu einer statischen Variablen, die an die Lebenszeit des Main-Threads gebunden ist, ist die Lebenszeit einer Thread-lokalen Variablen an die Lebenszeit ihres Threads gebunden. Listing 2 stellt die drei Strategien genauer vor.
Listing 2
Data Races vermeiden: drei Strategien
// --- Strategie 1 ---
std::mutex m;
double cached_computation(double x){
static double cached_x = 0.0;
static double cached_result = COMPUTATION_OF_ZERO;
double result;
{
std::lock_guard<std::mutex> lck(m);
if (cached_x == x) return cached_result;
result = computation(x);
cached_x = x;
cached_result = result;
}
return result;
}
// --- Strategie 2 ---
std::mutex cachedComputationMutex;
{
std::lock_guard<std::mutex> lck(cachedComputationMutex);
auto cached = cached_computation(3.33);
}
// --- Strategie 3 ---
double cached_computation(double x){
thread_local double cached_x = 0.0;
thread_local double cached_result = COMPUTATION_OF_ZERO;
double result;
if (cached_x == x) return cached_result;
result = computation(x);
cached_x = x;
cached_result = result;
return result;
}
Zuerst einmal gilt, dass statische Variablen Thread-sicher initialisiert werden. Daher muss man deren Initialisierung in dem Programmschnipsel nicht schützen.
Strategie 1 besitzt einen großen kritischen Bereich, in dem beide statische Variablen in einem atomaren Schritt auf ihren neuen Wert gesetzt werden. Dieser kritische Bereich sichert zu, dass ihn zu einem Zeitpunkt maximal ein Thread betreten darf. Im Gegensatz dazu ist Strategie 2 weniger subtil: Die ganze Funktion wird gelockt. Das hat den gravierenden Nachteil, den Anwender der Funktion für die Synchronisation des Funktionsaufrufs verantwortlich zu machen – ein sehr schlechter Entwurf, denn die Funktion sollte aus Anwendersicht Thread-sicher sein. Erklärt man gemäß Strategie 3 beide Variablen als Thread-lokal, löst sich die Synchronisation in Wohlgefallen auf, denn jeder Thread besitzt eine eigene Kopie der Variablen »cached_x« und »cached_result«.
Am Ende hängt die Frage, welche Strategie man wählt, von deren Leistungscharakteristik und den Anwendern ab. Einerseits muss die Performance ausreichen, andererseits müssen Programmierer die Funktion anwenden und weiter pflegen können.
Teile nicht
Das wohl wichtigste Prinzip zur Concurrency nennt die Regel 3: “CP.3: Minimize explicit sharing of writable data” [2]. Dieser Grundsatz verdient eine genauere Analyse.
Das Programmieren mit Threads sollte zwei Prinzipien verfolgen: Verwende konstante Daten, und arbeite so lokal wie möglich. Bleiben die Daten konstant, kann es keine Veränderungen und damit kein Data Race geben. Kommen die Daten nur lokal zum Einsatz, gibt es kein Teilen von Daten, und damit entfällt die Möglichkeit des gleichzeitigen Lesens oder Schreibens. Tony van Eerd hat das in seinem Vortrag zu Concurrency auf der Cppcon [3] sehr plakativ beschrieben: “Forget what you learned in Kindergarten (i.e. stop Sharing)”.
Das ist allerdings im Multithreading nicht so einfach, wie es scheint. Daher muss man die Aussage “verwende konstante Daten” relativieren. Sind Daten konstant, lassen sie sich per definitionem nicht verändern. Es gilt aber noch, eine weitere Herausforderung zu meistern: Zuerst muss man die Daten Thread-sicher initialisieren. C++ bietet dazu mehrere Möglichkeiten an.
So kann man die konstante Variable wie im folgenden Code-Schnipsel initialisieren, bevor die Threads starten (Listing 3). Dieser einfache Weg wird jedoch oft übersehen.
Listing 3
Konstante Variable
const int val = 2011;
thread t1([&val]{ [...] };
thread t2([&val]{ [...] };
Eine weitere Möglichkeit bieten konstante Ausdrücke, denn diese werden zur Compile-Zeit initialisiert: »constexpr auto doub = 5.1;«. Damit sind die Daten zur Laufzeit des Programms automatisch Thread-sicher.
Die Funktion »std::call_once« lässt sich in Kombination mit »std::once_flag« verwenden. In diesem Fall kann man die wichtigen Intialisierungsaufgaben direkt in die Funktion »onlyOnceFunction« verschieben. Die C++-Laufzeit sichert zu, dass die Funktion genau einmal erfolgreich ausgeführt wird. Listing 4 stellt diese Möglichkeit exemplarisch vor.
Listing 4
Thread-sicher initialisieren
<C>std::once_flag<C>
std::once_flag onceFlag;
void do_once(){
std::call_once(onceFlag, []{ std::cout << "Important initialization" << std::endl; });
}
std::thread t1(do_once);
std::thread t2(do_once);
std::thread t3(do_once);
std::thread t4(do_once);
Zu guter Letzt gibt es die Möglichkeit, statische Variablen mit Blockgültigkeit zu nutzen, denn die C++11-Laufzeit garantiert, dass diese wie in Listing 5 Thread-sicher initialisiert werden. Blockgültigkeit bedeutet, dass die statischen Variablen einen expliziten Scope besitzen, der durch geschweifte Klammern (Block) ausgezeichnet ist.
Listing 5
Blockgültigkeit
void func() {
[...]
static int val 2011;
[...]
}
thread t5{ func() };
thread t6{ func() };
Ende der Märchenstunde
Die Regel 8 lautet “Don’t try to use volatile for synchronization” [4] und räumt mit einem weitverbreiteten Irrtum zum Einsatz von »volatile« auf.
Falls man in Java oder C# eine atomare Variable benötigt, kommt »volatile« zum Einsatz – das ist einfach. In C++ kommt dazu ebenfalls »volatile« zum Einsatz, könnte man meinen. Falsch gedacht: »volatile« besitzt in C++ keine Multithreading-Semantik, atomare Variablen heißen hier »std::atomic«.
Jetzt tut Aufklärung not: Welche Bedeutung besitzt »volatile« in C++? Hier steht es für besondere Objekte, auf denen optimierte Lese- oder Schreib-Operationen nicht erlaubt sind. In der Embedded-Programmierung dient »volatile« typischerweise dazu, Speicherbereiche auszuzeichnen, die sich unabhängig vom regulären Programmverlauf modifizieren lassen. Dazu gehören etwa Speicherbereiche, die ein externes Device repräsentieren (Memory-Mapped I/O). Da diese sich unabhängig vom regulären Programmfluss verändern können, werden ihre Werte dank »volatile« direkt in den Hauptspeicher geschrieben. Es gibt also kein optimiertes Speichern in Zwischenspeichern (Caches).
Ausblick
Concurrency stellt eine der wohl anspruchsvollsten Herausforderungen dar, denen sich ein Programmierer stellen kann. Daher verwundert es nicht, dass die C++-Core-Guidelines einige Regeln mehr zum sicheren Einsatz von Threads, Locks und dem Teilen von Daten zu bieten haben. Genau diese Best Practices greift der nächste Artikel dieser Reihe auf. (jcb/jlu)







