C++ Core Guidelines: Verwende Werkzeuge, um deinen Concurrent-Code zu validieren

Modernes C++  –  0 Kommentare

Die wohl wichtigste Regel der C++ Core Guidelines zur Concurrency ist, falls möglich, ein Werkzeug zu verwenden, um den Code zu prüfen. Nicht alle, aber viele Bugs lassen sich mit Werkzeugen finden, und jeder beseitigte Bug ist ein guter Bug. Hier sind die zwei Werkzeuge, die mir sehr oft wertvolle Hilfe in den letzten Jahren gegeben haben: ThreadSanitizer und CppMem.

Dies ist die Regel für den heutigen Artikel:

Glaube mir, dieser Artikel basiert auf eigener Erfahrung. Einerseits schreiben viele meiner Schulungsteilnehmer Programme mit Data Races, andererseits habe ich einige Multithreading-Programme implementiert, die Bugs hatten. Wie kann ich mir so sicher sein: dank des dynamischen Codeanalyse-Werzeugs ThreadSanitizer und des statischen Codeanalyse-Werkzeugs CppMem. Die Anwendungsfälle für beide Werkzeuge unterscheiden sich deutlich.

ThreadSanitizer gibt das große Bild und entdeckt zur Laufzeit des Programms, ob dieses ein Data Race besitzt. CppMem hingegen bietet eine sehr detaillierte Sicht auf einen kleinen Codeschnipsel, der in der Regel atomare Variablen enthält. Vor allem gibt CppMem die Antwort zu der Frage: Welche verschränkten Ausführungen der Threads sind aufgrund des verwendeten Speichermodells möglich?

Los geht der Artikel mit ThreadSanitizer.

Dies ist die offizielle Beschreibung von ThreadSanitizer: "ThreadSanitizer (aka TSan) is a data race detector for C/C++. Data races are one of the most common and hardest to debug types of bugs in concurrent systems. A data race occurs when two threads access the same variable concurrently and at least one of the accesses is write. C++11 standard officially bans data races as undefined behavior."

ThreadSanitizer ist Bestandteil von clang 3.2 und gcc 4.8. Es unterstützt Linux x86_64 und ist auf Ubuntu 12.04 getestet. Um ThreadSanitizer zu verwenden, musst du mit dem Flag -fsanitize=thread compilieren und gegen es linken, zumindest das Optimierungslevel -O2 anwenden und das Flag -g für Debug-Information einsetzen: -fsanitize=thread -O2 -g.

Die Laufzeitkosten sind signifikant: Der Speicherverbraucht steigt um den Faktor 5 - 10, die Ausführungszeit um den Faktor 2 - 20. Daher möchte ich gerne das wichtigste Prinzip der Softwareentwicklung nennen: Zuerst gilt, dass das Programm korrekt ist, dann schnell.

Nun werde ich ThreadSanitizer in Aktion vorstellen. Hier ist eine Übungsaufgabe zu Bedingungsvariablen, die ich gerne in meiner Schulungen stelle:

Schreiben Sie ein kleines Ping-Pong Spiel:

  • Zwei Threads sollen abwechselnd einen Wahrheitswert auf true bzw. false setzen. Dabei setzt ein Thread den Wert auf true und signalisiert dies dem anderen Thread, der den Wert auf false setzt.
  • Das Spiel soll nach einer endlichen Zahl von Ballwechseln beendet werden.

Dies ist eine typische Lösung der Übungsaufgabe.

// conditionVariablePingPong.cpp

#include <condition_variable>
#include <iostream>
#include <thread>

bool dataReady= false; // (3)

std::mutex mutex_;
std::condition_variable condVar1;
std::condition_variable condVar2;

int counter=0;
int COUNTLIMIT=50;

void setTrue(){ // (1)

while(counter <= COUNTLIMIT){ // (7)

std::unique_lock<std::mutex> lck(mutex_);
condVar1.wait(lck, []{return dataReady == false;}); // (4)
dataReady= true;
++counter; // (5)
std::cout << dataReady << std::endl;
condVar2.notify_one(); // (6)

}
}

void setFalse(){ // (2)

while(counter < COUNTLIMIT){ // (8)

std::unique_lock<std::mutex> lck(mutex_);
condVar2.wait(lck, []{return dataReady == true;});
dataReady= false;
std::cout << dataReady << std::endl;
condVar1.notify_one();

}

}

int main(){

std::cout << std::boolalpha << std::endl;

std::cout << "Begin: " << dataReady << std::endl;

std::thread t1(setTrue);
std::thread t2(setFalse);

t1.join();
t2.join();

dataReady= false;
std::cout << "End: " << dataReady << std::endl;

std::cout << std::endl;

}

Die Funktion setTrue (1) setzen den Wahrheitswert dataReady (3) auf true und die Funktion setFalse (2) setzt ihn auf false. Das Spiel beginnt mit setTrue. Die Bedingungsvariable in der Funktion wartet auf die Benachrichtigung und prüft daher zuerst den Wahrheitswert dataRace (4). Danach erhöht die Funktion den counter (5) um 1 und benachrichtigt mit der Hilfe der Bedingungsvariable condVar2 (6) den anderen Thread. Die Funktion setFalse folgt demselben Arbeitsablauf. Falls der counter den Wert COUNTLIMIT (7) erreicht, endet das Spiel. Übungsaufgabe gelöst? NEIN!

Es gibt eine Data Race auf counter. Dieser wird gleichzeitig gelesen (8) und geschrieben (5). Dank ThreadSanitizer kann ich den Beweis sofort antreten:

ThreadSanitizer entdeckt das Data Race während der Laufzeit des Programms.

Mit CppMem lassen sich hingegen kleine Codeschnipsel analysieren.

In diesem Artikel kann ich nur einen einfachen Überblick zu CppMem geben. Das Online-Werkzeug, das sich auch lokal installieren lässt, bietet sehr wertvolle Dienste an.

  1. CppMem validiert kleine Codeschnipsel, die typischerweise atomare Variablen enthalten.
  2. Die sehr detaillierte Analyse von CppMem hilft ungemein, einen tieferen Einblick in das C++ Speichermodell zu erhalten.

Für deutlich tiefere Einsichten in CppMem, habe ich bereits einige Artikel zu CppMem geschrieben. In dem Artikel werde ich mich auf den ersten Punkt fokussieren und CppMem kurz aus der Vogelperspektive betrachten.

Mein vereinfachter Überblick geht von der Default-Konfiguration des Werkzeugs aus. Dieser Überblick sollte als Startpunkt für eigene Experimente ausreichen.

Der Einfachheit halber beziehe ich mich auf die roten Zahlen in dem folgenden Screenshot.

  1. Model
    • Spezifiziert das C++-Speichermodell. preferred entspricht dem C++-Speichermodell.
  2. Program
    • Enthält das ausführbare Programm in einer Syntax, die sehr stark an C oder C++ angelehnt ist.
    • CppMem bietet einen Satz an Programmen zu typischen Szenarien von Multithreading-Programmen an. Genauer sind die in dem sehr lesenswerten Artikel "Mathematizing C++ Concurrency" beschrieben. Darüber hinaus lässt sich natürlich auch eigener Code verwenden.
    • Da es um Threads bei CppMem geht, besitzt das Werkzeug ein paar Vereinfachungen für Threads.
      • Threads werden durch die Symbole {{{ ... ||| ... }}} definiert. Dabei steht die Ellipse (...) für das jeweilige Arbeitspaket des Threads.
  3. Display Relations
    • Beschreibt die Beziehungen zwischen Lese, Schreibe und Lese-Schreibe-Modifikationen auf atomaren Operationen, Speicherbarrieren und Locks.
    • Wenn eine Beziehung ausgewählt ist, wird diese im annotierten Graph (siehe Punkt 6) dargestellt.
      • Hier sind typische Beziehungen
        • sb: sequencedbeforer
        • rf: read from
        • mo: modification order
        • sc: sequentially consistency
        • lo: lock order
        • sw: sychronizes-with
        • dob: dependency-ordered-before
        • data_races
  4. Display Layout
    • Mit diesen Schaltern lässt sich steuern, welche Doxygraph-Graph zur Darstellung der konkreten Ausführung verwendet werden soll.
  5. Auswahl der Ausführung
    • Wechsel zwischen den verschiedenen, konsistenten Ausführungen
  6. Annotierter Graph
    • Stellt den annotierten Graph dar.

Nun will ich das Werkzeug anwenden.

Zuerst ist hier mein kleines Programm. Dieses besitzt ein Data Race auf x (1). Die Verwendung der atomare Variable y hingegen ist wohldefiniert. Dies ist unabhängig davon, welche memory-order ich für y einsetze, den zumindest ist y atomar.

// dataRaceOnX.cpp

#include <atomic>
#include <iostream>
#include <thread>

int x = 0;
std::atomic<int> y{0};

void writing(){
x = 2000; // (1)
y.store(11, std::memory_order_release); // (2)
}

void reading(){
std::cout << y.load(std::memory_order_acquire) << " "; // (2)
std::cout << x << std::endl; // (1)
}

int main(){

std::thread thread1(writing);
std::thread thread2(reading);

thread1.join();
thread2.join();

}

Hier ist das entsprechende Programm in der vereinfachten CppMem-Syntax.

// dataRaceOnXCppMem.txt

int main(){
int x = 0;
atomic_int y = 0;

{{{
{
x = 2000;
y.store(11, memory_order_release);
}
|||
{
y.load(memory_order_acquire);
x;
}
}}}
}

CppMem zeigt es sofort an. Die erste konsistente Ausführung besitzt ein Data Race auf x.

Das Data Race ist direkt in dem Graph sichtbar. Es ist die gelbe Kante (dr) zwischen der Schreibe- (x=2000) und der Lese-Operation (x=0).

Klar, es gibt noch viele Regeln zu Concurreny in den C++ Core Guidelines. Im nächsten Artikel werde ich mir genauer Locks und Mutexe anschauen.