Acquire- und Release-Speicherbarrieren

Inhaltsverzeichnis[Anzeigen]

Acquire und release Speicherbarrieren (fences) garantieren ähnliche Synchronisations- und Ordnungsbedingungen wie atomare Operationen mit Acquire-Release-Semantik. Ähnliche, denn die Unterschiede stecken im Detail.

Der offensichtlichste Unterschied zwischen acquire- und release-Speicherbarrieren und atomaren Operationen mit Acquire-Release-Semantik ist es, dass Speicherbarrieren ohne atomare Operationen auskommen. Es gibt aber einen viel feineren Unterschied. Die acquire- und release-Speicherbarrieren sind schwergewichtiger.

Atomare Operationen versus Speicherbarrieren

Der Einfachheit halber spreche ich nun nur noch von acquire-Operationen, wenn ich eine Speicherbarriere oder eine atomare Operation mit acquire-Semantik betrachte. Im gleichen Sinne spreche ich von release-Operartionen.

Die zentrale Idee einer acquire- und einer release-Operation ist es, dass sie eine Synchronisations- und Ordnungsbedingung zwischen Threads etablieren, die auch für atomare Operationen mit Relaxed-Semantik und nichtatomare Operationen gilt. Daher treten die acquire- und release-Operationen im Paar auf. Für die atomaren Operationen mit Acquire-Release-Semantik gilt darüber hinaus, dass sie auf der gleichen atomaren Variable stattfinden müssen. Trotzdem werde ich in erster Annäherung beide Operationen isoliert betrachten.

Anfangen werde ich mit der acquire-Operation.

Acquire-Operation

Ein Lese-Operation auf einer atomaren Variable mit dem std::memory_order_acquire versehen, ergibt eine acquire-Operation.

acquireOperation 

 Dem gegenüber steht der std::atomic_thread_fence mit acquire-Semantik. 

acquireFence

Die Gegenüberstellung zeigt schön zwei Punkte:

  1. Ein Speicherbarriere mit acquire-Semantik stellt strengere Ordnungsbedingungen auf. Zwar fordert sowohl die acquire-Operation auf der atomaren Variable als auch die acquire-Speicherbarriere, dass keine Lese- und Schreibe-Operationen vor die acquire-Operation verschoben werden können. Darüber hinaus garantiert die acquire-Speicherbarriere zusätzlich, dass keine Lese-Operationen hinter sie verschoben werden kann.
  2. Für das Lesen der atomaren Variable var ist die Relaxed-Semantik ausreichend. Der std::atomic_thread_fence(std::memory_order_acquire) verhindert, dass diese Operation hinter diesen umsortiert werden kann.

Ähnlich Aussagen gelten für die release-Speicherbarriere

Release-Operation

Ein Schreibe-Operation auf einer atomaren Variable mit dem std::memory_order_release versehen, ergibt eine release-Operation.

 releaseOperation

Weiter geht es mit der release-Speicherbarriere.

releaseFence

Zusätzlich zur release-Operation auf der atomaren Variable var sichert die release-Speicherbarriere zwei Eigenschaften zu:

  1. Schreibe-Operationen können nicht vor die Speicherbarriere verschoben werden.
  2. Für die Variable var ist es ausreichend, dass sie der Relaxed-Semantik genügt.

Wer den einfachen Überblick zu den Speicherbarrieren sucht, den verweise ich gerne auf den letzten Artikel dieses Blogs. Nun will ich einen Schritt weiter gehen und aus den Bausteinen ein Programm zusammenstellen.

Synchronisation mit atomaren Operationen versus Speicherbarrieren

Als Ausgangspunkt für meinen Vergleich implementiere ich einen typischen Consumer-Producer Arbeitsablauf mit Acquire-Release-Semantik. Diesen Ablauf werde ich mit atomaren Variablen und Speicherbarrieren umsetzen.

Los geht's mit atomaren Variablen, da viele Entwickler mit diesen bereits vertraut sind. Dies gilt aber nicht für Speicherbarrieren. Sie werden fast vollständig in der Literatur zum C++-Speichermodell ignoriert.

Atomare Operationen

 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
41
// acquireRelease.cpp

#include <atomic>
#include <thread>
#include <iostream>
#include <string>
 
std::atomic<std::string*> ptr;
int data;
std::atomic<int> atoData;
 
void producer(){
    std::string* p  = new std::string("C++11");
    data = 2011;
    atoData.store(2014,std::memory_order_relaxed);
    ptr.store(p, std::memory_order_release);
}
 
void consumer(){
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_acquire)));
    std::cout << "*p2: " << *p2 << std::endl;
    std::cout << "data: " << data << std::endl;
    std::cout << "atoData: " << atoData.load(std::memory_order_relaxed) << std::endl;
}
 
int main(){
    
    std::cout << std::endl;
    
    std::thread t1(producer);
    std::thread t2(consumer);
    
    t1.join();
    t2.join();
    
    delete ptr;
    
    std::cout << std::endl;
    
}

 

Wem das Programm bekannt vorkommt, darf sich nicht wundern. Dies ist ein Klassiker, den ich bereits in dem Artikel zum memory_order_consume verwendet habe. Die Graphik bringt direkt auf den Punkt, warum der Consumer-Thread t2 alle Werte des Producer-Thread t1 sieht.

acquireRelease

Die Wohldefiniertheit des Programms ist gewährleistet, da die happens-before Beziehung transitiv ist. Dazu müssen die drei happens-before Beziehungen verknüpft werden:

  1. Die Zeile 13 - 15 happens-before Zeile 16 (ptr.store(p,std::memory_order_release).
  2. Die Zeile 21 while(!(p2= ptrl.load(std::memory_order_acquire))) happens-before den Zeilen 22 - 24.
  3. Die Zeile 16 synchronizes-with Zeile 21. => Die Zeile 16 happens-before Zeile 21.

 Jetzt wird es spannend. Wie lässt sich der Ablauf auf Speicherbarrieren umstellen?

Speicherbarrieren

Das Programm ist schnell auf Speichbarrieren portiert. 

 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
41
42
43
// acquireReleaseFences.cpp

#include <atomic>
#include <thread>
#include <iostream>
#include <string>
 
std::atomic<std::string*> ptr;
int data;
std::atomic<int> atoData;
 
void producer(){
    std::string* p  = new std::string("C++11");
    data = 2011;
    atoData.store(2014,std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_release);
    ptr.store(p, std::memory_order_relaxed);
}
 
void consumer(){
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_relaxed)));
    std::atomic_thread_fence(std::memory_order_acquire);
    std::cout << "*p2: " << *p2 << std::endl;
    std::cout << "data: " << data << std::endl;
    std::cout << "atoData: " << atoData.load(std::memory_order_relaxed) << std::endl;
}
 
int main(){
    
    std::cout << std::endl;
    
    std::thread t1(producer);
    std::thread t2(consumer);
    
    t1.join();
    t2.join();
    
    delete ptr;
    
    std::cout << std::endl;
    
}

Zum einen füge ich an Stelle der atomaren Operationen mit acquire- und release-Semantik die entsprechenden Speicherbarrieren mit acquire- und release-Semantik (Zeile 16 und 23) ein, zum anderen verwende ich für die atomaren Operationen mit acquire- und release-Semantik die Relaxed-Semantik (Zeile 17 und 22). Das ist fast schon mechanisch. Natürlich kann ich auch nur eine acquire- oder release-Operation durch eine Speicherbarriere ersetzen. Der entscheidende Punkt ist, dass die release-Operation mit der acquire-Operation eine synchronizes-with Beziehung und damit eine happens-before Beziehung etabliert.

Die ganzen Zusicherungen möchte ich noch in bekannter Manier anhand einer Graphik erläutern.

acquireReleaseFences

Die entscheidende Frage ist, warum die Operationen, die der acquire-Speicherbarriere folgen, die Effekte der Operationen vor der release-Speicherbarriere sehen? Da data eine nichtatomare Variable ist und die atomare Variable atoData der Relaxed-Semantik folgt, können sie per se umsortiert werden. Dies ist aber nicht möglich, da std::atomic_thread_fence(std::memory_order_release) als release-Speicherbarriere, std::atomic_thread_fence(std::memory_order_acquire) als acquire-Speicherbarriere die potentielle Umsortierung unterbindet. Die detailiierte Begründung dazu findest du in der Analyse der acquire- und release-Speicherbarrieren am Anfang dieses Artikels.

Der Übersichtlichkeit halber die ganze Argumentation kurz und knackig.

  1. Die acquire- und release-Speicherbarrieren verhindern das Umsortieren der atomaren und nichtatomaren Operationen über die Speicherbarrieren hinaus.
  2. Der Consumer-Thread t2 wartet in der Schleife while (!(p2= ptr.load(std::memory_order_relaxed))) auf das Setzen des Zeigerwertes ptr.stor(p,std::memory_order_relaxed) im Producer-Thread t1.
  3. Die release-Speicherbarriere synchronizes-with der acquire-Speicherbarriere.

Zum Abschluss noch die Ausgabe der beiden Programme.

acquireReleaseAcquireReleaseFences

Wie geht's weiter?

Wenn ich eines bei meiner Beschäftigung mit dem C++-Speichermodell gelernt habe, ist es, dass ich meiner Intuition nicht trauen kann. Daher sind formale Verifizierungswerkzeuge so wertvoll. Dank dem online verfügbaren C++-Speichermodell Evaluierer CppMem ist es einfach möglich, die Wohldefiniertheit von Codeabschnitten zu verifizieren. Im nächsten Artikel werde ich CppMem genauer vorstellen.

 

 

 

 

 

 

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

Neuste Kommentare