C++ Core Guidelines: Teilen von Daten zwischen Threads

Modernes C++  –  9 Kommentare
Anzeige

Die großen Herausforderungen mit Threads beginnen dann, wenn veränderliche Daten zwischen diesen geteilt werden. Um kein Data Race und damit undefiniertes Verhalten zu erhalten, müssen die geteilten Daten geschützt werden.

Die drei Regeln des heutigen Artikels mögen für erfahrene Multithreading-Programmierer eine Selbstverständlichkeit sein, für Einsteiger in diese sehr anspruchsvolle Domäne sind sie überlebensnotwendig. Hier sind sie:

Anzeige

Los geht es mit der einfachsten Regel:

No naked mutex! Verpacke deinen Mutex immer in ein 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, dass du die Lebenszeit deiner Ressource an die Lebenszeit einer lokalen Variable bindest. Die C++-Laufzeit kümmert sich automatisch um die Lebenszeit seiner lokalen Variablen.

std::lock_guard, std::unique_lock, std::shared_lock (C++14) oder std::std::scoped_lock (C++17) setzen dies Pattern in C++ um. Dies gilt aber auch für die Smart Pointer std::unique_ptr und std::shared_ptr. Mein Artikel "Garbage Collection – No Thanks" geht auf die Details zu RAII ein.

Was bedeutet nun RAII für Multithreading-Code?

std::mutex mtx; 

void do_stuff()
{
mtx.lock();
// ... do stuff ... (1)
mtx.unlock();
}

Es macht keinen Unterschied, ob eine Ausnahme in Zeile (1) auftritt oder ich schlicht vergessen habe, den Mutex freizugeben. In beiden Fällen erhalte ich ein Deadlock, falls ein anderer Thread den std::mutex mtx benötigt (lock). Die Rettung liegt auf der Hand:

std::mutex mtx; 

void do_stuff()
{
std::lock_guard<std::mutex> lck {mtx};
// ... do stuff ...
} // (1)

Verpacke den Mutex in ein Lock, und der Lock wird automatisch freigegeben(1), denn dieser verlässt seinen Gültigkeitsbereich.

Falls ein Thread mehr als ein Mutex benötigt, ist größte Vorsicht angesagt. Mutexe sollten immer in derselben Reihenfolge gelockt werden, sonst droht ein Deadlock. Genau dies zeigt das nächste Programm:

// lockGuardDeadlock.cpp

#include <iostream>
#include <chrono>
#include <mutex>
#include <thread>

struct CriticalData{
std::mutex mut;
};

void deadLock(CriticalData& a, CriticalData& b){

std::lock_guard<std::mutex>guard1(a.mut); // (2)
std::cout << "Thread: " << std::this_thread::get_id() << std::endl;

std::this_thread::sleep_for(std::chrono::milliseconds(1));

std::lock_guard<std::mutex>guard2(b.mut); // (2)
std::cout << "Thread: " << std::this_thread::get_id() << std::endl;

// do something with a and b (critical region) (3)
}

int main(){

std::cout << std::endl;

CriticalData c1;
CriticalData c2;

std::thread t1([&]{deadLock(c1, c2);}); // (1)
std::thread t2([&]{deadLock(c2, c1);}); // (1)

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

std::cout << std::endl;

}

Thread t1 und t2 benötigen zwei Ressourcen CriticalData, um ihren Job (3) auszuführen. CriticalData besitzt seinen eigenen Mutex, um den Zugriff zu synchronisieren. Unglücklicherweise rufen die Threads die Funktion deadlock mit den Argumenten c1 und c2 in verschiedener Reihenfolge auf (1). Dies ist eine Race Condition. Falls der Thread t1 zuerst den ersten Mutex a.mut erhält und den zweiten Mutex b.mut nicht erhält, da dieser bereits der andere Thread besitzt, ergibt dies ein Deadlock (2).

Der einfachste Weg, den Deadlock aufzulösen, ist, beide Mutexe in eine atomaren Operation zu locken.

Mit C++11 kann dazu der Lock std::unique_lock zusammen mit der Funktion std::lock verwendet werden. std::unique_lock erlaubt es, das Locken des Mutex herauszuzögern. Die Funktion std::lock, die es ermöglicht, eine beliebige Anzahl von Mutexen in einem atomaren Schritt zu locken, führt das Locken der Mutexe aus:

void deadLock(CriticalData& a, CriticalData& b){ 
std::unique_lock<mutex> guard1(a.mut, std::defer_lock);
std::unique_lock<mutex> guard2(b.mut, std::defer_lock);
std::lock(guard1, guard2); // do something with a and b (critical region)
}

Mit C++17 erlaubt der Lock std::scoped_lock, eine beliebige Anzahl von Mutexen direkt in einer atomaren Operation zu locken:

void deadLock(CriticalData& a, CriticalData& b){ 
std::scoped_lock(a.mut, b.mut);
// do something with a and b (critical region
}

Warum ist der Codeschnipsel sehr schlecht?

std::mutex m;
{
std::lock_guard<std::mutex> lockGuard(m);
sharedVariable = unknownFunction();
}

Natürlich kann in dem Beispiel über die unknownFunction nur spekuliert werden. Falls die
Funktion

  • versucht, denselben Mutex m nochmals zu locken, ist das undefiniertes Verhalten. Meistens resultiert ein Deadlock daraus.
  • einen neuen Thread startet, der versucht, denselben Mutex m zu locken, wird dies einen Deadlock verursachen.
  • einen weiteren Mutex m2 benötigt. In diesem Fall erhältst du unter Umständen einen Deadlock, denn du lockst beide Mutexe m und m2 gleichzeitig. Natürlich kann es jetzt passieren, dass ein anderer Thread die Mutexe in einer anderen Reihenfolge lockt.
  • weder direkt noch indirekt versucht, den Mutex m zu locken, scheint alles richtig zu funktionieren. Die Betonung liegt dabei auf "scheint", denn dein Kollege verändert eventuell nachträglich die Funktion oder sie ist Teil einer dynamischen Bibliothek und du erhälst eine neue Variante von unknownFunction. Hierzu lässt sich nur spekulieren.
  • wie erwartet funktioniert, kann sie ein Performanzproblem besitzen, denn du weißt nicht, wie lange die Funktion unknownFunction benötigt. Was als Multithreading-Programm gedacht war, kann sich dadurch als Single-Threaded-Programm entpuppen.

Um diese Probleme zu lösen, bietet sich eine lokale Variable an:

std::mutex m;
auto tempVar = unknownFunction();
{
std::lock_guard<std::mutex> lockGuard(m);
sharedVariable = tempVar;
}

Die zusätzliche Indirektion löst die Probleme. tempVar ist eine lokale Variable und ist daher immun gegen eine Data Race. Das heißt, du kannst die unknownFunction ohne Schutzmechanismus aufrufen. Zusätzlich ist die Zeit, in der der Mutex gelockt ist, auf das Minimum reduziert: Die Zuweisungen von tempVar and sharedVariable.

Falls du nicht join oder detach auf dem Kinderthread child aufrufst, wirft child in seinem Destruktor eine std::terminate-Ausnahme. std::terminate ruft per Default std::abort auf. Um diesen Problem zu lösen, bietete die Guidelines Support Library gsl::joining_thread an. Dieser ruft automatisch join am Ende seiner Gültigkeitszeitraums auf. In meinen nächsten Artikel werde ich mir gsl::joining_thread genauer anschauen.

Anzeige