- Verwendete Werkzeuge
- Allgemeines
- Speicherbarierre Relaxed -
std::memory_order_relaxed - Behebung des Problems: Speicherbarierre Acquire/Release -
std::memory_order_acquire/std::memory_order_release - Speicherbarierre Relaxed: Real-World Beispiel
- Speicherbarierre Acquire/Release: Real-World Beispiel
- Beispiel aus Buch von Anthony Williams
- Literaturhinweise
Klassen:
- Klasse
std::thread - Klasse
std::atomic<>
Konstanten:
std::memory_order_relaxedstd::memory_order_consumestd::memory_order_acquirestd::memory_order_releasestd::memory_order_acq_relstd::memory_order_seq_cst
Was sind Speicherbarrieren?
Eine Speicherbarriere ist eine Anweisung an den Compiler oder die CPU, die sicherstellt, dass alle Anweisungen vor der Barriereanweisung vor und alle Anweisungen nach der Barriereanweisung danach ausgeführt werden.
Warum benötigen wir Speicherbarrieren? Moderne Compiler und CPUs können Anweisungen zur Optimierung in beliebiger Reihenfolge ausführen, solange dies die eigentliche Programmausführung nicht beeinträchtigt. Dies kann jedoch in Multithreading-Programmen zu Race Conditions oder Unexpected Behavior führen.
Um dieses unerwartete Verhalten zu verhindern, werden Speicherbarrieren eingesetzt.
Was versteht man unter der Speicherbarierre std::memory_order_relaxed?
- Die Speicherbarierre
std::memory_order_relaxedunterstützt den atomaren Zugriff auf die korrespondierende Variable. - Es ist aber nicht festgelegt, wann die anderen Anweisungen relativ zur Anweisung des atomaren Zugriffs dazu geschehen. In Bezug auf diese Anweisungen liegt keine Synchronisierung vor.
Schauen wir uns ein klassisches, fehlerhaftes Beispiel an und beheben wir es dann in einer zweiten Betrachtung mit einer weiteren Acquire/Release Speicherbarierre. Wir betrachten zu diesem Zweck ein vereinfachtes Produzenten/Konsumenten-Problem:
Thread 1 / Produzent:
01: std::size_t g_data{};
02: std::atomic<bool> g_ready{ false };
03:
04: void producer()
05: {
06: g_data = 123; // (1) write data
07: g_ready.store(true, std::memory_order_relaxed); // (2) publish flag
08: }Thread 2 / Konsument:
01: void consumer()
02: {
03: if (g_ready.load(std::memory_order_relaxed)) { // (3) consume flag
04: std::println("[{}] Data: {}", tid, g_data); // (4) read data
05: }
06: }Welche Ausgabe erwarten wir?
Wenn der Konsument erkennt, dass g_ready „wahr” ist, dann sicherlich
Data: 123
Was kann aber auch tatsächlich passieren? Eine Ausgabe
Data: 0
Warum kann das passieren? Weil keine Reihenfolgegarantie besteht zwischen den beiden Anweisungen
- (1)
g_data = 123;und - (2)
g_ready.store(...);
Mit der Speicherbarierre std::memory_order_relaxed darf sich das System so verhalten,
als ob die Ausführung (aus Sicht anderer Threads) neu geordnet wäre, zum Beispiel auch so:
g_ready.store(true, std::memory_order_relaxed); // happens "first"
g_data = 123; // happens "later"Der Verbraucher kann also Folgendes beobachten:
g_ready == true- er sieht aber weiterhin den alten Wert
g_data == 0
Behebung des Problems: Speicherbarierre Acquire/Release - std::memory_order_acquire/std::memory_order_release
Nun fügen wir die Synchronisation hinzu:
Thread 1 / Produzent:
01: std::size_t g_data{};
02: std::atomic<bool> g_ready{ false };
03:
04: void producer()
05: {
06: g_data = 123; // (1) write data
07: g_ready.store(true, std::memory_order_release); // (2) 'release'
08: }Thread 2 / Konsument:
01: void consumer()
02: {
03: if (g_ready.load(std::memory_order_acquire)) { // (3) 'acquire'
04: std::println("[{}] Data: {}", tid, g_data); // (4) read data
05: }
06: }Was hat sich geändert? Mit Hilfe der Speicherbarierre Acquire/Release können wir eine „Vorher”-Beziehung erschaffen.
- „Freigeben” stellt sicher, dass alle vorherigen Schreibvorgänge (z. B.
g_data = 123) sichtbar werden. - „Erwerben” stellt sicher, dass alle nachfolgenden Lesevorgänge diese Schreibvorgänge sehen.
Mit der Acquire/Release-Speicherbarierre haben wir so etwas wie eine „unsichtbare Barriere” geschaffen. Stellen Sie sich es so vor:
Mit release:
„Alles, was davor passiert, muss sichtbar sein, bevor das Flag sichtbar wird.”
Mit acquire:
„Sobald ich das Flag sehe, muss ich auch alles sehen, was davor passiert ist.”
Zusammenfassung:
std::memory_order_relaxed→
Nur atomarer Zugriff, keine Reihenfolge, keine Synchronisierung. Hohe Performanz, keine Einschränkungen in der Reihenfolge der Hardware-Befehle (Optimizer).std::memory_order_releaseundstd::memory_order_acquire: →
Legt eine threadübergreifende Reihenfolge fest („Cross-Thread Ordering”).
Beispiel:
Ein klassisches Beispiel für die Speicherbarierre std::memory_order_relaxed ist ein globaler Statistik-Zähler
(z. B. ein Request-Counter in einem Webserver).
Das Szenario könnte ein Thread Pool mit 100 verfügbaren Threads sein, die gleichzeitig eingehenden Anfragen sind (quasi)-parallel zu verarbeiten. Die Aufgabe besteht nun darin, einfach nur zählen, wie viele Anfragen insgesamt reingekommen sind.
01: std::atomic<int> requestCount{};
02: constexpr std::size_t NumRequests{ 10'000 };
03:
04: void handleRequest()
05: {
06: // get the job done...
07: requestCount.fetch_add(1, std::memory_order_relaxed);
08: }
09:
10: void test()
11: {
12: std::vector<std::thread> threads;
13: threads.reserve(NumRequests);
14:
15: for (std::size_t i{}; i != NumRequests; ++i) {
16: threads.emplace_back(handleRequest);
17: }
18:
19: for (auto& t : threads) {
20: t.join();
21: }
22:
23: Logger::log(std::cout, "Total requests: ", requestCount.load(std::memory_order_relaxed));
24: }Ausgabe:
Total requests: 10000
Eigenschaften des vorgestellten Beispiels:
Performance:
Auf Architekturen wie ARM oder PowerPC erzwingen strengere Reihenfolgen (wie std::memory_order_seq_cst)
teure „Memory Barriers” (Hardware-Befehle, die den CPU-Pipeline-Fluss stoppen, um Cache-Konsistenz zu erzwingen).
std::memory_order_relaxed lässt die CPU einfach mit voller Geschwindigkeit weiterlaufen.
Keine Logik-Abhängigkeit:
Der Zähler ist völlig unabhängig von anderen Daten. Wenn Thread A den Zähler erhöht, muss Thread B nicht sofort wissen,
welche Daten Thread A vorher geschrieben hat. Es zählt nur das Endergebnis.
Garantie:
Trotz der Speicherbarierre std::memory_order_relaxed ist die Operation atomar.
Es geht niemals ein Inkrement verloren. Es finden keine Race Conditions beim Schreiben statt,
was bei Verwendung einer normalen int-Variablen mit einer ++-Operation der Fall wäre.
Man kann sich das Prinzip von Acquire/Release wie eine Stafettenübergabe beim Stafffellaufen vorstellen: Es geht nicht nur darum, dass der Stab (die atomare Variable) ankommt, sondern dass auch alle anderen Informationen (die restlichen Daten) sicher mit übergeben werden.
Hier ist das Standard-Beispiel: Ein Daten-Producer und ein Daten-Consumer.
01: std::atomic<bool> g_ready{ false };
02: std::string g_data{ "<empty>" }; // normal, non-atomare variable
03:
04: void producer()
05: {
06: std::thread::id tid{ std::this_thread::get_id() };
07:
08: Logger::log(std::cout, "Producer: data = [", g_data, "]");
09:
10: std::this_thread::sleep_for(std::chrono::seconds{ 3 });
11:
12: Logger::log(std::cout, "Producer: writing data now ...");
13:
14: // (1) Write at first data, non synchronized
15: g_data = "<secret password>";
16:
17: // (2) Everything I did before this point,
18: // must be visible to anyone reading 'g_ready' with ACQUIRE
19: g_ready.store(true, std::memory_order_release);
20:
21: Logger::log(std::cout, "Producer: Done.");
22: }
23:
24: void consumer()
25: {
26: std::thread::id tid{ std::this_thread::get_id() };
27:
28: Logger::log(std::cout, "Consumer: data = [", g_data, "]");
29:
30: // (3) ACQUIRE: I wait until 'ready' is true. Once that happens,
31: // I guarantee that I will also see all previous write accesses (such as 'data').
32: while (!g_ready.load(std::memory_order_acquire))
33: ;
34:
35: // (4) Secure access: data is guaranteed "Secret password"
36: Logger::log(std::cout, "Consumer: received data [", g_data, "]");
37: }Ausgabe:
[1]: Producer: data = [<empty>]
[2]: Consumer: data = [<empty>]
[1]: Producer: writing data now ...
[2]: Consumer: received data [<secret password>]
[1]: Producer: Done.
Warum reicht die Speicherbarierre Relaxed hier nicht?
Würde man hier std::memory_order_relaxed verwenden, könnte folgendes passieren:
-
CPU-Optimierung:
Die CPU oder der Compiler könnten die Zeilen im Producer vertauschen (zuerstready = true, danndata = ...). -
Sichtbarkeit:
Der Consumer-Thread sieht zwar, dassreadyauftruespringt, aber sein lokaler CPU-Cache hat vielleicht noch den alten, leeren Wert vondata.
Im Buch „C++ Concurrency in Action” von Anthony Williams sind in Kapitel 5.4 einige weitere, vertiefende Beispiele zu dieser Thematik:
01: void write_x()
02: {
03: x.store(true, order);
04: }
05:
06: void write_y()
07: {
08: y.store(true, order);
09: }
10:
11: void read_x_then_y()
12: {
13: while (!x.load(order));
14: if (y.load(order))
15: ++z;
16: }
17:
18: void read_y_then_x()
19: {
20: while (!y.load(order));
21: if (x.load(order))
22: ++z;
23: }Welchen Wert hat z nach Ausführung aller vier Funktion im Kontext von vier Threads?
- Die beiden Funktionen
read_x_then_yundread_y_then_xblockieren zunächst, daxundymit dem 0 vorbelegt sind. - Irgendwann gelangt eine der beiden Funktionen
write_xoderwrite_yzur Ausführung, sagen wirwrite_x. xwird auf den Werttruegesetzt.- Nun endet die
while-Endlosschleife in Funktionread_x_then_y. - Da
write_ynoch nicht ausgeführt worden sein muss, bleibt der Wert vonzauf 0 stehen. - Für diesen Fall bleibt die
while-Endlosschleife in Funktionread_y_then_ximmer noch aktiv, dayja den Wert 0 hat. - Irgendwann gelangt die Funktione
write_yzur Ausführung. - Nur wird
yauf den Werttruegesetzt. - Damit blockiert die
while-Schleife in Funktionread_y_then_xnicht mehr. - Auf Grund der vorherigen Überlegungen wurde
write_xaber schon ausgeführt. - Damit wird in Funktion
read_y_then_xderif-Zweig ausgeführt und der Wert vonzinkrementiert.
Die Ideen zu den Beispielen aus diesem Abschnitt stammen aus diesen Artikeln:
std::atomic, Explained Properly: Memory Ordering Without the Hand-Waving.
Lock-Free Programming in C++: Compare-And-Swap Without the Magic.