Im Allgemeinen ist for int num
, num++
(oder ++num
) als Lese-, Änderungs- und Schreiboperation nicht atomar . Aber ich sehe oft, dass Compiler, zum Beispiel GCC , den folgenden Code dafür generieren ( versuchen Sie es hier ):
num++
Können wir daraus schließen, dass Zeile 5, die einer Anweisung entspricht, in diesem Fall num++
atomar ist?
Und wenn ja, bedeutet dies, dass so generierte num++
in gleichzeitigen (Multithread-) Szenarien verwendet werden können, ohne dass die Gefahr von Datenrennen besteht (dh wir müssen es beispielsweise nicht schaffen std::atomic<int>
und die damit verbundenen Kosten verursachen, da dies der Fall ist Atom sowieso)?
AKTUALISIEREN
Beachten Sie, dass diese Frage ist nicht , ob Zuwachs ist Atom (es ist nicht und das war und ist die erste Zeile der Frage). Es geht darum, ob es sich um bestimmte Szenarien handeln kann, dh ob in bestimmten Fällen eine Anweisung ausgeführt werden kann, um den Overhead des lock
Präfixes zu vermeiden . Und wie die akzeptierte Antwort im Abschnitt über Einprozessor-Maschinen erwähnt, sowie diese Antwort , die Konversation in ihren Kommentaren und anderen erklären, kann es (obwohl nicht mit C oder C ++).
add
das atomar ist?std::atomic<int>
.add
Befehls könnte ein anderer Kern diese Speicheradresse aus dem Cache dieses Kerns stehlen und ändern. Auf einer x86-CPUadd
benötigt der Befehl einlock
Präfix, wenn die Adresse für die Dauer des Vorgangs im Cache gesperrt werden muss.Antworten:
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 mitfetch_add
.Aber zuerst ist die Assemblersprache Teil der Frage:
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 dasslock
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 Ebenedec 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: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: Gibtgcc -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 Ihre
num++
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:
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 wirdflag
). Dann kann es die Modifikation komplett wegoptimieren, weilflag
es nicht gerade istvolatile
. (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 werdenvolatile
, aber es gibt noch viel mehr zu bieten als das. Auchvolatile std::atomic<int> foo
nicht die das gleiche wiestd::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 Verwendungnum.fetch_add(1, std::memory_order_relaxed);
von x86 wird also derselbe Code generiertnum++
(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.
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 einernum--;
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 eineload()
(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, einshared_ptr_unsynchronized<T>
für gcc zu definieren ).Zurück zum
num++; num-=2;
Kompilieren, als ob es so wärenum--
: Compiler dürfen dies tun, esnum
sei dennvolatile 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 (dasnum++
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 vonlock inc dword [num]
/ eine einzelne ausgebenlock sub dword [num], 2
.num++; num--
kann nicht verschwinden, da es immer noch eine Beziehung zum Synchronisieren mit anderen Threads hat, die es betrachtennum
, 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 eineslock add dword [num], 0
(dhnum += 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 anderesshared_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
auchmemory_order_relaxed
im offensichtlich optimierbaren Fall noch separate ed-Operationen . ( Godbolt Compiler Explorer, damit Sie sehen können, ob die neuesten Versionen unterschiedlich sind.)quelle
mov eax, 1
xadd [num], eax
(ohne Sperrpräfix) Post-Inkrement implementierennum++
, aber das tun Compiler nicht.... und jetzt aktivieren wir Optimierungen:
OK, geben wir ihm eine Chance:
Ergebnis:
Ein anderer beobachtender Thread (selbst wenn Cache-Synchronisationsverzögerungen ignoriert werden) hat keine Möglichkeit, die einzelnen Änderungen zu beobachten.
vergleichen mit:
wo das Ergebnis ist:
Jetzt ist jede Änderung: -
Atomizität ist nicht nur auf Befehlsebene, sondern umfasst die gesamte Pipeline vom Prozessor über die Caches zum Speicher und zurück.
Weitere Informationen
In Bezug auf die Wirkung von Optimierungen von Updates von
std::atomic
s.Der c ++ - Standard hat die 'als ob'-Regel, nach der der Compiler Code neu anordnen und sogar Code neu schreiben kann, vorausgesetzt, das Ergebnis hat genau die gleichen beobachtbaren Effekte (einschließlich Nebenwirkungen), als hätte es einfach Ihren ausgeführt Code.
Die Als-ob-Regel ist konservativ, insbesondere was die Atomik betrifft.
Erwägen:
Da es keine Mutex-Sperren, Atomics oder andere Konstrukte gibt, die die Sequenzierung zwischen Threads beeinflussen, würde ich argumentieren, dass der Compiler diese Funktion als NOP neu schreiben kann, z.
Dies liegt daran, dass im c ++ - Speichermodell keine Möglichkeit besteht, dass ein anderer Thread das Ergebnis des Inkrements beobachtet. Es wäre natürlich anders, wenn
num
es so wärevolatile
(Macht Einfluss Hardware-Verhalten). In diesem Fall ist diese Funktion jedoch die einzige Funktion, die diesen Speicher ändert (andernfalls ist das Programm fehlerhaft).Dies ist jedoch ein anderes Ballspiel:
num
ist ein Atom. Änderungen daran müssen für andere Threads, die sie beobachten, sichtbar sein. Änderungen, die diese Threads selbst vornehmen (z. B. das Setzen des Werts auf 100 zwischen Inkrementieren und Dekrementieren), haben sehr weitreichende Auswirkungen auf den möglichen Wert von num.Hier ist eine Demo:
Beispielausgabe:
quelle
add dword [rdi], 1
ist nicht atomar (ohnelock
Präfix). Die Last ist atomar und der Speicher ist atomar, aber nichts hindert einen anderen Thread daran, die Daten zwischen der Last und dem Speicher zu ändern. Der Store kann also auf eine Änderung zugreifen, die von einem anderen Thread vorgenommen wurde. Siehe jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . Außerdem sind Jeff Preshings sperrfreie Artikel extrem gut und er erwähnt das grundlegende RMW-Problem in diesem Intro-Artikel.num++
und erfordertnum--
. Wenn Sie im Standard einen Abschnitt finden, der dies erfordert, wird dies geregelt. Ich bin mir ziemlich sicher, dass es nur erforderlich ist, dass kein Beobachter jemals eine falsche Neuordnung sehen kann, was dort keine Ausbeute erfordert. Ich denke, es ist nur ein Problem mit der Qualität der Implementierung.Ohne viele Komplikationen ist eine Anweisung wie diese
add DWORD PTR [rbp-4], 1
sehr CISC- artig .Es werden drei Operationen ausgeführt: Laden des Operanden aus dem Speicher, Inkrementieren, Speichern des Operanden zurück in den Speicher.
Während dieser Operationen erfasst und gibt die CPU den Bus zweimal frei, dazwischen kann ihn auch jeder andere Agent erfassen, was die Atomizität verletzt.
X wird nur einmal erhöht.
quelle
Die Add-Anweisung ist nicht atomar. Es verweist auf den Speicher, und zwei Prozessorkerne können einen unterschiedlichen lokalen Cache dieses Speichers haben.
IIRC, die atomare Variante des Befehls add, heißt lock xadd
quelle
lock xadd
implementiert C ++ std :: atomicfetch_add
und gibt den alten Wert zurück. Wenn Sie das nicht benötigen, verwendet der Compiler die normalen Speicherzielanweisungen mit einemlock
Präfix.lock add
oderlock inc
.add [mem], 1
wäre auf einem SMP-Rechner ohne Cache immer noch nicht atomar, siehe meine Kommentare zu anderen Antworten.Es ist gefährlich, Schlussfolgerungen zu ziehen, die auf einer durch "Reverse Engineering" erzeugten Baugruppe beruhen. Zum Beispiel scheinen Sie Ihren Code mit deaktivierter Optimierung kompiliert zu haben, andernfalls hätte der Compiler diese Variable weggeworfen oder 1 direkt in sie geladen, ohne sie aufzurufen
operator++
. Da sich die generierte Baugruppe aufgrund von Optimierungsflags, Ziel-CPU usw. erheblich ändern kann, basiert Ihre Schlussfolgerung auf Sand.Auch Ihre Vorstellung, dass eine Montageanweisung bedeutet, dass eine Operation atomar ist, ist ebenfalls falsch. Dies
add
ist auf Systemen mit mehreren CPUs selbst auf der x86-Architektur nicht atomar.quelle
Auch wenn Ihr Compiler dies immer als atomare Operation ausgegeben hat, greifen Sie zu
num
ausgegeben hätte, würde der gleichzeitige von einem anderen Thread aus ein Datenrennen gemäß den Standards C ++ 11 und C ++ 14 darstellen und das Programm hätte ein undefiniertes Verhalten.Aber es ist schlimmer als das. Erstens kann, wie bereits erwähnt, die Anweisung, die der Compiler beim Inkrementieren einer Variablen generiert, von der Optimierungsstufe abhängen. Zweitens kann der Compiler andere Speicherzugriffe neu anordnen,
++num
wenn diesnum
nicht atomar ist, zSelbst wenn wir optimistisch davon ausgehen, dass dies
++ready
"atomar" ist und der Compiler die Prüfschleife nach Bedarf generiert (wie gesagt, es ist UB und daher kann der Compiler sie entfernen, durch eine Endlosschleife ersetzen usw.) Der Compiler verschiebt möglicherweise immer noch die Zeigerzuweisung oder noch schlimmer die Initialisierung vonvector
auf einen Punkt nach der Inkrementierungsoperation, was zu Chaos im neuen Thread führt. In der Praxis wäre ich überhaupt nicht überrascht, wenn ein optimierender Compiler dieready
Variable und die Prüfschleife vollständig entfernen würde , da dies das beobachtbare Verhalten unter Sprachregeln nicht beeinträchtigt (im Gegensatz zu Ihren privaten Hoffnungen).Tatsächlich habe ich auf der letztjährigen Meeting C ++ - Konferenz von zwei gehört Compiler-Entwicklern gehört, dass sie sehr gerne Optimierungen implementieren, die dazu führen, dass sich naiv geschriebene Multithread-Programme schlecht verhalten, sofern die Sprachregeln dies zulassen, wenn auch nur eine geringfügige Leistungsverbesserung festgestellt wird in korrekt geschriebenen Programmen.
Selbst wenn Sie sich nicht um Portabilität gekümmert haben und Ihr Compiler magisch nett war, handelt es sich bei der von Ihnen verwendeten CPU höchstwahrscheinlich um einen superskalaren CISC-Typ, der Anweisungen in Mikrooperationen aufteilt, neu anordnet und / oder spekulativ ausführt. in einem Ausmaß, das nur durch die Synchronisierung von Grundelementen wie (bei Intel) dem
LOCK
Präfix oder den Speicherzäunen begrenzt ist, um den Betrieb pro Sekunde zu maximieren.Um es kurz zu machen, die natürlichen Verantwortlichkeiten der thread-sicheren Programmierung sind:
Wenn Sie es auf Ihre eigene Weise tun möchten, funktioniert es möglicherweise in einigen Fällen, aber verstehen Sie, dass die Garantie ungültig ist und Sie allein für unerwünschte Ergebnisse verantwortlich sind. :-)
PS: Richtig geschriebenes Beispiel:
Dies ist sicher, weil:
ready
können nicht gemäß den Sprachregeln optimiert werden.++ready
geschieht-vor der Prüfung , die sieht ,ready
wie nicht Null ist , und andere Operationen nicht diese Vorgänge neu geordnet um werden können. Dies liegt daran, dass++ready
und die Prüfung sequentiell konsistent sind. Dies ist ein weiterer Begriff, der im C ++ - Speichermodell beschrieben wird und der diese spezifische Neuordnung verbietet. Daher darf der Compiler die Anweisungen nicht neu anordnen und muss der CPU auch mitteilen, dass er zB den Schreibvorgang nichtvec
nach dem Inkrement von aufschieben darfready
. Sequentiell konsistent ist die stärkste Garantie in Bezug auf Atomics im Sprachstandard. Geringere (und theoretisch günstigere) Garantien sind zB über andere Methoden verfügbarstd::atomic<T>
, aber diese sind definitiv nur für Experten und werden von den Compiler-Entwicklern möglicherweise nicht viel optimiert, da sie selten verwendet werden.quelle
ready
, würde er wahrscheinlichwhile (!ready);
zu etwas ähnlichem kompilierenif(!ready) { while(true); }
. Upvoted: Ein wesentlicher Bestandteil von std :: atomic ist die Änderung der Semantik, um zu jedem Zeitpunkt eine asynchrone Änderung anzunehmen. Wenn es normalerweise UB ist, können Compiler Lasten heben und Speicher aus Schleifen versenken.Auf einem Single-Core-x86-Computer ist ein
add
Befehl in Bezug auf anderen Code auf der CPU 1 im Allgemeinen atomar . Ein Interrupt kann keinen einzelnen Befehl in der Mitte aufteilen.Eine Ausführung außerhalb der Reihenfolge ist erforderlich, um die Illusion von Anweisungen zu bewahren, die einzeln in der Reihenfolge innerhalb eines einzelnen Kerns ausgeführt werden. Daher wird jede Anweisung, die auf derselben CPU ausgeführt wird, entweder vollständig vor oder vollständig nach dem Hinzufügen ausgeführt.
Moderne x86-Systeme sind Multi-Core-Systeme, daher gilt der Uniprozessor-Sonderfall nicht.
Wenn man auf einen kleinen eingebetteten PC abzielt und keine Pläne hat, den Code auf etwas anderes zu verschieben, könnte die atomare Natur des Befehls "Hinzufügen" ausgenutzt werden. Auf der anderen Seite werden Plattformen, auf denen Operationen von Natur aus atomar sind, immer knapper.
(Dies hilft Ihnen nicht , wenn Sie schreiben in C ++, though. Compiler keine Möglichkeit hat , zu verlangen ,
num++
zu einem Speicher-Ziel hinzufügen zu kompilieren oder xADD ohne einenlock
Präfix. Sie konnten wählen , ladennum
in ein Register und Speicher das Inkrement-Ergebnis mit einer separaten Anweisung und wird dies wahrscheinlich tun, wenn Sie das Ergebnis verwenden.)Fußnote 1: Das
lock
Präfix war sogar auf dem ursprünglichen 8086 vorhanden, da E / A-Geräte gleichzeitig mit der CPU arbeiten. Treiber auf einem Single-Core-System müssenlock add
einen Wert im Gerätespeicher atomar erhöhen , wenn das Gerät ihn auch ändern kann, oder in Bezug auf den DMA-Zugriff.quelle
Früher, als x86-Computer eine CPU hatten, stellte die Verwendung eines einzelnen Befehls sicher, dass Interrupts das Lesen / Ändern / Schreiben nicht aufteilten, und wenn der Speicher nicht auch als DMA-Puffer verwendet wurde, war er tatsächlich atomar (und C ++ erwähnte keine Threads im Standard, daher wurde dies nicht angesprochen.
Wenn es selten war, einen Dual-Prozessor (z. B. Pentium Pro mit zwei Sockeln) auf einem Kunden-Desktop zu haben, habe ich dies effektiv verwendet, um das LOCK-Präfix auf einem Single-Core-Computer zu vermeiden und die Leistung zu verbessern.
Heutzutage würde es nur gegen mehrere Threads helfen, die alle auf dieselbe CPU-Affinität eingestellt waren, sodass die Threads, um die Sie sich Sorgen machen, nur über die Zeitscheibe ins Spiel kommen, die abläuft und den anderen Thread auf derselben CPU (Kern) ausführt. Das ist nicht realistisch.
Bei modernen x86 / x64-Prozessoren wird der einzelne Befehl in mehrere Mikrooperationen aufgeteilt und außerdem das Lesen und Schreiben des Speichers gepuffert. Unterschiedliche Threads, die auf unterschiedlichen CPUs ausgeführt werden, sehen dies nicht nur als nicht atomar an, sondern können auch inkonsistente Ergebnisse in Bezug darauf anzeigen, was aus dem Speicher gelesen wird und was davon ausgegangen wird, dass andere Threads zu diesem Zeitpunkt gelesen haben: Sie müssen Speicherzäune hinzufügen , um gesund wiederherzustellen Verhalten.
quelle
a = 1; b = a;
gerade gespeicherte 1 korrekt zu laden.Nr https://www.youtube.com/watch?v=31g0YE61PLQ (Das ist nur ein Link zur „Nein“ Szene aus „The Office“)
Stimmen Sie zu, dass dies eine mögliche Ausgabe für das Programm wäre:
Beispielausgabe:
Wenn ja, kann der Compiler dies zur einzig möglichen Ausgabe für das Programm machen, je nachdem, wie der Compiler dies wünscht. dh ein main (), das nur 100s löscht.
Dies ist die "Als ob" -Regel.
Unabhängig von der Ausgabe können Sie sich die Thread-Synchronisation auf die gleiche Weise vorstellen. Wenn Thread A wiederholt
num++; num--;
und Thread Bnum
wiederholt liest , besteht eine mögliche gültige Verschachtelung darin, dass Thread B niemals zwischennum++
und liestnum--
. Da diese Verschachtelung gültig ist, kann der Compiler diese als einzig mögliche Verschachtelung festlegen. Und entfernen Sie einfach das Inkr / Decr vollständig.Hier gibt es einige interessante Implikationen:
(Stellen Sie sich vor, ein anderer Thread aktualisiert eine Fortschrittsbalken-Benutzeroberfläche basierend auf
progress
)Kann der Compiler daraus Folgendes machen:
wahrscheinlich ist das gültig. Aber wahrscheinlich nicht das, was der Programmierer sich erhofft hatte :-(
Das Komitee arbeitet immer noch an diesem Zeug. Derzeit "funktioniert" es, weil Compiler die Atomics nicht sehr optimieren. Das ändert sich aber.
Und selbst wenn
progress
es auch volatil wäre, wäre dies immer noch gültig:: - /
quelle
volatile
Objekten ermöglicht, wenn keine anderen Regeln verletzt werden. Zwei Standarddiskussionsdokumente diskutieren genau dies (Links in Richards Kommentar ), eines verwendet dasselbe Beispiel für einen Fortschrittszähler. Es handelt sich also um ein Problem mit der Implementierungsqualität, bis C ++ die Möglichkeiten zur Verhinderung standardisiert.lock
zu jeder Operation etwas hinzuzufügen . Oder eine Compiler + Uniprozessor-Kombination, bei der weder eine Neuordnung (dh "die guten alten Tage") alles atomar ist. Aber worum geht es dabei? Darauf kann man sich nicht wirklich verlassen. Es sei denn, Sie wissen, dass dies das System ist, für das Sie schreiben. (Selbst dann wäre es besser, wenn Atomic <int> keine zusätzlichen Operationen auf diesem System hinzufügt. Sie sollten also immer noch Standardcode schreiben ...)And just remove the incr/decr entirely.
das nicht ganz richtig ist. Es ist immer noch eine Erfassungs- und Freigabeoperationnum
. Auf x86num++;num--
konnte nur MFENCE kompiliert werden, aber definitiv nichts. (Es sei denn, die Gesamtprogrammanalyse des Compilers kann beweisen, dass nichts mit dieser Änderung von num synchronisiert ist und dass es keine Rolle spielt, ob einige Speicher von zuvor bis nach dem Laden von danach verzögert werden.) ZB wenn dies ein Entsperren und erneutes Laden war -lock-sofort-Anwendungsfall, Sie haben immer noch zwei separate kritische Abschnitte (möglicherweise mit mo_relaxed), nicht einen großen.Ja aber...
Atomic ist nicht das, was du sagen wolltest. Sie fragen wahrscheinlich das Falsche.
Das Inkrement ist sicherlich atomar . Sofern der Speicher nicht falsch ausgerichtet ist (und da Sie die Ausrichtung dem Compiler überlassen haben, ist dies nicht der Fall), wird er notwendigerweise innerhalb einer einzelnen Cache-Zeile ausgerichtet. Ohne spezielle Streaming-Anweisungen ohne Caching wird jeder Schreibvorgang durch den Cache geleitet. Komplette Cache-Zeilen werden atomar gelesen und geschrieben, niemals etwas anderes.
Daten, die kleiner als die Cacheline sind, werden natürlich auch atomar geschrieben (da sich die umgebende Cache-Zeile befindet).
Ist es threadsicher?
Dies ist eine andere Frage, und es gibt mindestens zwei gute Gründe, mit einem eindeutigen "Nein!" Zu antworten. .
Erstens besteht die Möglichkeit, dass ein anderer Kern eine Kopie dieser Cache-Zeile in L1 hat (L2 und höher wird normalerweise gemeinsam genutzt, aber L1 ist normalerweise pro Kern!) Und ändert diesen Wert gleichzeitig. Natürlich passiert das auch atomar, aber jetzt haben Sie zwei "richtige" (richtig, atomar, modifizierte) Werte - welcher ist jetzt der wirklich richtige?
Die CPU wird das natürlich irgendwie klären. Das Ergebnis entspricht jedoch möglicherweise nicht Ihren Erwartungen.
Zweitens gibt es eine Speicherreihenfolge oder eine andere Formulierung, bevor dies garantiert wird. Das Wichtigste an atomaren Anweisungen ist nicht so sehr, dass sie atomar sind . Es bestellt.
Sie haben die Möglichkeit, eine Garantie durchzusetzen, dass alles, was in Bezug auf das Gedächtnis geschieht, in einer garantierten, genau definierten Reihenfolge realisiert wird, in der Sie eine "Vorher-passiert" -Garantie haben. Diese Bestellung kann so "entspannt" (gelesen als: überhaupt keine) oder so streng sein, wie Sie es benötigen.
Sie können beispielsweise einen Zeiger auf einen Datenblock setzen (z. B. die Ergebnisse einer Berechnung) und dann das Flag "Daten sind bereit" atomar freigeben . Wer nun dieses Flag erwirbt, wird zu dem Gedanken gebracht, dass der Zeiger gültig ist. Und in der Tat wird es immer ein gültiger Zeiger sein, niemals etwas anderes. Das liegt daran, dass das Schreiben in den Zeiger vor der atomaren Operation stattgefunden hat.
quelle
Dass der Ausgang des einzelnen Compiler, auf einer bestimmten CPU - Architektur, mit Optimierungen deaktiviert (da gcc nicht einmal kompilieren ,
++
umadd
bei der Optimierung in einem quick & dirty Beispiel ), auf diese Weise zu implizieren scheint Inkrementieren atomar ist, bedeutet nicht , dieses Standard-kompatibel ist ( Sie würden undefiniertes Verhalten verursachen, wenn Sie versuchen, aufnum
einen Thread zuzugreifen. Dies ist sowieso falsch, daadd
es in x86 nicht atomar ist.Beachten Sie, dass Atomics (unter Verwendung des
lock
Anweisungspräfix) relativ schwer für x86 sind ( siehe diese relevante Antwort ), aber immer noch bemerkenswert weniger als ein Mutex, was in diesem Anwendungsfall nicht sehr geeignet ist.Die folgenden Ergebnisse stammen aus clang ++ 3.8 beim Kompilieren mit
-Os
.Inkrementieren eines int durch Referenz, die "normale" Art:
Dies setzt sich zusammen in:
Inkrementieren eines als Referenz übergebenen Int auf atomare Weise:
In diesem Beispiel, das nicht viel komplexer als der normale Weg ist, wird nur das
lock
Präfix zurincl
Anweisung hinzugefügt - aber Vorsicht, wie bereits erwähnt, ist dies nicht billig. Nur weil die Montage kurz aussieht, heißt das nicht, dass sie schnell ist.quelle
Wenn Ihr Compiler nur eine einzige Anweisung für das Inkrement verwendet und Ihr Computer Single-Threaded ist, ist Ihr Code sicher. ^^
quelle
Wenn Sie versuchen, denselben Code auf einem Nicht-x86-Computer zu kompilieren, werden schnell sehr unterschiedliche Assembly-Ergebnisse angezeigt.
Der Grund
num++
scheint atomar zu sein, weil auf x86-Computern das Inkrementieren einer 32-Bit-Ganzzahl tatsächlich atomar ist (vorausgesetzt, es findet kein Speicherabruf statt). Dies wird jedoch weder durch den c ++ - Standard garantiert, noch ist dies auf einem Computer der Fall, der den x86-Befehlssatz nicht verwendet. Daher ist dieser Code nicht plattformübergreifend vor Rennbedingungen geschützt.Sie haben auch keine starke Garantie dafür, dass dieser Code selbst auf einer x86-Architektur vor Race Conditions geschützt ist, da x86 keine Ladevorgänge und Speicher im Speicher einrichtet, es sei denn, dies wird ausdrücklich angewiesen. Wenn also mehrere Threads versucht haben, diese Variable gleichzeitig zu aktualisieren, werden möglicherweise zwischengespeicherte (veraltete) Werte erhöht
Der Grund, den wir haben
std::atomic<int>
und so weiter, ist, dass Sie, wenn Sie mit einer Architektur arbeiten, bei der die Atomizität grundlegender Berechnungen nicht garantiert ist, über einen Mechanismus verfügen, der den Compiler zwingt, atomaren Code zu generieren.quelle
add
tatsächlich atomar garantiert? Es würde mich nicht wundern, wenn Registerinkremente atomar wären, aber das ist kaum nützlich. Um das Registerinkrement für einen anderen Thread sichtbar zu machen, muss es sich im Speicher befinden. Dies würde zusätzliche Anweisungen zum Laden und Speichern erfordern, wodurch die Atomizität beseitigt wird. Ich verstehe, dass aus diesem Grund daslock
Präfix für Anweisungen vorhanden ist. Das einzig nützliche Atomadd
gilt für den dereferenzierten Speicher und verwendet daslock
Präfix, um sicherzustellen, dass die Cache-Zeile für die Dauer des Vorgangs gesperrt ist .add
ist atomar, aber ich habe klargestellt, dass dies nicht bedeutet, dass der Code rennsicher ist, da Änderungen nicht sofort global sichtbar werden.