Locks statt Mutexen

Inhaltsverzeichnis[Anzeigen]

Wenn der letzte Artikel Gefahren von Mutexen etwas gezeigt hat, dann, dass Mutexe mit großer Vorsicht zu genießen sind. Daher sollen Mutexe in C++ in Locks verpackt werden.

Locks

Locks verwalten ihre Ressource nach dem bekannten RAII-Idiom. Ein Lock bindet sowohl in seinem Konstruktor automatisch den Mutex als auch gibt ihn automatisch in seinem Destruktor wieder frei. Damit ist die Gefahr von Verklemmung deutlich gebannt, da nicht der Programmierer, sondern die C++-Laufzeit für die Mutexe sorgt.

Locks gibt es in zwei Geschmacksrichtungen in C++11. std::lock_guard für den einfachen und std::unique_lock für den anspruchsvolleren Anwendungsfall.

std::lock_guard

Zuerst zum einfachen Anwendungsfall. 

mutex m;
m.lock();
sharedVariable= getVar();
m.unlock();

 

Der Mutex m sichert in dem kleinen Codeschnipsel zu, dass der Zugriff auf den kritischen Bereich sharedVariable= getVar() serialisiert wird. Serialisiert bedeutet in diesem Fall, dass ein Thread nach dem anderen auf den kritischen Bereich zugreifen darf. So einfach der Codeschnipsel ist, so anfällig ist er für Verklemmungen. Eine Verklemmung kann dann auftreten, wenn der kritische Bereich eine Ausnahme wirft oder wenn der Programmierer schlicht vergisst, den Mutex wieder freizugeben. Das geht mit std::lock_guard deutlich eleganter.

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

 

Das war einfach. Doch was sollen die öffnenden und schließenden geschweiften Klammern? Durch die geschweiften Klammern wird die Lebenszeit des std::lock_guard beschränkt. Das heißt, seine Lebenszeit endet genau dann, wenn er den künstlichen Bereich, bestehend aus den öffnenden und schließenden Klammern, verlässt. Genau zu diesem Zeitpunkt wird der Destruktor von std::lock_guard aufgerufen und - sie ahnen es vermutlich schon - der Mutex freigegeben. Dies geschieht automatisch, dies geschieht aber auch, falls der kritische Bereich sharedVariable= getVar() eine Ausnahme wirft. Natürlich kann der künstliche Bereich auch ein Bereich wie ein Funktionskörper oder eine if-Schleife sein. 

std::unique_lock

std::unique_lock ist deutlich mächtiger, aber auch  teurer in der Anwendung als sein kleiner Bruder std::lock_guard.

std::unique_lock kann zusätzlich zum std::lock_guard

  • ohne eine assoziierten Mutex erzeugt werden.
  • ohne einen gelockten assoziierten Mutex erzeugt werden.
  • den Lock seines assoziieren Mutex (wiederholt) explizit setzen bzw. freigeben.
  • den Mutex verschieben.
  • den Lock seines assoziierten Mutex versuchsweise oder verzögert locken.

Doch wozu ist diese zusätzliche Funktionalität notwendig? Sie erinnern  sich sicher an die Verklemmung im letzten Artikel Gefahren von Mutexen, die dadurch entstand, dass Mutexe in verschiedener Reihenfolge gelockt wurden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <chrono>
#include <mutex>
#include <thread>

struct CriticalData{
  std::mutex mut;
};

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

  a.mut.lock();
  std::cout << "get the first mutex" << std::endl;
  std::this_thread::sleep_for(std::chrono::milliseconds(1));
  b.mut.lock();
  std::cout << "get the second mutex" << std::endl;
  // do something with a and b
  a.mut.unlock();
  b.mut.unlock();
  
}

int main(){

  CriticalData c1;
  CriticalData c2;

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

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

}

 

Die Lösung für die Verklemmung ist relativ naheliegend. Die Funktion deadlock muss beiden Mutexe in einer atomaren Aktion locken. Genau das passiert im folgenden Beispiel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>
#include <chrono>
#include <mutex>
#include <thread>

struct CriticalData{
  std::mutex mut;
};

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

  std::unique_lock<std::mutex>guard1(a.mut,std::defer_lock);
  std::cout << "Thread: " << std::this_thread::get_id() << " first mutex" <<  std::endl;

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

  std::unique_lock<std::mutex>guard2(b.mut,std::defer_lock);
  std::cout << "    Thread: " << std::this_thread::get_id() << " second mutex" <<  std::endl;

  std::cout << "        Thread: " << std::this_thread::get_id() << " get both mutex" << std::endl;
  std::lock(guard1,guard2);
  // do something with a and b
}

int main(){

  std::cout << std::endl;

  CriticalData c1;
  CriticalData c2;

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

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

  std::cout << std::endl;

}

 

Wird der Konstruktor des std::unique_lock mit dem Argument std::defer_lock aufgerufen, so lockt er nicht automatisch seinen assoziierten Mutex. Dies geschieht in der Zeile 12 und 17. Gelockt werden die Locks in der Zeile 21 in einem atomaren Schritt mit Hilfe des Variadic Templates std::lock. Variadic Template bedeutet, dass std::lock eine beliebige Anzahl von Locks annehmen kann. Dabei lockt std::lock entweder alle oder kein Lock in einem untrennbaren Schritt. 

Während das std::unique_lock in dem Beispiel sich um die Lebenszeit seiner Ressource kümmert, bindet std::lock alle zugrundeliegenden Mutexe. Es geht aber auch anders herum. Zuerst werden die Mutexe durch std::lock gebunden und dann erst kümmert sich std::unique_lock um deren Lebenszeiten. Die zweite Variante ist schnell skizziert:

std::lock(a.mut, b.mut);
std::lock_guard<std::mutex> guard1(a.mut, std::adopt_lock);
std::lock_guard<std::mutex> guard2(b.mut, std::adopt_lock);

 

Nun lässt sich das Programm ohne Verklemmung ausführen.

deadlockResolved

Exkurs: Verklemmungen auf eine besondere Art

Es ist ein Irrglaube, dass nur Mutexe Verklemmungen verursachen können. Sobald ein Thread auf eine Ressource wartet, während er selber eine Ressource gelockt hat, lauert eine Verklemmung.

 

Die Ressource kann selbst ein Thread sein.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <mutex>
#include <thread>

std::mutex coutMutex;

int main(){

  std::thread t([]{
    std::cout << "Still waiting ..." << std::endl;
    std::lock_guard<std::mutex> lockGuard(coutMutex);
    std::cout << std::this_thread::get_id() << std::endl;
    }
  );

  {
    std::lock_guard<std::mutex> lockGuard(coutMutex);
    std::cout << std::this_thread::get_id() << std::endl;
    t.join();
  }

}

Das Programm kommt schnell zum Stillstand.

blockJoin

Was passiert hier? Das Locken des Ausgabekanals std::cout und das Warten des main-Threads auf sein Kinderthread t führt zur Verklemmung.  An der Ausgabe lässt sich schön festmachen, in welcher Reihenfolge die Anweisungen ausgeführt werden.

Zuerst führt der main-Thread die Zeilen 17 - 19 aus. In Zeile 19 wartet er durch den Aufruf t.join(), bis sein Kinderthread sein Arbeitspaket abgearbeitet hat. Der main-Thread wartet aber, während der Ausgabekanal gelockt hat. Die ist genau die Ressource, auf die auch der Kindertrhread wartet. Zwei Lösungen der Verklemmung bieten sich an.

  • Der main-Thread lockt den Ausgabestream std::cout nach seinem Aufruf t.join().

{
  t.join();
  std::lock_guard<std::mutex> lockGuard(coutMutex);
  std::cout << std::this_thread::get_id() << std::endl;
}
  • Der main-Thread gibt den Lock automatisch durch ein künstlichen Bereich frei, bevor er auf seinen Kinderthread mit t.join() wartet.

{
  {
    std::lock_guard<std::mutex> lockGuard(coutMutex);
    std::cout << std::this_thread::get_id() << std::endl;
} t.join(); }

Hintergrundinformation

RAII
    Resource Acquisition Is Initialization, kurz RAII, bezeichnet eine beliebte Programmiertechnik in C++, bei der die Ressourcenbelegung und -freigabe an den Lebenszyklus eines Objektes gebunden wird. Das bedeutet im Fall des Mutex, dass der Mutex im Konstruktor gebunden und im Destruktor wieder freigegeben wird. Das bedeutet im Fall der Smart Pointer in C++, dass der dynamische Speicher im Konstruktor allokiert und im Destruktor wieder freigegeben deallokiert wird.

Wie geht's weiter?

Im nächsten Artikel geht mit den sogenannte Reader-Writer Locks weiter. Reader-Writer Locks ermöglichen es seit C++14, lesende von schreibenden Threads zu unterscheiden. Damit wird der Engpass um den kritischen Bereich entschärft, den nun können beliebig viele lesende Threads gleichzeitig auf den kritischen Bereich zugreifen. 

 

 

 

 

 

 

title page smalltitle page small Go to Leanpub/cpplibrary "What every professional C++ programmer should know about the C++ standard library".   Hole dir dein E-Book. Unterstütze meinen Blog.

Tags: Mutexe, Locks

Kommentare   

0 #1 inhouse pharmacy 2016-11-20 12:20
Wow, attractive website. Thnx ...
Zitieren

Kommentar schreiben


Abonniere den Newsletter (+ pdf Päckchen)

Beiträge-Archiv

Sourcecode

Neuste Kommentare