Open Source im professionellen Einsatz
Linux-Magazin 06/2014
1184

Schutz mit Locks

Locks sorgen für Ordnung. Das bedeutet für den konkreten Fall (Listing 2), dass entweder »thread1« oder »thread2« als Erster zum Zuge kommt. Folglich sind die Ausgaben »(0,0)« oder »(11,2000)« in Abbildung 2 möglich.

Listing 2

Synchronisation mit Locks

01 #include <iostream>
02 #include <mutex>
03 #include <thread>
04
05 std::mutex mutex;
06
07 int x,y;
08
09 void writing(){
10   mutex.lock();
11   x= 2000;
12   y= 11;
13   mutex.unlock();
14 }
15
16 void reading(){
17   mutex.lock();
18   std::cout << "y: " << y << " ";
19   std::cout << "x: " << x << std::endl;
20   mutex.unlock();
21 }
22
23 int main(){
24
25   std::cout << std::endl;
26
27   std::thread thread1(writing);
28   std::thread thread2(reading);
29
30   thread1.join();
31   thread2.join();
32
33   std::cout << std::endl;
34
35 };
Abbildung 2: Bei der Synchronisation mit Threads sind nur noch zwei Ergebnisse möglich.

Das Aufrufen der Paare »mutex.lock()« und »mutex.unlock()« in den Zeilen 10 und 13 sowie in den Zeilen 17 und 20 stellt zwar sicher, dass nur ein einziger Thread in der kritischen Region der Funktion »writing()« oder »reading()« aktiv ist.

Das Lock leistet aber noch mehr. Zum einen sorgt »mutex.lock()« in Zeile 17 dafür, dass der lesende Thread alle aktuellen Werte der Variablen »x« und »y« erhält. Zum anderen erreicht der Aufruf »mutex.unlock()« in Zeile 13, dass der schreibende Thread seine geänderten Daten veröffentlichen darf. In diesem Zusammenhang spricht der C++-Standard von der Acquire-Semantik (in Besitz nehmen) des »mutex.lock()« -Aufrufs und von der Release-Semantik (freigeben) des »mutex.unlock()« -Aufrufs.

Ein weiterer Aspekt der Aufrufe »mutex.lock« und »mutex.unlock« ist bemerkenswert: Sie erklären einen untrennbaren, atomaren Programmbereich. Das hat den schönen Seiteneffekt, dass die Aufrufe in diesem ebenfalls atomar sind. So ist das Schreiben und Lesen der Variablen »x« und »y« wohldefiniert, insbesondere kann das Programm keine beliebigen Werte schreiben oder lesen. Damit leisten Locks deutlich mehr, als es der erste Anschein vermuten lässt.

Dieser Aufwand hat aber seinen Preis. Das größte Performanceproblem besteht darin, dass immer nur ein Thread aktiv sein kann. Polemisch ausgedrückt: Die Synchronisation mit Locks macht aus einem Multithreading- ein Singlethreading-Programm. Der Programmablauf ist zu Lasten der Performance wohldefiniert. Abhilfe schaffen atomare Variablen, die Teil des C++-Memory-Modells sind.

Atomare Variablen

Statt der Funktionen »reading()« und »writing()« schützt Listing 3 nur die Variablen »x« und »y« . Der Code erklärt sie zu atomaren Variablen. C++ bringt einen Satz einfacher atomarer Datentypen mit [1], daneben lassen sich auch eigene definieren. Atomare Datentypen haben einen offensichtlichen und einen nicht so offensichtlichen Mehrwert: Zum einen sind Operationen auf ihnen atomar, zum anderen legen sie im Standardfall eine Ordnung fest.

Listing 3

Synchronisation mit atomaren Variablen

01 #include <iostream>
02 #include <atomic>
03 #include <thread>
04
05 std::atomic<int> x, y;
06
07 void writing(){
08   x.store(2000);
09   y.store(11);
10 }
11
12 void reading(){
13   std::cout << y.load() << " ";
14   std::cout << x.load() << std::endl;
15 }
16
17 int main(){
18
19   std::cout << std::endl;
20
21   std::thread thread1(writing);
22   std::thread thread2(reading);
23
24   thread1.join();
25   thread2.join();
26
27   std::cout << std::endl;
28
29 };

Atomar bedeutet in diesem konkreten Fall, dass das Schreiben der Werte »x« und »y« (Zeilen 8 und 9) und das Lesen (Zeilen 13 und 14) atomar sind. Durch die atomaren Variablen »x« und »y« ist aber auch die Reihenfolge der Variablenzugriffe in den Funktionen »writing()« und »reading()« sequenziell konsistent. Das heißt insbesondere, dass »thread1« die Anweisungen in seiner Funktion »writing()« genau in der Reihenfolge ausführen muss, in der sie im Quelltext stehen. Gleiches gilt natürlich für »thread2« und seine Funktion »reading()« .

Der Begriff sequenzielle Konsistenz stammt aus den 70er Jahren und geht auf den Mathematiker und Informatiker Leslie Lamport zurück, der unter anderem die Textsatz-Sprache Latex geschaffen hat und kürzlich den Turing-Preis erhielt [2]. Im Unterschied zu Listing 2 kann Listing 3 auch die Ausgabe »(0,2000)« besitzen, siehe Abbildung 3. Dieser Fall tritt genau dann ein, wenn auf das Ausführen der Anweisung »y.load()« in Zeile 13 die Anweisungen der Funktion »writing()« komplett ausgeführt werden.

Abbildung 3: Synchronisation mit atomaren Variablen.

Sequenzielle Konsistenz beschreibt das Standardverhalten für atomare Variablen. Sie entspricht der natürlichen Intuition des Programmierers: Die Anweisungen laufen in der Reihenfolge ab, in der sie im Programmcode stehen. Mit dem Relaxed-Memory-Modell verlässt C++ den vertrauten Bereich der Intuition und betritt ein Gebiet, in das sich Programmierer nur im äußersten Fall wagen sollten. Bis zu diesem Punkt unterscheidet sich das Memory-Modell von C++ nicht vom Java-Pendant, dann aber geht es eigene Wege.

Für atomare Variablen lässt sich exakt spezifizieren, welche Zusicherungen Operationen auf ihnen einzuhalten haben. Der Einfachheit halber ist das Listing 4 nur so weit modifiziert, dass es die gleiche Semantik und Ausgabe wie Listing 3 besitzt. Im Vergleich mit Listing 3 erzeugt es potenziell aber eine ausführbare Datei mit besserer Performance.

Listing 4

Acquire-Release-Semantik

01 #include <iostream>
02 #include <atomic>
03 #include <thread>
04
05 std::atomic<int> x, y;
06
07 void writing(){
08   x.store(2000,std::memory_order_release);
09   y.store(11,std::memory_order_release);
10 }
11
12 void reading(){
13   std::cout << y.load(std::memory_order_acquire) << " ";
14   std::cout << x.load(std::memory_order_acquire) << std::endl;
15 }
16
17 int main(){
18
19   std::cout << std::endl;
20
21   std::thread thread1(writing);
22   std::thread thread2(reading);
23
24   thread1.join();
25   thread2.join();
26
27   std::cout << std::endl;
28
29 };

Der Unterschied zwischen den beiden Listings ist minimal. Die »store()« -Operationen in den Zeilen 8 und 9 sind mit »std::memory_order_release« , die »load()« -Operationen in den Zeilen 13 und 14 sind mit »std::memory_order_acquire« ausgezeichnet. Die entscheidende Beobachtung ist, dass Listing 4 die »store()« -Operation der Variablen »y« in Zeile 9 mit der »load()« -Operation der gleichen Variablen in Zeile 13 synchronisiert, sodass die »load« ()-Operation nach der »store()« -Operation ausgeführt wird.

Die »store()« -Operation in Zeile 9 bewirkt, dass die »store()« -Operation der Variablen »x« in Zeile 8 zuvor ausgeführt wird. Außerdem sorgt sie dafür, dass die »load()« -Operation von »x« in Zeile 14 erst nach der »load()« -Operation von »y« in Zeile 13 ausgeführt wird.

Nach dem Verknüpfen der Abhängigkeiten ist das Programm mit minimalem Synchronisationsaufwand wohldefiniert: Einerseits geschieht das Speichern von »x« vor dem Speichern von »y« , das Laden von »x« nach dem Laden von »y« im jeweiligen Thread. Andererseits erfolgt das Laden von »y« nach dem Speichern von »y« . Das Ergebnis dieser Reihenfolge ist, dass »y« und auch »x« die gesetzten Werte aufweisen.

Diesen Artikel als PDF kaufen

Express-Kauf als PDF

Umfang: 4 Heftseiten

Preis € 0,99
(inkl. 19% MwSt.)

Linux-Magazin kaufen

Einzelne Ausgabe
 
Abonnements
 
TABLET & SMARTPHONE APPS
Bald erhältlich
Get it on Google Play

Deutschland

Ähnliche Artikel

  • Reichhaltiges Angebot

    C++0x bringt nicht nur Veränderungen in der Kernsprache. Die Standardbibliothek der C++-Neuausgabe hat Multithreading, asynchrone Funktionsaufrufe, reguläre Ausdrücke und vieles mehr im Angebot.

  • C++11

    2014 ist ein besonderes Jahr für C++. Drei Jahre nach C++11 erfährt der Sprachstandard mit C++14 den letzten Feinschliff. Neben generischen Lambda-Funktionen und der vereinfachten Ermittlung des Rückgabetyps kann C++14 vor allem mit einem Feature punkten: Reader-Writer-Locks.

  • C++

    Hochperformanten Code zu schreiben, den nur Eingeweihte zu würdigen wissen, ist für Entwickler ein bisschen so, wie für Formel-1-Testpiloten mit einem neuen Motor den Rundenrekord auf dem Nürburgring zu knacken. Doch oft endet der Tuningversuch in der Leitplanke.

  • C++11

    Die C++11-Reihe beschäftigt sich weiter mit dem Synchronisieren von Threads. Diesmal setzt der Chef-Thread Bedingungsvariablen ein, um die Tätigkeit seiner Mitarbeiter-Threads zu koordinieren.

  • C++

    Der neue Standard C++17 kündigt sich für 2017 an. Er wird zwar einige großartige Features wie eine Bibliothek für das Dateisystem mitbringen, doch andere lang ersehnte Funktionen fehlen wohl weiterhin.

comments powered by Disqus

Stellenmarkt

Artikelserien und interessante Workshops aus dem Magazin können Sie hier als Bundle erwerben.