Mit der Relaxed-Semantik sind wir am Ende der Skala angelangt. Die Relaxed-Semantik ist das schwächste C++-Speichermodell und sichert nur zu, dass die Operationen auf atomaren Variablen atomar sind.
Keine Synchronisations- und Ordnungsbedingungen
Das hört sich einfach an. Wo es keine Regeln gibt, können diese auch nicht gebrochen werden. So einfach ist es aber nicht. Das Programm soll definiertes Verhalten besitzen. Das heißt in dem Fall konkret: Keine kritischen Wettläufe. Um dies zu erreichen, werden typischerweise die Synchronisations- und Ordnungsbedingungen atomare Operationen mit strengerer Speicherordnung verwendet, um atomare Operationen mit Relaxed-Semantik zu kontrollieren. Wie funktioniert das? Ein Thread kann durchaus die Operationen eines anderen Threads in beliebiger Reihenfolge wahrnehmen. Es muss nur sichergestellt sein, dass es ein Punkt im Programm gibt, an dem alle bisherigen Operationen zwischen den Threads synchronisiert werden.
Ein typisches Beispiel für eine atomare Operationen, dessen Ausführungsreihenfolge nicht wichtig ist, ist ein Zähler. Das entscheidende bei einem Zähler ist nicht, in welcher Reihenfolge die Threads den globalen Zähler inkrementieren. Das entscheidende ist, dass jedes Inkrementieren des Zählers atomar ist und das am Ende alle Threads mit ihrem inkrementieren fertig sind. Genau das zeigt das 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
|
#include <vector>
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> cnt = {0};
void f()
{
for (int n = 0; n < 1000; ++n) {
cnt.fetch_add(1, std::memory_order_relaxed);
}
}
int main()
{
std::vector<std::thread> v;
for (int n = 0; n < 10; ++n) {
v.emplace_back(f);
}
for (auto& t : v) {
t.join();
}
std::cout << "Final counter value is " << cnt << '\n';
}
|
Drei Zeilen verdienen besondere Aufmerksamkeit. Das sind die Zeilen 11, 22 und 24.
Zum einen wird in der Zeile 11 die atomare ganze Zahl mit der Relaxed-Semantik inkrementiert. Damit besteht die Garantie, dass die Operationen auf der Variable cnt atomar sind. Darüber hinaus erzeugen die fetch_add Aufrufe eine Ordnung auf cnt. Die Funktion f in Zeile 8 - 13 stellt das Arbeitspaket der Threads dar. Dies Arbeitspaket wird in Zeile 19 dem Thread überreicht.
Zum anderen synchronisiet sich in Zeile 22 der Erzeuger-Thread mit allen seinen Kindern. Deutlicher geht es nicht. Durch t.join() wartet der Erzeuger, bis alle seine Kinder mit ihrer Arbeit fertig sind. Genau diese Regel benötigen die atomaren Operationen in dem Programm, denn durch t.join() werden alle Ergebnisse veröffentlicht. Rein formal gilt: t.join() ist eine release-Operation.
Letztendlich gibt es zwischen all den Inkrementoperationen in Zeile 11 und dem Lesen des Zählers cnt in Zeile 24 eine happens-before Beziehung.
Das Ergebnis ist es, dass das Programm immer 10000 ausgibt. Einerseits langweilig. Andererseits beruhigend.
Ein prominentes Beispiel für einen atomaren Zähler, der der Relaxed-Semantik folgt, ist der Referenzzähler des std::shared_ptr. Dies trifft aber nur auf das Inkrementieren zu. Entscheidend beim Inkrementieren des Referenzzähler ist es, dass dieser atomar erhöht wird. Nicht entscheidend ist es, welcher Threads als Erstes zum Zuge kommt. Das gilt aber nicht für das Dekrementieren des Referenzzählers. Dieser verlange eine Acquire-Release Semantik mit dem Destruktor.
Explizit möchte ich in dem Artikel Anthony Williams danken, Autor des Buches C++ Concurrency In Action, der mir wertvolle Tipps bei diesem Artikel gegeben hat. Anthony betreibt selber einen englischen Blog rund um Concurrency in modernem C++: https://www.justsoftwaresolutions.co.uk/blog/.
Auf die Pflicht folgt die Kür
Auf die Pflicht folgt die Kür. Diesem einfachen Motto will ich in den nächsten Artikeln folgen und die bisher vorgestellte Theorie zu atomaren Datentypen und dem C++-Speichermodell am konkreten Beispiel vorzustellen.
int x= 0;
int y= 0;
void writing(){
x= 2000;
y= 11;
}
void reading(){
std::cout << "y: " << y << " ";
std::cout << "x: " << x << std::endl;
}
int main(){
std::thread thread1(writing);
std:.thread thread2(reading);
thread1.join();
thread2.join();
};
Wie geht's weiter?
Das Programm schaut sehr übersichtlich aus. Trotzdem besitzt es undefiniertes Verhalten. Warum? Diese Frage werde ich im nächsten Artikel beantworten und noch ein paar Schritte weiter gehen. Der Programmablauf soll nicht nur definiert sein, er soll auch noch darüber hinaus optimiert werden. In dem Zuge zäume ich in den nächsten Artikel die ganze Theorie nochmals von hinten im Schnelldurchlauf auf.
Viele weitere Artikel zur Anwendung der Theorie gibt es unter der Übersichsseite.
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.
Weiterlesen...