In C ++ 11 normalerweise nie volatile
zum Threading verwenden, nur für MMIO
Aber TL: DR, es "funktioniert" wie atomar mit mo_relaxed
Hardware mit kohärenten Caches (dh alles); Es reicht aus, Compiler daran zu hindern, Vars in Registern zu führen. atomic
Es sind keine Speicherbarrieren erforderlich, um Atomizität oder Sichtbarkeit zwischen Threads zu erzeugen, sondern nur, um den aktuellen Thread vor / nach einer Operation warten zu lassen, um eine Reihenfolge zwischen den Zugriffen dieses Threads auf verschiedene Variablen zu erstellen. mo_relaxed
braucht nie irgendwelche Barrieren, nur laden, lagern oder RMW.
Für Roll-your-own atomics mit volatile
(und Inline-asm für Barrieren) in den schlechten alten Tagen vor C ++ 11 std::atomic
, volatile
war die einzige gute Möglichkeit , einige Dinge zur Arbeit zu kommen . Es hing jedoch von vielen Annahmen darüber ab, wie Implementierungen funktionierten, und wurde von keinem Standard garantiert.
Zum Beispiel verwendet der Linux-Kernel immer noch seine eigenen handgerollten Atomics mit volatile
, unterstützt jedoch nur einige spezifische C-Implementierungen (GNU C, Clang und möglicherweise ICC). Dies liegt zum Teil an GNU C-Erweiterungen und der Inline-Asm-Syntax und -Semantik, aber auch daran, dass einige Annahmen über die Funktionsweise von Compilern getroffen werden.
Es ist fast immer die falsche Wahl für neue Projekte; Sie können std::atomic
(mit std::memory_order_relaxed
) verwenden, um einen Compiler dazu zu bringen, denselben effizienten Maschinencode auszugeben, mit dem Sie arbeiten können volatile
. std::atomic
mit mo_relaxed
veralteten volatile
zum Einfädeln. (außer vielleicht, um Fehler bei der Fehloptimierung bei atomic<double>
einigen Compilern zu umgehen .)
Die interne Implementierung von std::atomic
On-Mainstream-Compilern (wie gcc und clang) wird nicht nur volatile
intern verwendet. Compiler stellen die integrierten Funktionen für Atomlast, Speicher und RMW direkt zur Verfügung. (zB GNU C- __atomic
Builtins, die mit "einfachen" Objekten arbeiten.)
Volatile ist in der Praxis verwendbar (aber nicht)
Das heißt, volatile
ist in der Praxis für Dinge wie ein exit_now
Flag auf allen (?) Vorhandenen C ++ - Implementierungen auf realen CPUs verwendbar , da CPUs funktionieren (kohärente Caches) und gemeinsame Annahmen darüber, wie volatile
es funktionieren soll. Aber sonst nicht viel und wird nicht empfohlen. Mit dieser Antwort soll erläutert werden, wie vorhandene CPUs und C ++ - Implementierungen tatsächlich funktionieren. Wenn Sie sich nicht darum kümmern, müssen Sie nur wissen, dass std::atomic
mo_relaxed volatile
für das Threading veraltet ist .
(Der ISO C ++ - Standard ist ziemlich vage und sagt nur, dass volatile
Zugriffe streng nach den Regeln der abstrakten C ++ - Maschine ausgewertet und nicht wegoptimiert werden sollten. Angesichts der Tatsache, dass echte Implementierungen den Speicheradressraum der Maschine verwenden, um den C ++ - Adressraum zu modellieren, Dies bedeutet, dass volatile
Lesevorgänge und Zuweisungen kompiliert werden müssen, um Anweisungen zu laden / speichern, um auf die Objektdarstellung im Speicher zuzugreifen.)
Wie eine andere Antwort hervorhebt, ist ein exit_now
Flag ein einfacher Fall von Kommunikation zwischen Threads, für den keine Synchronisierung erforderlich ist : Es wird nicht veröffentlicht, dass Array-Inhalte bereit sind oder ähnliches. Nur ein Geschäft, das sofort durch eine nicht optimierte Abwesenheitsladung in einem anderen Thread bemerkt wird.
// global
bool exit_now = false;
// in one thread
while (!exit_now) { do_stuff; }
// in another thread, or signal handler in this thread
exit_now = true;
Ohne flüchtig oder atomar erlaubt die Als-ob-Regel und die Annahme, dass kein Datenrenn-UB vorliegt, einem Compiler, es in asm zu optimieren, das das Flag nur einmal überprüft , bevor es in eine Endlosschleife eintritt (oder nicht). Genau das passiert im wirklichen Leben für echte Compiler. (Und optimieren Sie normalerweise viel davon, do_stuff
weil die Schleife nie beendet wird, sodass späterer Code, der möglicherweise das Ergebnis verwendet hat, nicht erreichbar ist, wenn wir in die Schleife eintreten.)
// Optimizing compilers transform the loop into asm like this
if (!exit_now) { // check once before entering loop
while(1) do_stuff; // infinite loop
}
Das Multithreading-Programm, das im optimierten Modus steckt, aber normal in -O0 ausgeführt wird, ist ein Beispiel (mit Beschreibung der ASM-Ausgabe von GCC), wie genau dies mit GCC auf x86-64 geschieht. Auch die MCU-Programmierung - C ++ O2-Optimierung unterbricht die Schleife der Elektronik. SE zeigt ein weiteres Beispiel.
Wir wollen normalerweise aggressive Optimierungen, die CSE und Hoist aus Schleifen laden, auch für globale Variablen.
Vor C ++ 11 gab volatile bool exit_now
es eine Möglichkeit , dies wie beabsichtigt zu machen (bei normalen C ++ - Implementierungen). In C ++ 11 gilt Data Race UB jedoch weiterhin für, volatile
sodass der ISO-Standard nicht garantiert , dass er überall funktioniert, selbst wenn HW-kohärente Caches vorausgesetzt werden.
Beachten Sie, dass bei breiteren Typen volatile
keine Garantie für mangelndes Reißen gegeben ist. Ich habe diese Unterscheidung hier ignoriert, bool
da sie bei normalen Implementierungen kein Problem darstellt. Aber das ist auch ein Teil dessen, warum volatile
UB immer noch dem Datenrennen unterliegt, anstatt einem entspannten Atom zu entsprechen.
Beachten Sie, dass "wie beabsichtigt" nicht bedeutet, dass der Thread darauf exit_now
wartet, dass der andere Thread tatsächlich beendet wird. Oder sogar, dass es darauf wartet, dass der flüchtige exit_now=true
Speicher überhaupt global sichtbar ist, bevor mit späteren Operationen in diesem Thread fortgefahren wird. ( atomic<bool>
Mit der Standardeinstellung mo_seq_cst
würde es warten, bis später seq_cst mindestens geladen wird. Bei vielen ISAs erhalten Sie nach dem Speichern nur eine vollständige Barriere.)
C ++ 11 bietet eine Nicht-UB-Methode, die dieselbe kompiliert
Ein "Weiter laufen" - oder "Jetzt beenden" -Flag sollte std::atomic<bool> flag
mit verwendet werdenmo_relaxed
Verwenden von
flag.store(true, std::memory_order_relaxed)
while( !flag.load(std::memory_order_relaxed) ) { ... }
Sie erhalten genau das gleiche Ergebnis (ohne teure Barriereanweisungen), von dem Sie erhalten würden volatile flag
.
Sie können nicht nur reißen, sondern atomic
auch in einem Thread speichern und in einem anderen ohne UB laden, sodass der Compiler die Last nicht aus einer Schleife heben kann. (Die Annahme, dass kein Datenrenn-UB vorhanden ist, ermöglicht die aggressiven Optimierungen, die wir für nichtatomare, nichtflüchtige Objekte wünschen.) Diese Funktion von entspricht atomic<T>
weitgehend der volatile
für reine Lasten und reine Speicher.
atomic<T>
Machen Sie auch +=
und so weiter zu atomaren RMW-Operationen (wesentlich teurer als eine atomare Last in eine temporäre, betreiben Sie dann einen separaten Atomspeicher. Wenn Sie keine atomare RMW möchten, schreiben Sie Ihren Code mit einer lokalen temporären).
Mit der Standardbestellung seq_cst
, von der Sie erhalten while(!flag)
, werden auch Bestellgarantien hinzugefügt. nichtatomare Zugriffe und auf andere atomare Zugriffe.
(Theoretisch schließt der ISO C ++ - Standard eine Optimierung der Atomik zur Kompilierungszeit nicht aus. In der Praxis tun dies Compiler jedoch nicht, da nicht gesteuert werden kann, wann dies nicht in Ordnung ist. Es gibt einige Fälle, in denen dies volatile atomic<T>
möglicherweise nicht der Fall ist Genug Kontrolle über die Optimierung von Atomics haben, wenn Compiler optimiert haben, also Compiler vorerst nicht. Siehe Warum Compiler keine redundanten std :: atomic-Schreibvorgänge zusammenführen? Beachten Sie, dass wg21 / p0062 davon abrät, volatile atomic
im aktuellen Code die Optimierung von zu verwenden Atomics.)
volatile
funktioniert tatsächlich auf echten CPUs (aber immer noch nicht verwenden)
auch bei schwach geordneten Speichermodellen (nicht x86) . Aber verwenden Sie nicht eigentlich ist es, die Verwendung atomic<T>
mit mo_relaxed
statt !! In diesem Abschnitt geht es darum, Missverständnisse über die Funktionsweise realer CPUs auszuräumen und nicht zu rechtfertigen volatile
. Wenn Sie sperrenlosen Code schreiben, ist Ihnen wahrscheinlich die Leistung wichtig. Das Verständnis der Caches und der Kosten für die Kommunikation zwischen Threads ist normalerweise wichtig für eine gute Leistung.
Echte CPUs verfügen über kohärente Caches / gemeinsam genutzten Speicher: Nachdem ein Speicher von einem Kern global sichtbar wird, kann kein anderer Kern einen veralteten Wert laden . (Siehe auch Mythen Programmierer glauben an CPU-Caches, in denen es um flüchtige Java- Dateien geht, die C ++ atomic<T>
mit der Speicherreihenfolge seq_cst entsprechen.)
Wenn ich Laden sage , meine ich eine ASM-Anweisung, die auf den Speicher zugreift. Dies volatile
stellt ein Zugriff sicher und ist nicht dasselbe wie die Konvertierung einer nichtatomaren / nichtflüchtigen C ++ - Variablen von lWert in rWert. (zB local_tmp = flag
oder while(!flag)
).
Das einzige, was Sie besiegen müssen, sind Optimierungen zur Kompilierungszeit, die nach der ersten Überprüfung überhaupt nicht neu geladen werden. Jedes Laden + Überprüfen bei jeder Iteration ist ohne Bestellung ausreichend. Ohne Synchronisation zwischen diesem Thread und dem Haupt-Thread ist es nicht sinnvoll, darüber zu sprechen, wann genau der Speicher passiert ist, oder über die Reihenfolge des Ladevorgangs. andere Operationen in der Schleife. Nur wenn es für diesen Thread sichtbar ist, kommt es darauf an. Wenn das Flag exit_now gesetzt ist, beenden Sie das Programm. Die Latenz zwischen den Kernen auf einem typischen x86-Xeon kann zwischen separaten physischen Kernen etwa 40 ns betragen .
Theoretisch: C ++ - Threads auf Hardware ohne kohärente Caches
Ich sehe keine Möglichkeit, dass dies mit reinem ISO C ++ aus der Ferne effizient sein könnte, ohne dass der Programmierer explizite Löschvorgänge im Quellcode durchführen muss.
Theoretisch könnten Sie eine C ++ - Implementierung auf einem Computer haben, der nicht so ist, und vom Compiler generierte explizite Flushes erfordern, um Dinge für andere Threads auf anderen Kernen sichtbar zu machen . (Oder für Lesevorgänge, um keine möglicherweise veraltete Kopie zu verwenden). Der C ++ - Standard macht dies nicht unmöglich, aber das Speichermodell von C ++ ist darauf ausgelegt, auf kohärenten Shared-Memory-Computern effizient zu sein. Beispielsweise spricht der C ++ - Standard sogar von "Lese-Lese-Kohärenz", "Schreib-Lese-Kohärenz" usw. Ein Hinweis im Standard weist sogar auf die Verbindung zur Hardware hin:
http://eel.is/c++draft/intro.races#19
[Hinweis: Die vier vorhergehenden Kohärenzanforderungen verbieten effektiv die Neuanordnung von atomaren Operationen in ein einzelnes Objekt durch den Compiler, selbst wenn beide Operationen entspannte Lasten sind. Dies macht die Cache-Kohärenzgarantie, die von der meisten Hardware bereitgestellt wird, effektiv für atomare C ++ - Operationen verfügbar. - Endnote]
Es gibt keinen Mechanismus für ein release
Geschäft, um sich selbst und einige ausgewählte Adressbereiche zu leeren: Es müsste alles synchronisieren, da es nicht wissen würde, was andere Threads lesen möchten, wenn ihre Erfassungslast diesen Release-Speicher sah (a Release-Sequenz, die eine Thread-Before-Beziehung zwischen Threads herstellt und garantiert, dass frühere nicht-atomare Operationen, die vom Schreib-Thread ausgeführt werden, jetzt sicher gelesen werden können. Es sei denn, sie wurden nach dem Release-Speicher weiter geschrieben ...) Oder Compiler hätten dies getan um wirklich klug zu sein und zu beweisen, dass nur wenige Cache-Zeilen geleert werden müssen.
Verwandte: meine Antwort auf Ist mov + mfence auf NUMA sicher? geht detailliert auf die Nichtexistenz von x86-Systemen ohne kohärenten gemeinsamen Speicher ein. Ebenfalls verwandt: Lädt und speichert die Neuordnung in ARM, um mehr über das Laden / Speichern am selben Ort zu erfahren.
Es sind Ich denke , Cluster mit nicht-kohärenten Shared Memory, aber sie sind nicht Single-System-Image - Maschinen. Jede Kohärenzdomäne führt einen separaten Kernel aus, sodass Sie keine Threads eines einzelnen C ++ - Programms darauf ausführen können. Stattdessen führen Sie separate Instanzen des Programms aus (jede mit ihrem eigenen Adressraum: Zeiger in einer Instanz sind in der anderen nicht gültig).
Um sie dazu zu bringen, über explizite Löschvorgänge miteinander zu kommunizieren, verwenden Sie normalerweise MPI oder eine andere API zur Nachrichtenübermittlung, damit das Programm angibt, welche Adressbereiche gelöscht werden müssen.
Echte Hardware läuft nicht std::thread
über Cache-Kohärenzgrenzen hinweg:
Es gibt einige asymmetrische ARM-Chips mit gemeinsam genutztem physischen Adressraum, jedoch nicht gemeinsam nutzbaren Cache-Domänen. Also nicht kohärent. (zB Kommentarthread ein A8-Kern und ein Cortex-M3 wie TI Sitara AM335x).
Auf diesen Kernen würden jedoch unterschiedliche Kernel ausgeführt, nicht ein einziges Systemabbild, das Threads über beide Kerne ausführen könnte. Mir sind keine C ++ - Implementierungen bekannt, die std::thread
Threads über CPU-Kerne ohne kohärente Caches ausführen .
Speziell für ARM generieren GCC und Clang Code, sofern alle Threads in derselben gemeinsam nutzbaren Domäne ausgeführt werden. In der Tat heißt es im ARMv7 ISA-Handbuch
Diese Architektur (ARMv7) wurde mit der Erwartung geschrieben, dass sich alle Prozessoren, die dasselbe Betriebssystem oder denselben Hypervisor verwenden, in derselben Domäne für die gemeinsame Nutzung von Inner Shareable befinden
Ein nicht kohärenter gemeinsamer Speicher zwischen verschiedenen Domänen ist also nur eine Sache für die explizite systemspezifische Verwendung von gemeinsam genutzten Speicherbereichen für die Kommunikation zwischen verschiedenen Prozessen unter verschiedenen Kerneln.
Siehe auch diese CoreCLR- Diskussion über Code-Gen unter Verwendung von dmb ish
(Inner Shareable Barrier) vs. dmb sy
(System) Speicherbarrieren in diesem Compiler.
Ich mache die Behauptung, dass keine C ++ - Implementierung für andere ISA std::thread
über Kerne mit nicht kohärenten Caches läuft . Ich habe keinen Beweis dafür, dass es keine solche Implementierung gibt, aber es scheint höchst unwahrscheinlich. Sofern Sie nicht auf ein bestimmtes exotisches Stück HW abzielen, das auf diese Weise funktioniert, sollte Ihr Denken über die Leistung eine MESI-ähnliche Cache-Kohärenz zwischen allen Threads voraussetzen. (Am besten atomic<T>
auf eine Weise verwenden, die die Richtigkeit garantiert!)
Kohärente Caches machen es einfach
Aber auf einem Multi-Core - System mit kohärentem Caches, Release-Store - Implementierung bedeutet nur , Ordnung in der Cache - commit für diesen speichert Thread, keine ausdrückliche Spülung tun. ( https://preshing.com/20120913/acquire-and-release-semantics/ und https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/ ). (Und ein Erfassungsladen bedeutet, den Zugriff auf den Cache im anderen Kern zu bestellen).
Ein Speicherbarrierebefehl blockiert nur das Laden und / oder Speichern des aktuellen Threads, bis der Speicherpuffer leer ist. das geht immer so schnell wie möglich von alleine. ( Stellt eine Speicherbarriere sicher, dass die Cache-Kohärenz abgeschlossen ist? Behebt dieses Missverständnis). Wenn Sie also keine Bestellung benötigen, ist es in Ordnung, nur die Sichtbarkeit in anderen Threads zu veranlassen mo_relaxed
. (Und so ist es auch volatile
, aber tu das nicht.)
Siehe auch C / C ++ 11-Zuordnungen zu Prozessoren
Unterhaltsame Tatsache: Auf x86 ist jeder ASM-Speicher ein Release-Speicher, da das x86-Speichermodell im Grunde genommen aus einem Speicherpuffer (mit Speicherweiterleitung) besteht.
Halbbezogener Re: Store-Puffer, globale Sichtbarkeit und Kohärenz: C ++ 11 garantiert nur sehr wenig. Die meisten echten ISAs (außer PowerPC) garantieren, dass sich alle Threads auf die Reihenfolge des Auftretens von zwei Speichern durch zwei andere Threads einigen können. (In der formalen Terminologie des Speichermodells der Computerarchitektur sind sie "atomar mit mehreren Kopien").
Ein weiteres Missverständnis ist, dass Speicherzaun-Anweisungen erforderlich sind, um den Speicherpuffer zu leeren, damit andere Kerne unsere Speicher überhaupt sehen können . Tatsächlich versucht der Speicherpuffer immer, sich so schnell wie möglich zu entleeren (Commit in den L1d-Cache), da er sonst die Ausführung auffüllt und blockiert. Eine vollständige Barriere / ein vollständiger Zaun blockiert den aktuellen Thread, bis der Speicherpuffer leer ist , sodass unsere späteren Ladevorgänge in der globalen Reihenfolge nach unseren früheren Speichern angezeigt werden.
(x86 dringend asm Speichermodell Mittel angeordnet , dass volatile
auf x86 kann am Ende Ihnen näher an geben mo_acq_rel
, außer dass Compile-Zeit mit nicht-atomaren Variablen Nachbestellung kann immer noch passieren. Aber die meisten nicht-x86 hat Speichermodelle-schwach bestellt so volatile
und relaxed
ist etwa so schwach wie mo_relaxed
erlaubt.)