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.

 

Kommentar schreiben


Abonniere den Newsletter (+ pdf Päckchen)

Beiträge-Archiv

Sourcecode