Wann ist flüchtig mit Multithreading zu verwenden?

129

Wenn zwei Threads auf eine globale Variable zugreifen, wird in vielen Tutorials angegeben, dass die Variable flüchtig ist, um zu verhindern, dass der Compiler die Variable in einem Register zwischenspeichert und sie daher nicht korrekt aktualisiert wird. Zwei Threads, die beide auf eine gemeinsam genutzte Variable zugreifen, erfordern Schutz über einen Mutex, nicht wahr? In diesem Fall befindet sich der Code zwischen dem Sperren des Threads und dem Freigeben des Mutex in einem kritischen Abschnitt, in dem nur dieser eine Thread auf die Variable zugreifen kann. In diesem Fall muss die Variable nicht flüchtig sein.

Was ist also die Verwendung / der Zweck von flüchtig in einem Multithread-Programm?

David Preston
quelle
3
In einigen Fällen möchten / benötigen Sie keinen Schutz durch den Mutex.
Stefan Mai
4
Manchmal ist es in Ordnung, eine Rennbedingung zu haben, manchmal nicht. Wie verwenden Sie diese Variable?
David Heffernan
3
@ David: Ein Beispiel dafür, wann es "in Ordnung" ist, ein Rennen zu haben, bitte?
John Dibling
6
@ John Hier geht. Stellen Sie sich vor, Sie haben einen Arbeitsthread, der eine Reihe von Aufgaben verarbeitet. Der Arbeitsthread erhöht einen Zähler, wenn eine Aufgabe abgeschlossen ist. Der Master-Thread liest diesen Zähler regelmäßig und aktualisiert den Benutzer mit Nachrichten über den Fortschritt. Solange der Zähler richtig ausgerichtet ist, um ein Zerreißen zu vermeiden, muss der Zugriff nicht synchronisiert werden. Obwohl es ein Rennen gibt, ist es gutartig.
David Heffernan
5
@ John Die Hardware, auf der dieser Code ausgeführt wird, garantiert, dass ausgerichtete Variablen nicht beschädigt werden können. Wenn der Worker beim Lesen von n auf n + 1 aktualisiert, ist es dem Leser egal, ob er n oder n + 1 erhält. Es werden keine wichtigen Entscheidungen getroffen, da diese nur für die Fortschrittsberichterstattung verwendet werden.
David Heffernan

Antworten:

167

Kurze und schnelle Antwort : volatileIst (fast) nutzlos für plattformunabhängige Multithread-Anwendungsprogrammierung. Es bietet keine Synchronisation, erstellt keine Speicherzäune und stellt auch nicht die Reihenfolge der Ausführung von Operationen sicher. Operationen werden dadurch nicht atomar. Es macht Ihren Code nicht magisch threadsicher. volatileist möglicherweise die am häufigsten missverstandene Funktion in C ++. Sehen Sie dies , dies und dies für weitere Informationen übervolatile

Auf der anderen Seite volatilehat eine Verwendung, die möglicherweise nicht so offensichtlich ist. Es kann ähnlich wie constder Compiler verwendet werden, um Ihnen zu zeigen, wo Sie möglicherweise einen Fehler beim Zugriff auf eine gemeinsam genutzte Ressource auf nicht geschützte Weise machen. Diese Verwendung wird von Alexandrescu in diesem Artikel erörtert . Dies verwendet jedoch im Wesentlichen das System vom Typ C ++ auf eine Weise, die häufig als Erfindung angesehen wird und undefiniertes Verhalten hervorrufen kann.

volatilewurde speziell für die Schnittstelle mit speicherabgebildeter Hardware, Signalhandlern und der Anweisung setjmp machine code verwendet. Dies ist volatiledirekt auf die Programmierung auf Systemebene anwendbar und nicht auf die normale Programmierung auf Anwendungsebene.

Der 2003 C ++ Standard sagt nicht, dass volatileirgendeine Art von Acquire- oder Release-Semantik auf Variablen angewendet wird. Tatsächlich enthält der Standard keine Informationen zu allen Fragen des Multithreading. Bestimmte Plattformen wenden jedoch die Acquire- und Release-Semantik auf volatileVariablen an.

[Update für C ++ 11]

Die C ++ 11 Standard jetzt tut acknowledge Multithreading direkt in das Speichermodell und dem lanuage, und es bietet Bibliothekseinrichtungen mit ihm in einer plattformunabhängigen Art und Weise zu behandeln. Die Semantik von hat sich jedoch volatilenoch nicht geändert. volatileist immer noch kein Synchronisationsmechanismus. Bjarne Stroustrup sagt so viel in TCPPPL4E:

Verwenden Sie es volatilenur in Code auf niedriger Ebene, der sich direkt mit Hardware befasst.

Nehmen Sie nicht an, volatiledass das Speichermodell eine besondere Bedeutung hat. Es tut nicht. Es ist nicht - wie in einigen späteren Sprachen - ein Synchronisationsmechanismus. Verwenden Sie zum Synchronisieren atomica mutexoder a condition_variable.

[/ Update beenden]

Vor allem gilt die C ++ - Sprache selbst, wie sie im Standard von 2003 (und jetzt im Standard von 2011) definiert ist. Einige spezifische Plattformen fügen jedoch zusätzliche Funktionen oder Einschränkungen hinzu volatile. Zum Beispiel in MSVC 2010 (mindestens) Acquire and Release Semantik tun , um bestimmte Operationen auf gelten volatileVariablen. Aus dem MSDN :

Bei der Optimierung muss der Compiler die Reihenfolge zwischen Verweisen auf flüchtige Objekte sowie Verweisen auf andere globale Objekte beibehalten. Bestimmtes,

Ein Schreibvorgang in ein flüchtiges Objekt (flüchtiger Schreibvorgang) hat die Release-Semantik. Ein Verweis auf ein globales oder statisches Objekt, der vor dem Schreiben in ein flüchtiges Objekt in der Befehlssequenz auftritt, erfolgt vor diesem flüchtigen Schreiben in der kompilierten Binärdatei.

Das Lesen eines flüchtigen Objekts (flüchtiges Lesen) hat die Acquire-Semantik. Ein Verweis auf ein globales oder statisches Objekt, der nach einem Lesen des flüchtigen Speichers in der Befehlssequenz auftritt, erfolgt nach diesem flüchtigen Lesen in der kompilierten Binärdatei.

Sie können jedoch die Tatsache zur Kenntnis nehmen, dass, wenn Sie dem obigen Link folgen, in den Kommentaren einige Debatten darüber geführt werden, ob in diesem Fall die Semantik für den Erwerb / die Freigabe tatsächlich gilt oder nicht .

John Dibling
quelle
19
Ein Teil von mir möchte dies wegen des herablassenden Tons der Antwort und des ersten Kommentars ablehnen. "flüchtig ist nutzlos" ist vergleichbar mit "manuelle Speicherzuweisung ist nutzlos". Wenn Sie ein Multithread-Programm ohne volatiledieses Programm schreiben können, liegt dies daran, dass Sie auf den Schultern von Personen standen, die früher volatileThreading-Bibliotheken implementiert haben.
Ben Jackson
19
@ Ben nur, weil etwas Ihre Überzeugungen herausfordert, macht es nicht herablassend
David Heffernan
38
@ Ben: Nein, lesen Sie, was in C ++ volatiletatsächlich funktioniert . Was @John gesagt hat, ist richtig , Ende der Geschichte. Es hat nichts mit Anwendungscode gegen Bibliothekscode oder "gewöhnlichen" gegen "gottähnliche allwissende Programmierer" zu tun. volatileist für die Synchronisation zwischen Threads unnötig und nutzlos. Threading-Bibliotheken können nicht implementiert werden in Bezug auf volatile; Es muss sich sowieso auf plattformspezifische Details stützen, und wenn Sie sich auf diese verlassen, brauchen Sie diese nicht mehr volatile.
Jalf
6
@jalf: "flüchtig ist unnötig und nutzlos für die Synchronisation zwischen Threads" (was Sie gesagt haben) ist nicht dasselbe wie "flüchtig ist nutzlos für Multithread-Programmierung" (was John in der Antwort sagte). Sie sind zu 100% korrekt, aber ich bin mit John nicht einverstanden (teilweise) - flüchtig kann immer noch für Multithread-Programmierung verwendet werden (für eine sehr begrenzte
4
@GMan: Alles, was nützlich ist, ist nur unter bestimmten Anforderungen oder Bedingungen nützlich. Volatile ist nützlich für Multithread-Programmierung unter strengen Bedingungen (und in einigen Fällen sogar besser (für eine Definition von besser) als Alternativen). Sie sagen "Ignorieren dieses und jenes", aber der Fall, dass flüchtig für Multithreading nützlich ist, ignoriert nichts. Du hast etwas erfunden, was ich nie behauptet habe. Ja, der Nutzen von flüchtig ist begrenzt, aber es gibt ihn - aber wir können uns alle einig sein, dass er für die Synchronisation NICHT nützlich ist.
31

(Anmerkung des Herausgebers: In C ++ 11 volatileist es nicht das richtige Werkzeug für diesen Job und verfügt immer noch über UB für Datenrennen. Verwenden Sie es std::atomic<bool>mit std::memory_order_relaxedLasten / Speichern, um dies ohne UB zu tun. Bei realen Implementierungen wird es auf dieselbe Weise kompiliert wie volatile. Ich habe hinzugefügt Eine detailliertere Antwort und die Behebung der Missverständnisse in Kommentaren, dass schwach geordneter Speicher ein Problem für diesen Anwendungsfall sein könnte: Alle realen CPUs verfügen über einen kohärenten gemeinsam genutzten Speicher, sodass dies bei realen C ++ - Implementierungen volatilefunktioniert tu es nicht

Einige Diskussion in den Kommentaren scheint über andere Anwendungsfälle zu sprechen , wo man würde etwas stärker als entspannt atomics brauchen. Diese Antwort weist bereits darauf hin, dass volatileSie keine Bestellung erhalten.)


Flüchtig ist gelegentlich aus folgendem Grund nützlich: Dieser Code:

/* global */ bool flag = false;

while (!flag) {}

wird von gcc optimiert für:

if (!flag) { while (true) {} }

Was offensichtlich falsch ist, wenn das Flag vom anderen Thread geschrieben wird. Beachten Sie, dass der Synchronisationsmechanismus ohne diese Optimierung wahrscheinlich funktioniert (abhängig vom anderen Code sind möglicherweise einige Speicherbarrieren erforderlich). In 1 Producer-1-Consumer-Szenario ist kein Mutex erforderlich.

Andernfalls ist das flüchtige Schlüsselwort zu seltsam, um verwendet werden zu können. Es bietet keine Speicherordnungsgarantie für flüchtige und nichtflüchtige Zugriffe und bietet keine atomaren Operationen. Das heißt, Sie erhalten vom Compiler keine Hilfe mit dem flüchtigen Schlüsselwort, außer dem deaktivierten Register-Caching .

zeuxcg
quelle
4
Wenn ich mich recht erinnere, soll C ++ 0x Atomic richtig machen, was viele Leute glauben (falsch), dass es von volatile gemacht wird.
David Heffernan
13
volatileverhindert nicht, dass Speicherzugriffe neu angeordnet werden. volatileZugriffe werden nicht in Bezug aufeinander neu angeordnet, bieten jedoch keine Garantie für eine Neuordnung in Bezug auf Nicht- volatileObjekte und sind daher im Grunde auch als Flags unbrauchbar.
Jalf
13
@ Ben: Ich denke, du hast es auf den Kopf gestellt. Die Menge "flüchtig ist nutzlos" stützt sich auf die einfache Tatsache, dass flüchtig nicht vor Neuordnung schützt , was bedeutet, dass es für die Synchronisation völlig nutzlos ist. Andere Ansätze sind möglicherweise ebenso nutzlos (wie Sie bereits erwähnt haben, kann der Compiler durch die Optimierung des Link-Time-Codes einen Blick in den Code werfen, von dem Sie angenommen haben, dass der Compiler ihn als Black Box behandelt), aber das behebt nicht die Mängel von volatile.
Jalf
15
@jalf: Siehe den Artikel von Arch Robinson (an anderer Stelle auf dieser Seite verlinkt), 10. Kommentar (von "Spud"). Grundsätzlich ändert die Neuordnung nichts an der Logik des Codes. Der veröffentlichte Code verwendet das Flag, um eine Aufgabe abzubrechen (anstatt zu signalisieren, dass die Aufgabe erledigt ist). Es spielt also keine Rolle, ob die Aufgabe vor oder nach dem Code abgebrochen wird (z. B.: while (work_left) { do_piece_of_work(); if (cancel) break;}Wenn der Abbruch innerhalb der Schleife neu angeordnet wird). Die Logik ist immer noch gültig. Ich hatte einen Code, der ähnlich funktionierte: Wenn der Hauptthread beendet werden soll, setzt er das Flag für andere Threads, aber nicht ...
15
... egal, ob die anderen Threads vor dem Beenden einige zusätzliche Iterationen ihrer Arbeitsschleifen ausführen, sofern dies relativ bald nach dem Setzen des Flags geschieht. Natürlich ist dies die EINZIGE Verwendung, die ich mir vorstellen kann, und ihre Nische (und funktioniert möglicherweise nicht auf Plattformen, auf denen das Schreiben in eine flüchtige Variable die Änderung für andere Threads nicht sichtbar macht, obwohl dies zumindest auf x86 und x86-64 der Fall ist funktioniert). Ich würde sicherlich niemandem raten, dies ohne einen sehr guten Grund zu tun. Ich sage nur, dass eine pauschale Aussage wie "flüchtig ist NIE nützlich in Multithread-Code" nicht 100% korrekt ist.
15

In C ++ 11 normalerweise nie volatilezum Threading verwenden, nur für MMIO

Aber TL: DR, es "funktioniert" wie atomar mit mo_relaxedHardware mit kohärenten Caches (dh alles); Es reicht aus, Compiler daran zu hindern, Vars in Registern zu führen. atomicEs 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_relaxedbraucht 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, volatilewar 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::atomicmit mo_relaxedveralteten volatilezum Einfädeln. (außer vielleicht, um Fehler bei der Fehloptimierung bei atomic<double>einigen Compilern zu umgehen .)

Die interne Implementierung von std::atomicOn-Mainstream-Compilern (wie gcc und clang) wird nicht nur volatileintern verwendet. Compiler stellen die integrierten Funktionen für Atomlast, Speicher und RMW direkt zur Verfügung. (zB GNU C- __atomicBuiltins, die mit "einfachen" Objekten arbeiten.)


Volatile ist in der Praxis verwendbar (aber nicht)

Das heißt, volatileist in der Praxis für Dinge wie ein exit_nowFlag auf allen (?) Vorhandenen C ++ - Implementierungen auf realen CPUs verwendbar , da CPUs funktionieren (kohärente Caches) und gemeinsame Annahmen darüber, wie volatilees 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::atomicmo_relaxed volatilefür das Threading veraltet ist .

(Der ISO C ++ - Standard ist ziemlich vage und sagt nur, dass volatileZugriffe 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 volatileLesevorgä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_nowFlag 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_stuffweil 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_nowes eine Möglichkeit , dies wie beabsichtigt zu machen (bei normalen C ++ - Implementierungen). In C ++ 11 gilt Data Race UB jedoch weiterhin für, volatilesodass der ISO-Standard nicht garantiert , dass er überall funktioniert, selbst wenn HW-kohärente Caches vorausgesetzt werden.

Beachten Sie, dass bei breiteren Typen volatilekeine Garantie für mangelndes Reißen gegeben ist. Ich habe diese Unterscheidung hier ignoriert, boolda sie bei normalen Implementierungen kein Problem darstellt. Aber das ist auch ein Teil dessen, warum volatileUB immer noch dem Datenrennen unterliegt, anstatt einem entspannten Atom zu entsprechen.

Beachten Sie, dass "wie beabsichtigt" nicht bedeutet, dass der Thread darauf exit_nowwartet, dass der andere Thread tatsächlich beendet wird. Oder sogar, dass es darauf wartet, dass der flüchtige exit_now=trueSpeicher überhaupt global sichtbar ist, bevor mit späteren Operationen in diesem Thread fortgefahren wird. ( atomic<bool>Mit der Standardeinstellung mo_seq_cstwü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> flagmit 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 atomicauch 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 volatilefü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 atomicim 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_relaxedstatt !! 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 volatilestellt ein Zugriff sicher und ist nicht dasselbe wie die Konvertierung einer nichtatomaren / nichtflüchtigen C ++ - Variablen von lWert in rWert. (zB local_tmp = flagoder 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 releaseGeschä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::threadThreads ü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 volatileauf 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 volatileund relaxedist etwa so schwach wie mo_relaxederlaubt.)

Peter Cordes
quelle
Kommentare sind nicht für eine ausführliche Diskussion gedacht. Dieses Gespräch wurde in den Chat verschoben .
Samuel Liew
2
Tolles Schreiben. Dies ist genau das, wonach ich gesucht habe (unter Angabe aller Fakten), anstatt einer pauschalen Aussage, die nur lautet: "Verwenden Sie atomar statt flüchtig für ein einzelnes globales gemeinsames boolesches Flag".
Bernie
2
@bernie: Ich habe dies geschrieben, nachdem ich durch wiederholte Behauptungen frustriert wurde, dass die Nichtverwendung atomicdazu führen könnte, dass unterschiedliche Threads unterschiedliche Werte für dieselbe Variable im Cache haben . / Gesichtspalme. Im Cache nein, in den CPU- Registern ja (mit nichtatomaren Variablen); CPUs verwenden einen kohärenten Cache. Ich wünschte, andere Fragen zu SO wären nicht voller Erklärungen atomic, die falsche Vorstellungen über die Funktionsweise von CPUs verbreiten. (Weil dies aus Leistungsgründen eine nützliche Sache ist und auch erklärt, warum die ISO C ++ - Atomregeln so geschrieben sind, wie sie sind.)
Peter Cordes
-1
#include <iostream>
#include <thread>
#include <unistd.h>
using namespace std;

bool checkValue = false;

int main()
{
    std::thread writer([&](){
            sleep(2);
            checkValue = true;
            std::cout << "Value of checkValue set to " << checkValue << std::endl;
        });

    std::thread reader([&](){
            while(!checkValue);
        });

    writer.join();
    reader.join();
}

Einmal argumentierte ein Interviewer, der auch glaubte, dass flüchtig nutzlos ist, mit mir, dass die Optimierung keine Probleme verursachen würde, und bezog sich auf verschiedene Kerne mit separaten Cache-Zeilen und all dem (verstand nicht wirklich, worauf er sich genau bezog). Wenn dieser Code mit -O3 unter g ++ (g ++ -O3 thread.cpp -lpthread) kompiliert wird, zeigt er undefiniertes Verhalten. Grundsätzlich funktioniert es einwandfrei, wenn der Wert vor der while-Prüfung festgelegt wird, und wenn nicht, geht er in eine Schleife, ohne sich die Mühe zu machen, den Wert abzurufen (der tatsächlich vom anderen Thread geändert wurde). Grundsätzlich glaube ich, dass der Wert von checkValue nur einmal in das Register abgerufen und unter der höchsten Optimierungsstufe nie wieder überprüft wird. Wenn es vor dem Abruf auf true gesetzt ist, funktioniert es einwandfrei und wenn nicht, geht es in eine Schleife. Bitte korrigieren Sie mich, wenn ich falsch liege.

Anu Siril
quelle
4
Was hat das damit zu tun volatile? Ja, dieser Code ist UB - aber es ist auch UB mit volatile.
David Schwartz
-2

Sie benötigen flüchtige und möglicherweise sperrende.

volatile teilt dem Optimierer mit, dass sich der Wert asynchron ändern kann

volatile bool flag = false;

while (!flag) {
    /*do something*/
}

liest jedes Mal Flag um die Schleife.

Wenn Sie die Optimierung deaktivieren oder jede Variable flüchtig machen, verhält sich ein Programm gleich, aber langsamer. flüchtig bedeutet nur: „Ich weiß, dass Sie es vielleicht gerade gelesen haben und wissen, was es sagt, aber wenn ich sage, lesen Sie es, dann lesen Sie es.

Das Sperren ist Teil des Programms. Wenn Sie also Semaphoren implementieren, müssen diese unter anderem volatil sein. (Versuchen Sie es nicht, es ist schwer, wird wahrscheinlich einen kleinen Assembler oder das neue atomare Zeug brauchen, und es wurde bereits getan.)

Strg-Alt-Delor
quelle
1
Aber ist das nicht das gleiche Beispiel in der anderen Antwort, beschäftigt zu warten und daher etwas, das vermieden werden sollte? Wenn dies ein erfundenes Beispiel ist, gibt es Beispiele aus dem wirklichen Leben, die nicht erfunden wurden?
David Preston
7
@ Chris: Beschäftigtes Warten ist gelegentlich eine gute Lösung. Insbesondere wenn Sie erwarten, nur ein paar Taktzyklen warten zu müssen, bedeutet dies weitaus weniger Overhead als der viel schwerere Ansatz, den Thread anzuhalten. Wie ich bereits in anderen Kommentaren erwähnt habe, sind Beispiele wie dieses natürlich fehlerhaft, da davon ausgegangen wird, dass Lese- / Schreibvorgänge auf dem Flag in Bezug auf den zu schützenden Code nicht neu angeordnet werden und keine solche Garantie gegeben wird , volatileist nicht wirklich nützlich , auch in diesem Fall. Aber beschäftigtes Warten ist eine gelegentlich nützliche Technik.
Jalf
3
@richard Ja und nein. Die erste Hälfte ist richtig. Dies bedeutet jedoch nur, dass die CPU und der Compiler keine flüchtigen Variablen in Bezug aufeinander neu anordnen dürfen. Wenn ich eine flüchtige Variable A und dann eine flüchtige Variable B lese, muss der Compiler Code ausgeben, der (auch bei einer Neuordnung der CPU) garantiert ist, um A vor B zu lesen. Er übernimmt jedoch keine Garantie für alle nichtflüchtigen Variablenzugriffe . Sie können rund um Ihr flüchtiges Lesen / Schreiben nachbestellt werden. Wenn Sie also nicht jede Variable in Ihrem Programm flüchtig machen, erhalten Sie nicht die Garantie, an der Sie interessiert sind
10.
2
@ ctrl-alt-delor: Das ist nicht das volatile, was "keine Neuordnung" bedeutet. Sie hoffen, dass dies bedeutet, dass die Stores in der Programmreihenfolge global (für andere Threads) sichtbar werden. Das ist was atomic<T>mit memory_order_releaseoder seq_cstgibt dir. Sie erhalten jedoch volatile nur die Garantie, dass keine Neuordnung zur Kompilierungszeit erfolgt : Jeder Zugriff wird in der Programmreihenfolge im asm angezeigt. Nützlich für einen Gerätetreiber. Und nützlich für die Interaktion mit einem Interrupt-Handler, Debugger oder Signal-Handler auf dem aktuellen Kern / Thread, jedoch nicht für die Interaktion mit anderen Kernen.
Peter Cordes
1
volatileIn der Praxis reicht es aus, ein keep_runningFlag zu überprüfen, wie Sie es hier tun: Echte CPUs haben immer kohärente Caches, die kein manuelles Leeren erfordern. Aber es gibt keinen Grund zu empfehlen volatileüber atomic<T>mit mo_relaxed; Du wirst den gleichen Asm bekommen.
Peter Cordes