Dies ist absolut das, was C ++ als Datenrennen definiert, das undefiniertes Verhalten verursacht, selbst wenn ein Compiler zufällig Code erstellt hat, der das getan hat, was Sie sich auf einem Zielcomputer erhofft haben. Sie müssen es std::atomic
für zuverlässige Ergebnisse verwenden, aber Sie können es verwenden, memory_order_relaxed
wenn Sie sich nicht für eine Nachbestellung interessieren. Im Folgenden finden Sie einige Beispiele für die Code- und ASM-Ausgabe mit fetch_add
.
Aber zuerst ist die Assemblersprache Teil der Frage:
Da num ++ eine Anweisung ( add dword [num], 1
) ist, können wir daraus schließen, dass num ++ in diesem Fall atomar ist?
Speicherzielanweisungen (außer reinen Speichern) sind Lese-, Änderungs- und Schreibvorgänge, die in mehreren internen Schritten ausgeführt werden . Es wird kein Architekturregister geändert, aber die CPU muss die Daten intern speichern, während sie sie über ihre ALU sendet . Die eigentliche Registerdatei ist nur ein kleiner Teil des Datenspeichers in selbst der einfachsten CPU, wobei Latches die Ausgänge einer Stufe als Eingänge für eine andere Stufe usw. usw. halten.
Speicheroperationen von anderen CPUs können zwischen Laden und Speichern global sichtbar werden. Das heißt, zwei Threads, die add dword [num], 1
in einer Schleife laufen , würden sich gegenseitig in die Läden führen. (Sehen @ Margarets Antwort für ein schönes Diagramm). Nach 40.000 Inkrementen von jedem der beiden Threads ist der Zähler auf echter Multi-Core-x86-Hardware möglicherweise nur um ~ 60.000 (nicht 80.000) gestiegen.
„Atomic“, aus dem griechischen Wort unteilbar, bedeutet , dass kein Beobachter sieht den Vorgang als getrennte Schritte. Das physische / elektrische sofortige Auftreten für alle Bits gleichzeitig ist nur eine Möglichkeit, dies für eine Last oder einen Speicher zu erreichen, aber dies ist nicht einmal für eine ALU-Operation möglich. In meiner Antwort auf Atomicity auf x86 habe ich viel detaillierter auf reine Lasten und reine Speicher eingegangen , während sich diese Antwort auf Lesen, Ändern, Schreiben konzentriert.
Das lock
Präfix kann auf viele Lese-, Änderungs- und Schreibbefehle (Speicherziel) angewendet werden, um die gesamte Operation in Bezug auf alle möglichen Beobachter im System atomar zu machen (andere Kerne und DMA-Geräte, kein Oszilloskop, das an die CPU-Pins angeschlossen ist). Deshalb existiert es. (Siehe auch diese Fragen und Antworten ).
So lock add dword [num], 1
ist atomar . Ein CPU-Kern, der diesen Befehl ausführt, würde die Cache-Zeile in ihrem privaten L1-Cache im modifizierten Zustand fixieren, sobald das Laden Daten aus dem Cache liest, bis der Speicher sein Ergebnis zurück in den Cache schreibt. Dies verhindert, dass ein anderer Cache im System zu einem beliebigen Zeitpunkt vom Laden bis zum Speichern eine Kopie der Cache-Zeile gemäß den Regeln des MESI-Cache-Kohärenzprotokolls (oder der MOESI / MESIF-Versionen davon, die von Multi-Core-AMD / verwendet werden) hat. Intel-CPUs). Daher scheinen Operationen durch andere Kerne entweder vor oder nach, nicht während zu erfolgen.
Ohne das lock
Präfix könnte ein anderer Kern das Eigentum an der Cache-Zeile übernehmen und sie nach dem Laden, jedoch vor unserem Geschäft ändern, sodass ein anderes Geschäft zwischen dem Laden und dem Geschäft global sichtbar wird. Mehrere andere Antworten verstehen dies falsch und behaupten, ohne dass lock
Sie widersprüchliche Kopien derselben Cache-Zeile erhalten würden. Dies kann in einem System mit kohärenten Caches niemals passieren.
(Wenn eine lock
ed-Befehl in einem Speicher ausgeführt wird, der zwei Cache-Zeilen umfasst, ist viel mehr Arbeit erforderlich, um sicherzustellen, dass die Änderungen an beiden Teilen des Objekts atomar bleiben, da sie sich an alle Beobachter ausbreiten, sodass kein Beobachter ein Zerreißen sehen kann müssen den gesamten Speicherbus sperren, bis die Daten auf den Speicher treffen. Richten Sie Ihre atomaren Variablen nicht falsch aus!)
Beachten Sie, dass das lock
Präfix einen Befehl auch in eine vollständige Speicherbarriere (wie MFENCE ) verwandelt , wodurch alle Neuordnungen zur Laufzeit gestoppt werden und somit eine sequentielle Konsistenz erzielt wird . (Siehe Jeff Preshings ausgezeichneten Blog-Beitrag . Auch seine anderen Beiträge sind ausgezeichnet und erklären deutlich viele gute Dinge über sperrenfreies Programmieren , von x86 und anderen Hardwaredetails bis hin zu C ++ - Regeln.)
Auf einer Einprozessor - Maschine oder in einem Single-Threaded - Prozess , ein einzelner RMW Befehl tatsächlich ist atomar ohne lock
Präfix. Die einzige Möglichkeit für anderen Code, auf die gemeinsam genutzte Variable zuzugreifen, besteht darin, dass die CPU einen Kontextwechsel durchführt, der nicht mitten in einer Anweisung erfolgen kann. So kann eine Ebene dec dword [num]
zwischen einem Single-Thread-Programm und seinen Signalhandlern oder in einem Multi-Thread-Programm, das auf einem Single-Core-Computer ausgeführt wird, synchronisiert werden. Siehe die zweite Hälfte meiner Antwort auf eine andere Frage und die Kommentare darunter, wo ich dies genauer erkläre.
Zurück zu C ++:
Es ist völlig falsch, es zu verwenden, num++
ohne dem Compiler mitzuteilen, dass Sie es zum Kompilieren zu einer einzelnen Lese-, Änderungs- und Schreibimplementierung benötigen:
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
Dies ist sehr wahrscheinlich, wenn Sie den Wert von num
später verwenden: Der Compiler hält ihn nach dem Inkrement in einem Register aktiv. Also auch wenn du prüfst wienum++
Kompilierung selbst durchgeführt wird, kann sich eine Änderung des umgebenden Codes darauf auswirken.
(Wenn der Wert später nicht benötigt wird, inc dword [num]
wird er bevorzugt. Moderne x86-CPUs führen einen Speicherziel-RMW-Befehl mindestens so effizient aus wie drei separate Befehle. Unterhaltsame Tatsache: Gibt gcc -O3 -m32 -mtune=i586
dies tatsächlich aus , da die superskalare Pipeline von (Pentium) P5 dies nicht tat Dekodieren Sie komplexe Anweisungen nicht in mehrere einfache Mikrooperationen, wie dies bei P6 und späteren Mikroarchitekturen der Fall ist Weitere Informationen finden Befehlstabellen / im Handbuch für Mikroarchitekturen von Agner Fogx86 Tag-Wiki für viele nützliche Links (einschließlich Intels x86 ISA-Handbücher, die frei als PDF verfügbar sind)).
Verwechseln Sie das Zielspeichermodell (x86) nicht mit dem C ++ - Speichermodell
Eine Neuordnung zur Kompilierungszeit ist zulässig . Der andere Teil dessen, was Sie mit std :: atomic erhalten, ist die Kontrolle über die Neuordnung zur Kompilierungszeit, um sicherzustellen, dass Ihrenum++
erst nach einer anderen Operation global sichtbar wird.
Klassisches Beispiel: Speichern einiger Daten in einem Puffer, damit ein anderer Thread sie anzeigen kann, und Setzen eines Flags. Obwohl x86 Lade- / Freigabespeicher kostenlos erwirbt, müssen Sie dem Compiler dennoch mitteilen, dass er nicht mithilfe von neu bestellen soll flag.store(1, std::memory_order_release);
.
Sie können erwarten, dass dieser Code mit anderen Threads synchronisiert wird:
// flag is just a plain int global, not std::atomic<int>.
flag--; // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo); // doesn't look at flag, and the compilers knows this. (Assume it can see the function def). Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;
Aber das wird es nicht. Dem Compiler steht es frei, flag++
den Funktionsaufruf zu verschieben (wenn er die Funktion einbindet oder weiß, dass sie nicht angezeigt wird flag
). Dann kann es die Modifikation komplett wegoptimieren, weil flag
es nicht gerade ist volatile
. (Und nein, C ++ volatile
ist kein brauchbarer Ersatz für std :: Atom. Std :: Atom der Compiler , dass die Werte im Speicher übernehmen machen kann asynchron ähnlich modifiziert werden volatile
, aber es gibt noch viel mehr zu bieten als das. Auch volatile std::atomic<int> foo
nicht die das gleiche wie std::atomic<int> foo
mit @Richard Hodges besprochen.)
Durch das Definieren von Datenrassen für nichtatomare Variablen als undefiniertes Verhalten kann der Compiler weiterhin Lasten und Senken aus Schleifen herausheben und viele andere Optimierungen für den Speicher vornehmen, auf die mehrere Threads möglicherweise verweisen. (In diesem LLVM-Blog erfahren Sie mehr darüber, wie UB Compileroptimierungen ermöglicht.)
Wie bereits erwähnt, handelt es sich bei dem x86- lock
Präfix um eine vollständige Speicherbarriere. Bei der Verwendung num.fetch_add(1, std::memory_order_relaxed);
von x86 wird also derselbe Code generiert num++
(der Standardwert ist die sequentielle Konsistenz), bei anderen Architekturen (wie ARM) kann dies jedoch wesentlich effizienter sein. Selbst unter x86 ermöglicht Relaxed eine Neuordnung während der Kompilierungszeit.
Dies ist, was GCC tatsächlich auf x86 für einige Funktionen tut, die mit einer std::atomic
globalen Variablen arbeiten.
Sehen Sie sich den Quell- und Assemblersprachencode an, der im Godbolt-Compiler-Explorer gut formatiert ist . Sie können andere Zielarchitekturen auswählen, einschließlich ARM, MIPS und PowerPC, um zu sehen, welche Art von Assembler-Code Sie von Atomics für diese Ziele erhalten.
#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
num.fetch_add(1, std::memory_order_relaxed);
}
int load_num() { return num; } // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.
# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
lock add DWORD PTR num[rip], 1 #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
ret
inc_seq_cst():
lock add DWORD PTR num[rip], 1
ret
load_num():
mov eax, DWORD PTR num[rip]
ret
store_num(int):
mov DWORD PTR num[rip], edi
mfence ##### seq_cst stores need an mfence
ret
store_num_release(int):
mov DWORD PTR num[rip], edi
ret ##### Release and weaker doesn't.
store_num_relaxed(int):
mov DWORD PTR num[rip], edi
ret
Beachten Sie, wie MFENCE (eine vollständige Barriere) nach einer Speicherung mit sequentieller Konsistenz benötigt wird. x86 ist im Allgemeinen stark geordnet, aber eine Neuordnung von StoreLoad ist zulässig. Ein Speicherpuffer ist für eine gute Leistung auf einer Pipeline-CPU ohne Betrieb unerlässlich. Jeff Preshing des Speicher Neuordnen Gefangen in der Tat zeigt die Folgen nicht MFENCE verwenden, mit echtem Code Neuordnungs zeigen auf echte Hardware geschehen.
Betreff: Diskussion in Kommentaren zu @Richard Hodges 'Antwort über Compiler, die std :: atomic- num++; num-=2;
Operationen in einer num--;
Anweisung zusammenführen :
Eine separate Frage und Antwort zu demselben Thema: Warum führen Compiler keine redundanten std :: atomic-Schreibvorgänge zusammen? , wo meine Antwort viel von dem wiedergibt, was ich unten geschrieben habe.
Aktuelle Compiler tun dies (noch) nicht, aber nicht, weil sie es nicht dürfen. C ++ WG21 / P0062R1: Wann sollten Compiler die Atomics optimieren? diskutiert die Erwartung, die viele Programmierer haben, dass Compiler keine "überraschenden" Optimierungen vornehmen, und was der Standard tun kann, um Programmierern die Kontrolle zu geben. N4455 beschreibt viele Beispiele für Dinge, die optimiert werden können, einschließlich dieses. Es wird darauf hingewiesen, dass Inlining und konstante Ausbreitung Dinge einführen fetch_or(0)
können, die sich möglicherweise nur in eine load()
(aber immer noch erworbene und freigegebene Semantik) verwandeln können , selbst wenn die ursprüngliche Quelle keine offensichtlich redundanten Atomoperationen hatte.
Die wahren Gründe, warum Compiler dies (noch) nicht tun, sind: (1) Niemand hat den komplizierten Code geschrieben, der es dem Compiler ermöglichen würde, dies sicher zu tun (ohne jemals etwas falsch zu machen), und (2) er verstößt möglicherweise gegen das Prinzip der geringsten Überraschung . Sperrfreier Code ist schwer genug, um überhaupt richtig zu schreiben. Seien Sie also nicht lässig im Umgang mit Atomwaffen: Sie sind nicht billig und optimieren nicht viel. Es ist jedoch nicht immer einfach, redundante atomare Operationen zu vermeiden std::shared_ptr<T>
, da es keine nicht-atomare Version davon gibt (obwohl eine der Antworten hier eine einfache Möglichkeit bietet, ein shared_ptr_unsynchronized<T>
für gcc zu definieren ).
Zurück zum num++; num-=2;
Kompilieren, als ob es so wäre num--
: Compiler dürfen dies tun, es num
sei denn volatile std::atomic<int>
. Wenn eine Neuordnung möglich ist, kann der Compiler nach der Als-ob-Regel zur Kompilierungszeit entscheiden, dass dies immer so geschieht. Nichts garantiert, dass ein Beobachter die Zwischenwerte (das num++
Ergebnis) sehen kann.
Das heißt, wenn die Reihenfolge, in der zwischen diesen Operationen nichts global sichtbar wird, mit den Bestellanforderungen der Quelle kompatibel ist (gemäß den C ++ - Regeln für die abstrakte Maschine, nicht für die Zielarchitektur), kann der Compiler lock dec dword [num]
anstelle von lock inc dword [num]
/ eine einzelne ausgeben lock sub dword [num], 2
.
num++; num--
kann nicht verschwinden, da es immer noch eine Beziehung zum Synchronisieren mit anderen Threads hat, die es betrachten num
, und es ist sowohl ein Erfassungsladen als auch ein Release-Speicher, der die Neuordnung anderer Vorgänge in diesem Thread nicht zulässt. Für x86 kann dies möglicherweise zu einem MFENCE anstelle eines lock add dword [num], 0
(dh num += 0
) kompiliert werden .
Wie in PR0062 erläutert , kann eine aggressivere Zusammenführung nicht benachbarter Atomoperationen zur Kompilierungszeit schlecht sein (z. B. wird ein Fortschrittszähler am Ende statt jeder Iteration nur einmal aktualisiert), aber auch die Leistung ohne Nachteile verbessern (z. B. das Überspringen der atomic inc / dec of ref zählt, wenn eine Kopie von a shared_ptr
erstellt und zerstört wird, wenn der Compiler nachweisen kann, dass ein anderes shared_ptr
Objekt für die gesamte Lebensdauer des temporären Objekts vorhanden ist.)
Selbst das num++; num--
Zusammenführen kann die Fairness einer Sperrimplementierung beeinträchtigen, wenn ein Thread sofort entsperrt und erneut gesperrt wird. Wenn es im asm nie veröffentlicht wird, geben selbst Hardware-Arbitrierungsmechanismen einem anderen Thread an diesem Punkt keine Chance, die Sperre zu ergreifen.
Mit den aktuellen gcc6.2 und clang3.9 erhalten Sie lock
auch memory_order_relaxed
im offensichtlich optimierbaren Fall noch separate ed-Operationen . ( Godbolt Compiler Explorer, damit Sie sehen können, ob die neuesten Versionen unterschiedlich sind.)
void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
num.fetch_add( 1, std::memory_order_relaxed);
num.fetch_add(-1, std::memory_order_relaxed);
num.fetch_add( 6, std::memory_order_relaxed);
num.fetch_add(-5, std::memory_order_relaxed);
//num.fetch_add(-1, std::memory_order_relaxed);
}
multiple_ops_relaxed(std::atomic<unsigned int>&):
lock add DWORD PTR [rdi], 1
lock sub DWORD PTR [rdi], 1
lock add DWORD PTR [rdi], 6
lock sub DWORD PTR [rdi], 5
ret
add
das atomar ist?