C++ Core Guidelines: Mehr Regeln zur Concurrency und zur Parallelität

Die Regeln der C++ Core Guidelines geben die notwendige Hilfe an die Hand, um korrekte Multithreading-Programme zu schreiben. Die in diesem Artikel werden sich mit Themen wie Data Races, dem Teilen von Daten, Tasks und dem berühmt-berüchtigten Schlüsselwort volatile beschäftigen.

In Pocket speichern vorlesen Druckansicht 6 Kommentare lesen
Lesezeit: 6 Min.
Von
  • Rainer Grimm

Das Schreiben von Multithreading-Programmen ist sehr anspruchsvoll. Das gilt vor allem, wenn sie korrekt sein sollen. Die Regeln der C++ Core Guidelines geben die notwendige Hilfe an die Hand, um korrekte Programme zu schreiben. Die Regeln in diesem Artikel werden sich mit Themen wie Data Races, dem Teilen von Daten, Tasks und dem berühmt-berüchtigten Schlüsselwort volatile beschäftigen.

Genau um diese vier Regeln geht es heute.

Lass mich direkt mit der ersten Regel beginnen.

CP.2: Avoid data races

Im letzten Artikel dieser Serie habe ich bereits den Begriff Data Race definiert. Daher kann ich mich heute kurz halten. Ein Data Race ist ein gleichzeitiges Lesen und Schreiben von Daten. Das Ergebnis ist undefiniertes Verhalten. Die C++ Core Guidelines bieten dazu ein typisches Beispiel an: eine statische Variable:

int get_id() {
static int id = 1;
return id++;
}

Was läuft schief? Zum Beispiel ist es möglich, dass Thread A und Thread B denselben Wert k für id lesen. Danach schreiben der Thread A und der Thread B den Wert für k + 1 zurück. Damit gibt es den Identifier k + 1 zweimal.

Das nächste Beispiel ist deutlich überraschender – ein kleiner switch-Block:

unsigned val;

if (val < 5) {
switch (val) {
case 0: // ...
case 1: // ...
case 2: // ...
case 3: // ...
case 4: // ...
}
}

Der Compiler setzt typischerweise einen switch-Block mit einer Sprungtabelle um. Diese kann die folgende Form besitzen:

if (val < 5){
// (1)
functions[val]();
}

Im diesem Falle steht der Aufruf functions[val]() für die Funktionalität des switch-Blocks, falls val den Wert 3 besitzt. Nun kann es natürlich passieren, dass ein anderer Thread B zum Zuge kommt, wenn sich der ursprüngliche Thread A an der Stelle (1) befindet. Dieser andere Thread B ändert den Wert val so, dass er sich außerhalb des gültigen Bereichs der if-Anweisung befindet. Dies stellt natürlich undefiniertes Verhalten dar.

CP.3: Minimize explicit sharing of writable data

Dies ist eine unmittelbar einleuchtende und sehr wichtige Regel. Falls du Daten teilst, sollten diese konstant sein. Nun gilt es nur noch die Herausforderung zu lösen, dass die geteilten Daten thread-sicher initialisiert werden müssen. C++11 bieten ein paar Möglichkeiten an, dies zu gewährleisten.

  • Initialisiere die Daten, bevor du einen Thread startest. Dieser einfache Weg ist natürlich unabhängig vom C++11-Standard.
const int val = 2011;
thread t1([&val]{ .... };
thread t2([&val]{ .... };
  • Verwende konstante Ausdrücke, denn diese werden zur Compilezeit initialisiert.
constexpr auto doub = 5.1;
  • Verwende die Funktion std::call_once in Kombination mit dem std:once_flag. Dann können die wichtigen Intialisierungsaufgaben direkt in die Funktion onlyOnceFunction verschoben werden. Die C++-Laufzeit sichert zu, dass die Funktion genau einmal erfolgreich durchgeführt wird.
std::once_flag onceFlag;
void do_once(){
std::call_once(onceFlag, []{ std::cout << "Important initialisation" << std::endl; });
}
std::thread t1(do_once);
std::thread t2(do_once);
std::thread t3(do_once);
std::thread t4(do_once);
  • Verwende statische Variablen mit Blockgültigkeit, denn die C++11-Laufzeit garantiert, dass diese thread-sicher initialisiert werden.
void func(){
....
static int val 2011;
....
}
thread t5{ func() };
thread t6{ func() };

CP.4: Think in terms of tasks, rather than threads

Zuerst einmal, was ist ein Task? Ein Task ist ein Begriff in C++11, der für zwei Komponenten steht: einen Promise und einen Future. Promise gibt es in drei Variationen in C++11: std::async, std::packaged_task und std::promise. Ich habe bereits einige Artikel zu Tasks verfasst.

Einem Thread, einem std::packaged_task und einem std::promise ist gemein, dass die sie Low-level-Werkzeuge sind. Daher beziehe ich mich ab jetzt auf std::async.

Das folgende Programmschnipsel zeigt einen Thread und ein Future/Promise-Paar. Beide berechnen die Summe von 3 + 4.

// thread
int res;
thread t([&]{ res = 3 + 4; });
t.join();
cout << res << endl;

// task
auto fut = async([]{ return 3 + 4; });
cout << fut.get() << endl;

Was ist nun der fundamentale Unterschied zwischen einem Thread und einem Promise/Future-Paar? Ein Thread legt fest, wie etwas berechnet wird. Ein Task hingegen, was berechnet wird.

Diesen Punkt möchte gerne ein wenig vertiefen:

  • Der Thread verwendet die geteilte Variable res für das Ergebnis der Berechnung. Im Gegensatz dazu verwendet der Promise std::async einen sicheren Datenkanal um sein Ergebnis an den Future fut zu übermitteln. Dies bedeutet im Fall des Threads, dass die geteilte Variable res geschützt werden muss.
  • Im Fall des Threads wird explizit ein neuer Thread erzeugt. Das gilt aber nicht im Falle des Tasks, da in diesem Fall nur das Arbeitspaket spezifiziert wird.

CP.8: Don’t try to use volatile for synchronization

Falls du in Java oder C# eine atomare Variable benötigst, verwende volatile. Das ist einfach. Falls du ein C++ eine atomare Variable benötigst, verwende volatile. Falsch, ganz falsch. volatile besitzt keine Mulithreading-Semantik in C++. Atomare Variablen heißen in C++11 std::atomic.

Jetzt bin ich neugierig: Was ist die Bedeutung von volatile in C++? volatile steht für besondere Objekte, auf den optimierte Lese- oder Schreibe-Operationen nicht erlaubt sind.

volatile wird typischerweise in der Embedded-Programmierung eingesetzt, um Speicherbereiche auszuzeichnen, die unabhängig vom regulären Programmverlauf modifiziert werden können. Dies sind zum Beispiel Speicherbereiche, die ein externes Device (memory-mapped I/O) repräsentieren. Da diese Speicherbereiche sich unabhängig vom regulären Programmfluss verändern können, werden ihre Werte dank volatile direkt in den Hauptspeicher geschrieben. So findet kein optimiertes Speichern in den Zwischenspeichern (Caches) statt.

Das korrekte Implementieren von Multithreading-Programmen ist sehr anspruchsvoll. Das ist der Grund, warum du jedes Werkzeug verwenden solltest, dass dir hilft, deinen Code zu validieren. Mit dem dynamischen Codeanalysewerkzeug ThreadSanitzer und dem statischen Codeanalysewerkzeug CppMem gibt es zwei hervorragende Werkzeuge, die in die Werkzeugkiste jedes professionellen Programmierers gehört, der sich mit Multithreading beschäftigt. ()