Acquire-Release-Semantik - Der typische Fehler

Inhaltsverzeichnis[Anzeigen]

Eine release-Operation synchronizes-with einer acquire-Operation auf der gleichen atomaren Variable. Damit lassen sich Threads einfach synchronisieren, wenn ... . Auf dieses wenn will ich tiefer eingehen.

Wie so schreibe ich den Artikel zum typischen Missverständnis der Acquire-Releae-Semantik? Ganz einfach. In diese Falle bin ich selbst schon mal getappt. Ich bin aber bei weitem nicht alleine. Sowohl auf meinen Schulungen wie auch auf meinen Vorträgen gab es einiges an Verwirrungspotential. Zuerst aber zum Gutfall.

Warten inklusive

Als Anschauungsbeispiel soll das einfache Programm dienen. 

 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
// acquireReleaseWithWaiting.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

std::vector<int> mySharedWork;
std::atomic<bool> dataProduced(false);

void dataProducer(){
    mySharedWork={1,0,3};
    dataProduced.store(true, std::memory_order_release);
}

void dataConsumer(){
    while( !dataProduced.load(std::memory_order_acquire) );
    mySharedWork[1]= 2;
}

int main(){
    
  std::cout << std::endl;

  std::thread t1(dataConsumer);
  std::thread t2(dataProducer);

  t1.join();
  t2.join();
  
  for (auto v: mySharedWork){
      std::cout << v << " ";
  }
      
  std::cout << "\n\n";
  
}

 

In diesem wartet der consumer-Thread t1 in Zeile 17, bis der producer-Thread t2 in Zeile 13 dataProduced auf true gesetzt hat. dataProduced ist der Wächter, denn er stellt sicher, dass der Zugriff auf die nichtatomare Variable mySharedWork synchronisiert wird. Das heißt, zuerst initialisiert der producer-Thread t1 mySharedWork, dann vollendet der consumer-Thread t2 die Arbeit, indem er mySharedWork[1] auf 2 setzt. Damit ist das Programm wohldefiniert.

acquireReleaseWithWaiting

Die Graphik stellt die happens-before Beziehungen auf den Threads und die synchronize-with Beziehung zwischen den Threads graphisch dar. synchronize-with erzeugt selbst eine happens-before Beziehung. Aufgrund der Transitivität der happens-before Beziehung gilt: mySharedWork={1,0,3} happens-before mySharedWork[1]=2.

withWaiting

Welcher Aspekt wird bei dieser Graphik gerne vergessen? Das Wenn.

Wenn, ...

Was passiert, wenn ich den consumer-Thread t2 in Zeile 17 nicht auf den producer-Thread t1 warten lasse?

 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
// acquireReleaseWithoutWaiting.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

std::vector<int> mySharedWork;
std::atomic<bool> dataProduced(false);

void dataProducer(){
    mySharedWork={1,0,3};
    dataProduced.store(true, std::memory_order_release);
}

void dataConsumer(){
    dataProduced.load(std::memory_order_acquire);
    mySharedWork[1]= 2;
}

int main(){
    
  std::cout << std::endl;

  std::thread t1(dataConsumer);
  std::thread t2(dataProducer);

  t1.join();
  t2.join();
  
  for (auto v: mySharedWork){
      std::cout << v << " ";
  }
      
  std::cout << "\n\n";
  
}

 

Das Programm besitzt undefiniertes Verhalten, denn es gibt einen kritischen Wettlauf um die Variable mySharedWork. Sowohl unter Linux als auch unter Windows zeigt sich das sofort bei der Programmausführung.

acquireReleaseWithoutWaitingacquireReleaseWithoutWaitingWin

Was ist das Problem? Es gilt doch: dataProduced.store(true, std::memory_order_release) synchronizes-with dataProduced.load(std::memory_order_acquire). Ja klar, das heißt aber nicht, dass die acquire-Operation auf die release-Operation wartet. Genau das zeigt die Graphik. In ihr wird dataProduced.load(std::memory_order_acquire) vor dataProduced.store(true, std::memory_order_release) ausgeführt. Daher gibt es keine synchronize-with Beziehung.

withoutWaiting

Die Auflösung

synchronize-with bedeutet in diesem konkreten Fall: Wenn dataProduced.store(true, std::memory_order_release) vor dataProduced.load(std::memory_order_acquire) stattfindet, dann sind auch alle Operationen vor dataProduced.store(true, std::memory_order_release) nach dataProduced.load(std::memory_order_acquire) sichtbar. Das entscheidende Wort ist das Wenn. Genau dieses Wenn wird durch das Warten im ersten Beispiel (while( !dataProduced.load(std::memory_order_acquire) ) sichergestellt. 

Die Argumentation will ich gerne nochmals formaler wiederholen.

  • Alle Operationen vor dataProduced.store(true, std::memory_order_release) happens-before  allen Operationen nach dataProduced.load(std::memory_order_acquire), wenn git: dataProduced.store(true, std::memory_order_release) happens-before dataProduced.load(std::memory_order_acquire).

Wie geht's weiter?

 Acquire-Release-Semantik ohne Operationen auf atomaren Variablen. Geht das? Ja, mit Fences. Wie zeigt der nächste Artikel.

 

 

 

 

 

 

 

 

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.

 

Kommentare   

+1 #1 Marcel Wid 2016-04-05 19:28
Der typische Fehler ist, dass die Beziehung "synchronizes with" falsch verstanden wird. Das ist auch in diesem Artikel der Fall. Im C++ Standard findet man folgende Definition:

"An atomic operation A that performs a release operation on an atomic object M synchronizes with an atomic operation B that performs an acquire operation on M and takes its value from any side effect in the release sequence headed by A."

Daraus wird ersichtlich, dass in ihrem Beispiel die Operation dataProduced.store(true) "synchronizes with" der Operation dataProduced(load), wenn der Load den Wert true liest. Außerdem impliziert "synchronizes with" immer (per Definition) die Beziehung "happens before".

Die Zeit hier ins Spiel zu bringen, ist ebenfalls ein typischer Fehler: Bei Acquire-Release-Sematik gibt es keine (physikalische) Zeit. Verschiedene Threads können die gleichen Operationen in unterschiedlicher Reihenfolge wahrnehmen.
Zitieren
0 #2 Rainer Grimm 2016-04-07 05:14
zitiere Marcel Wid:
Der typische Fehler ist, dass die Beziehung "synchronizes with" falsch verstanden wird. Das ist auch in diesem Artikel der Fall. Im C++ Standard findet man folgende Definition:

"An atomic operation A that performs a release operation on an atomic object M synchronizes with an atomic operation B that performs an acquire operation on M and takes its value from any side effect in the release sequence headed by A."

Daraus wird ersichtlich, dass in ihrem Beispiel die Operation dataProduced.store(true) "synchronizes with" der Operation dataProduced(load), wenn der Load den Wert true liest. Außerdem impliziert "synchronizes with" immer (per Definition) die Beziehung "happens before".

Die Zeit hier ins Spiel zu bringen, ist ebenfalls ein typischer Fehler: Bei Acquire-Release-Sematik gibt es keine (physikalische) Zeit. Verschiedene Threads können die gleichen Operationen in unterschiedlicher Reihenfolge wahrnehmen.

Ich sehe den Widerspruch noch nicht. Da muss ich nochmals in mich selber gehen. Was ich aber in jedem Fall zu oberflächlich verwendet habe, ist den Begriff Zeit. Einen Zeittakt gibt es nur in der Sequenziellen Konsistenz. Die Argumentation auf den schwächeren Speichermodellen dreht sich um happens-before und deren Transitivität. happen-before macht keine zeitliche Zusicherungen, sondern Zusicherungen zur Speichersichtbarkeit von Operationen. So unscharf wird aber bereits im Single Threade Fall argumentiert, wenn wir sagen, dass eine Operation vor der anderen ausgeführt wird. Tatsächlich wird nicht eine Operation vor der anderen ausgeführt, sondern der Speichereffekt einer Operation muss vor der anderen sichtbar werden. Zugegeben, diese Problematik verschärft sich deutlich im Multithreaded Anwendungsfall.
Zitieren
0 #3 Marcel Wid 2016-04-10 19:57
Der Widerspruch besteht darin, dass ihre Grafik falsch ist: Entweder es gilt die "synchonizes-with" Beziehung, dann ist diese Ausführung in Ordnung, oder es gilt die "synchronizes-with" Beziehung nicht, dann besitzt diese Ausführung einen kritischen Wettlauf. Mit dem Tool CppMem kann man das schön nachvollziehen: Es gibt 2 konsistente Ausführungen, wovon 1 einen kritischen Wettlauf hat.
Zitieren
0 #4 Rainer Grimm 2016-04-11 19:37
zitiere Marcel Wid:
Der Widerspruch besteht darin, dass ihre Grafik falsch ist: Entweder es gilt die "synchonizes-with" Beziehung, dann ist diese Ausführung in Ordnung, oder es gilt die "synchronizes-with" Beziehung nicht, dann besitzt diese Ausführung einen kritischen Wettlauf. Mit dem Tool CppMem kann man das schön nachvollziehen: Es gibt 2 konsistente Ausführungen, wovon 1 einen kritischen Wettlauf hat.

Jetzt ist der Groschen gefallen. Mir war nicht klar, wie ich den Sachverhalt am einfachsten graphisch darstellen kann. Eigentlich wollte ich in der zweiten Graphik ein vermeintliches synchronizes-with darstellen. Das ist mir wohl misslungen. Daher werde werde ich jetzt einen dicken Strich durchziehen. Um das Wenn zu verdeutlichen, führte ich das Bild eines Zeittaktes ein. Das stimmt natürlich so auch nicht, da diesen einen universellen Takt suggeriert.
Ich finde, das Bild von Zeittakten ist eine sehr gute Intuition, um sich die verschränkte Ausführung von Threads vorzustellen. Beim Bruch der Sequnziellen Konsistenz tritt in meiner Intuition das Phänomen auf, dass jeder Thread verschiedene Zeitgeber besitzt und ein Thread den Zeitgeber eines anderen Threads auch nicht monoton wahrnehmen kann. Die Acquire-Release Semantik stellt in meiner Intuition ein expliziten Synchronisationspunkt zwischen den beiden Zeitgebern dar. Mir ist natürlich klar, dass Intuition nur Annäherung sind. Damit synchronisiert die Acquire-Release Semantik nur einzelne Zeittakte, während die Sequenzielle Konsistenz alle Zeittakte synchronisiert. Wenn ich in dem Bild bleibe, bedeutet dies für die Consume-Semantik, das hier zwar die Takte synchronisiert werden, die verschiedenen Zeitgeber aber nicht monoton wahrgenommen werden müssen.
Zitieren

Kommentar schreiben


Modernes C++

Abonniere den Newsletter

Inklusive zwei Artikel meines Buchs
Introduction und Multithreading

Beiträge-Archiv

Sourcecode

Neuste Kommentare