Beim Einsatz von Threads lauert stets die Gefahr, sich einen Nagel einzutreten. Der richtige Einsatz der Regeln der Core Guidelines helfen, das Verletzungsrisiko zu minimieren. Dieser Artikel stellt die entsprechenden Regeln vor.
Starten möchte ich den Artikel mit einer persönlichen Erfahrung. Mit Nicolai Josuttis, einem langjährigen Mitarbeiter am C++-Standard, diskutierte ich vor zwei Jahren über seine C++20-Standarderweiterung »std::jthread« [1]. Zum Abschluss unserer Diskussion über die Gefahren von Threads stellte er die Gretchenfrage, ob ich etwas im Threading kenne, was einfach sei. Sie war natürlich rhetorisch: Der Einsatz von Threads ist per se ein Expertengebiet. Das verleiht den Regeln der C++ Core Guidelines auch so große Bedeutung, helfen sie doch, die Komplexität von Concurrency auf ein beherrschbares Maß zu reduzieren.
Lass Dir helfen
Nur beseitigte Bugs sind gute Bugs. Daher gilt es, beim Umgang mit Threads alle zur Verfügung stehenden Werkzeuge einzusetzen. Dazu sagt CP.9: “Whenever feasible use tools to validate your concurrent code.” [2]. Den Wahrheitsgehalt dieser Regel habe ich schon mehrfach schmerzhaft erfahren. Einerseits schreiben viele meiner Schulungsteilnehmer Programme mit Data Races, andererseits habe ich das eine oder andere Multithreading-Programm implementiert, das Bugs aufwies. Woher kommt diese späte Erkenntnis? Ich verdanke sie dem dynamischen Codeanalysewerkzeug ThreadSanitizer [3]. ThreadSanitizer gibt das große Bild wieder und entdeckt zur Laufzeit des Programms, ob es ein Data Race enthält.
Laut offizieller Beschreibung handelt es sich bei ThreadSanitizer alias TSan um “einen Data-Race-Detektor für C/C++. Data Races zählen zu den häufigsten und am schwierigsten zu entdeckenden Bugs in nebenläufigen Systemen. Ein Data Race tritt auf, wenn zwei Threads parallel auf dieselbe Variable zugreifen und dabei mindestens ein Schreibzugriff auftritt. Der C++11-Standard betrachtet Data Races offiziell als undefiniertes Verhalten.”
ThreadSanitizer, ein Bestandteil von Clang 3.2 und GCC 4.8, unterstützt 64-Bit-Linux und ist unter Ubuntu 12.04 getestet. Um ThreadSanitizer zu verwenden, müssen Sie Ihr Programm mit »-fsanitize=thread -g« kompilieren, wobei das Flag »-g« die Debug-Information setzt. Das verursacht signifikante Laufzeitkosten: Der Speicherverbrauch steigt um den Faktor 5 bis 10, die Ausführungszeit um den Faktor 2 bis 20. Doch hier greift ein altes Prinzip der Software-Entwicklung: Primär muss ein Programm korrekt sein, die Geschwindigkeit kommt erst an zweiter Stelle.
Pingpong-Spiel
Sehen wir uns ThreadSanitizer einmal in Aktion an. Eine Übungsaufgabe zu Bedingungsvariablen, die ich gern in meinen Schulungen stelle, besteht im Schreiben eines kleinen Pingpong-Spiels.
Es gibt zwei Randbedingungen: Zum einen sollen zwei Threads abwechselnd einen Wahrheitswert auf »true« beziehungsweise »false« setzen. Dabei setzt einer den Wert auf »true« und signalisiert das dem anderen Thread, der den Wert auf »false« setzt. Zum anderen muss das Spiel nach einer endlichen Zahl von Ballwechseln enden. Listing 1 zeigt eine typische Lösung der Übungsaufgabe.
Listing 1
Ein Pingpong-Spiel
#include <condition_variable>
#include <iostream>
#include <thread>
bool dataReady=false;
std::mutex mutex_;
std::condition_variable condVar1;
std::condition_variable condVar2;
int counter=0;
int COUNTLIMIT=50;
void setTrue(){
while(counter <= COUNTLIMIT){
std::unique_lock<std::mutex> lck(mutex_);
condVar1.wait(lck, []{return dataReady == false;});
dataReady=true;
++counter;
std::cout << dataReady << std::endl;
condVar2.notify_one();
}
}
void setFalse(){
while(counter < COUNTLIMIT){
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()« (ab Zeile 12) setzt den Wahrheitswert »dataReady« (Zeile 5) auf »true«, die Funktion »setFalse()« (ab Zeile 23) 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 »dataReady« (Zeile 15). Danach erhöht die Funktion den »counter« (Zeile 17) um 1 und benachrichtigt mit der Hilfe der Bedingungsvariable »condVar2« (Zeile 19) den anderen Thread. Die Funktion »setFalse« folgt demselben Ablauf. Falls der »counter« den Wert »COUNTLIMIT« (Zeile 10) erreicht, endet das Spiel.
Übungsaufgabe gelöst? Nein! Es gibt ein Data Race auf »counter«. Die Variable wird gleichzeitig gelesen (Zeile 24) und geschrieben (Zeile 17). Dank ThreadSanitizer lässt sich der Beweis dafür sofort erbringen (Abbildung 1). Das Tool entdeckt das Data Race während der Laufzeit des Programms. Die weiteren Regeln helfen dabei, Data Races, Deadlocks und undefiniertes Verhalten im Allgemeinen zu vermeiden.
Dresscode für den Mutex
Eine davon lautet “no naked mutex”, oder, wie es die C++ Core Guidelines in CP.20 [4] formulieren: “Use RAII, never plain »lock()«/»unlock()«.” Zu Deutsch: Verpacke ein Mutex immer in einen Lock. Der Lock wird dank RAII automatisch den Mutex freigeben (»unlock()«), falls er seinen Gültigkeitsbereich verlässt. RAII steht für Resource Acquisition is Initialization und bedeutet, die Lebenszeit einer Ressource an die Lebenszeit einer lokalen Variable zu binden.
Die C++-Runtime kümmert sich automatisch um die Lebenszeit der lokalen Variablen. »std::lock_guard«, »std::unique_lock«, »std::shared_lock« und »std::std::scoped_lock« setzen dies Idiom in C++ um. Was bedeutet nun RAII für Multithreading-Code?
Listing 2 präsentiert eine Regelverletzung. Es macht keinen Unterschied, ob eine Ausnahme in Zeile 4 auftritt oder der Autor des Codes schlicht vergessen hat, den Mutex freizugeben. Beide Fälle führen zwangsläufig zu einem Deadlock, falls ein anderer Thread den »std::mutex mtx« benötigt. Die Rettung liegt auf der Hand, Listing 3 zeigt das korrekte Vorgehen.
Listing 2
Naked Mutex
std::mutex mtx;
void do_stuff() {
mtx.lock();
// ... do other stuff ...
mtx.unlock();
}
Listing 3
Mutex in einem Lock
std::mutex mtx;
void do_stuff() {
std::lock_guard<std::mutex> lck {mtx};
// ... do other stuff ...
}
Verpackt man den Mutex in einen Lock, wird er automatisch freigegeben, wenn er seinen Gültigkeitsbereich verlässt. Dabei sperrt der »std::lock_guard« automatisch in seinem Konstruktor den »mtx« und gibt ihn deterministisch am Ende der Funktion »do_stuff()« wieder frei.
Lassen sich alle Anforderungen durch den Einsatz von Mutexen lösen, die man in Locks verpackt? Leider nicht. Die Regel CP.22 [5] bringt ein typisches Problem beim Einsatz auf den Punkt: “Never call unknown code while holding a lock (e.g., a callback).” Listing 4 illustriert die Probleme, die entstehen, wenn man während eines Locks eine unbekannte Funktion aufruft.
Listing 4
Verstoß gegen CP.22
std::mutex m;
{
std::lock_guard<std::mutex> lockGuard(m);
sharedVariable = unknownFunction();
}
Was macht den Codeschnipsel so verwerflich? Über die aufgerufene »unknownFunction()« kann man nur spekulieren. Falls die Funktion versucht, denselben Mutex »m« nochmals zu sperren, ist das undefiniertes Verhalten. Meistens resultiert daraus ein Deadlock. Startet sie einen neuen Thread, der versucht, denselben Mutex »m« zu blockieren, verursacht das ebenfalls einen Deadlock.
Versucht die Funktion, dem Problem über einen weiteren Mutex »m2« auszuweichen, lauert wiederum ein Deadlock, denn die Mutexe »m« und »m2« sind nicht gleichzeitig geblockt. Daher kann es passieren, dass ein anderer Thread sie in einer anderen Reihenfolge sperrt.
Blockiert »unknownFunction()« weder direkt noch indirekt den Mutex »m«, scheint alles richtig zu funktionieren. Die Betonung liegt dabei auf “scheint”, denn ein Kollege verändert eventuell nachträglich die Funktion, oder sie gehört zu einer dynamischen Bibliothek. Darüber lässt sich nur spekulieren.
Selbst, wenn die Funktion wie erwartet funktioniert, kann sie ein Leistungsproblem auslösen, denn es nicht ersichtlich, welche Laufzeit die Funktion »unknownFunction()« benötigt. Was als Multithreading-Programm gedacht war, kann dadurch zum Single-Threaded-Programm degenerieren.
All diese Probleme lösen sich in Wohlgefallen auf, bringt man wie in Listing 5 eine lokale Variable ins Spiel. Diese zusätzliche Indirektion löst alle Probleme. Als lokale Variable ist »tempVar« immun gegen ein Data Race, »unknownFunction()« lässt sich daher ohne Schutzmechanismus aufrufen.
Listing 5
Einsatz einer lokalen Variable
std::mutex m;
auto tempVar = unknownFunction();
{
std::lock_guard<std::mutex> lockGuard(m);
sharedVariable = tempVar;
}
Zudem gewinnt die Performance, denn die Zeit, für die »std::lock_guard« den Mutex sperrt, sinkt auf ein Minimum: die Zuweisungen der Variable »tempVar« an die geteilte Variable »sharedVariable«.
Beachte die Lebenszeit der Threads
Im Multithreading lauern viele Gefahren. Besondere Vorsicht ist angebracht, wenn man ein Thread “detached”. Die Regel CP.26 [6], “don’t detach() a thread”, hört sich zunächst seltsam an. Der C++-Standard bietet zwar an, »detach()« auf einen Thread »thr« aufzurufen, aber wir sollen es nicht tun?
Der einfache Grund: Der korrekte Einsatz von »thr.detach()« ist anspruchsvoll. Es bedeutet, dass der aufrufende Code nicht darauf wartet, bis der erzeugte Thread seine Arbeit vollbracht hat. In diesem Fall lauert typischerweise undefiniertes Verhalten. Das wiederum heißt, dass sich keine verlässlichen Aussagen mehr zum Programm treffen lassen. Die einzige Lösung: Das Programm noch einmal richtig programmieren. Beispiel gefällig? Listing 6 stellt undefiniertes Verhalten in Aktion vor.
Listing 6
Undefiniertes Verhalten
#include <iostream>
#include <string>
#include <thread>
void func(){
std::string s{"C++11"};
std::thread t([&s]{ std::cout << s << std::endl;});
t.detach();
}
int main(){
func();
}
Das offensichtliche Problem: Die Lambda-Funktion nimmt das Argument »s« per Referenz an. Hier liegt ein undefiniertes Verhalten vor: Der Kinder-Thread verwendet »s«, obwohl die Variable eventuell keine Gültigkeit mehr besitzt, da ihre Lebenszeit an den Scope der Funktion »func()« gebunden ist. Gleichzeitig kann die Lambda-Funktion länger ausgeführt werden als die Funktion »func«.
Es gibt ein weiteres, weniger offensichtliches Problem: »std::cout« besitzt einen statischen Gültigkeitsbereich. Er endet, wenn das Programm terminiert. Das verursacht eine Race Condition, also eine Situation, in der der Programmverlauf vom verschränkten Ausführen der Threads abhängt: Der Thread »t« kann zu diesem Zeitpunkt »std::cout« noch verwenden.
Wie geht es weiter?
Geht es um den Umgang mit Fehlern in einem Programm, sind mehrere Punkte zu beachten. Dazu gehören das Erkennen des Fehlers sowie das Übermitteln der Fehlermeldung an eine Instanz, die ihn behandelt. Darüber hinaus muss man den Zustand des Programms sichern und Ressourcen freigeben. Genau mit diesen Aspekten der Fehlerbehandlung beschäftigen sich die C++ Core Guidelines und der nächste Artikel dieser Serie. (jcb/jlu)
Infos
- »std::jthread«: https://en.cppreference.com/w/cpp/thread/jthread
- CP.9: http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rconc-tools
- ThreadSanitizer: https://github.com/google/sanitizers/wiki/ThreadSanitizerCppManual
- CP.20: http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rconc-raii
- CP.22: http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rconc-unknown
- CP.26: http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rconc-detached_thread





