Führt das flüchtige Schlüsselwort C ++ einen Speicherzaun ein?

84

Ich verstehe, dass dies volatileden Compiler darüber informiert, dass der Wert möglicherweise geändert wird. Muss der Compiler jedoch einen Speicherzaun einführen, damit diese Funktion funktioniert, um diese Funktionalität zu erreichen?

Nach meinem Verständnis kann die Reihenfolge der Operationen an flüchtigen Objekten nicht neu angeordnet werden und muss beibehalten werden. Dies scheint zu implizieren, dass einige Gedächtniszäune notwendig sind und dass es keinen Weg gibt, dies zu umgehen. Bin ich richtig, wenn ich das sage?


Zu dieser verwandten Frage gibt es eine interessante Diskussion

Jonathan Wakely schreibt :

... Zugriffe auf bestimmte flüchtige Variablen können vom Compiler nicht neu angeordnet werden, solange sie in separaten vollständigen Ausdrücken vorkommen ... Recht, dass flüchtig für die Thread-Sicherheit nutzlos ist, aber nicht aus den von ihm angegebenen Gründen. Dies liegt nicht daran, dass der Compiler die Zugriffe auf flüchtige Objekte möglicherweise neu anordnet, sondern daran, dass die CPU sie möglicherweise neu anordnet. Atomare Operationen und Speicherbarrieren verhindern, dass der Compiler und die CPU neu angeordnet werden

Welchem David Schwartz antwortet in den Kommentaren :

... Aus Sicht des C ++ - Standards gibt es keinen Unterschied zwischen dem Compiler, der etwas tut, und dem Compiler, der Anweisungen ausgibt, die die Hardware veranlassen, etwas zu tun. Wenn die CPU Zugriffe auf flüchtige Stoffe neu anordnen kann, verlangt der Standard nicht, dass ihre Reihenfolge beibehalten wird. ...

... Der C ++ - Standard macht keinen Unterschied darüber, was die Neuordnung bewirkt. Und Sie können nicht behaupten, dass die CPU sie ohne beobachtbaren Effekt neu anordnen kann, also ist das in Ordnung - der C ++ - Standard definiert ihre Reihenfolge als beobachtbar. Ein Compiler ist mit dem C ++ - Standard auf einer Plattform kompatibel, wenn er Code generiert, der die Plattform dazu bringt, das zu tun, was der Standard erfordert. Wenn der Standard verlangt, dass Zugriffe auf flüchtige Stoffe nicht neu angeordnet werden, ist eine Plattform, die sie neu anordnet, nicht konform. ...

Mein Punkt ist, dass, wenn der C ++ - Standard dem Compiler verbietet, Zugriffe auf bestimmte flüchtige Stoffe neu zu ordnen, nach der Theorie, dass die Reihenfolge solcher Zugriffe Teil des beobachtbaren Verhaltens des Programms ist, der Compiler auch Code ausgeben muss, der die CPU daran hindert so. Der Standard unterscheidet nicht zwischen dem, was der Compiler tut, und dem Generierungscode des Compilers, den die CPU ausführt.

Was zwei Fragen aufwirft: Ist eine von ihnen "richtig"? Was machen tatsächliche Implementierungen wirklich?

Nathan Doromal
quelle
8
Dies bedeutet meistens, dass der Compiler diese Variable nicht in einem Register behalten sollte. Jede Zuweisung und Einlesen des Quellcodes sollte Speicherzugriffen im Binärcode entsprechen.
Basile Starynkevitch
1
Ich vermute, der Punkt ist, dass jeder Speicherzaun unwirksam wäre, wenn der Wert in einem internen Register gespeichert würde. Ich denke, Sie müssen in einer gleichzeitigen Situation noch andere Schutzmaßnahmen ergreifen.
Galik
Soweit ich weiß, wird flüchtig für Variablen verwendet, die durch Hardware geändert werden können (häufig bei Mikrocontrollern verwendet). Es bedeutet einfach, dass das Lesen der Variablen nicht in einer anderen Reihenfolge erfolgen und nicht weg optimiert werden kann. Das ist zwar C, sollte aber in ++ gleich sein.
Mast
1
@Mast Ich habe noch keinen Compiler gesehen, der verhindert, dass das Lesen von volatileVariablen durch die CPU-Caches optimiert wird. Entweder sind alle diese Compiler nicht konform oder der Standard bedeutet nicht, was Sie denken, dass er bedeutet. (Der Standard unterscheidet nicht zwischen dem, was der Compiler tut, und dem, was der Compiler die CPU macht. Es ist die Aufgabe des Compilers, Code auszugeben, der beim Ausführen dem Standard entspricht.)
David Schwartz

Antworten:

58

Lassen volatileSie mich erklären, wann Sie verwenden sollten, anstatt zu erklären , was funktioniert volatile.

  • In einem Signalhandler. Da das Schreiben in eine volatileVariable so ziemlich das einzige ist, was Sie mit dem Standard innerhalb eines Signalhandlers tun können. Seit C ++ 11 können Sie std::atomicfür diesen Zweck verwenden, aber nur, wenn das Atomic gesperrt ist.
  • Beim Umgang mit setjmp laut Intel .
  • Wenn Sie direkt mit Hardware arbeiten und sicherstellen möchten, dass der Compiler Ihre Lese- oder Schreibvorgänge nicht optimiert.

Beispielsweise:

volatile int *foo = some_memory_mapped_device;
while (*foo)
    ; // wait until *foo turns false

Ohne den volatileBezeichner kann der Compiler die Schleife vollständig optimieren. Der volatileBezeichner teilt dem Compiler mit, dass er möglicherweise nicht davon ausgeht, dass zwei nachfolgende Lesevorgänge denselben Wert zurückgeben.

Beachten Sie, dass dies volatilenichts mit Threads zu tun hat. Das obige Beispiel funktioniert nicht, wenn ein anderer Thread geschrieben wurde als, *fooda keine Erfassungsoperation beteiligt ist.

In allen anderen Fällen sollte die Verwendung von volatileals nicht portierbar betrachtet werden und die Codeüberprüfung nicht mehr bestehen, außer wenn es sich um Compiler und Compiler-Erweiterungen vor C ++ 11 handelt (z. B. den /volatile:msSwitch von msvc , der standardmäßig unter X86 / I64 aktiviert ist).

Stefan
quelle
5
Es ist strenger als "darf nicht davon ausgehen, dass 2 nachfolgende Lesevorgänge den gleichen Wert zurückgeben". Selbst wenn Sie nur einmal lesen und / oder die Werte wegwerfen, muss der Lesevorgang durchgeführt werden.
philipxy
1
Die Verwendung in Signalhandlern und setjmpsind die beiden Garantien, die der Standard macht. Andererseits bestand die Absicht zumindest zu Beginn darin, speicherabgebildete E / A zu unterstützen. Für einige Prozessoren ist möglicherweise ein Zaun oder eine Membar erforderlich.
James Kanze
@philipxy Außer niemand weiß, was "das Lesen" bedeutet. Zum Beispiel glaubt niemand, dass ein tatsächlicher Lesevorgang aus dem Speicher durchgeführt werden muss - kein mir bekannter Compiler versucht, CPU-Caches bei volatileZugriffen zu umgehen .
David Schwartz
@ JamesKanze: Nicht so. In Bezug auf Signalhandler besagt der Standard, dass während der Signalverarbeitung nur flüchtige std :: sig_atomic_t & lock-freie atomare Objekte definierte Werte haben. Es heißt aber auch, dass der Zugriff auf flüchtige Objekte beobachtbare Nebenwirkungen sind.
philipxy
1
@DavidSchwartz Einige Compiler-Architektur-Paare ordnen die standardmäßige Reihenfolge der Zugriffe auf tatsächliche Effekte zu, und Arbeitsprogramme greifen auf flüchtige Stoffe zu, um diese Effekte zu erhalten. Die Tatsache, dass einige solcher Paare keine Zuordnung oder eine triviale, nicht hilfreiche Zuordnung aufweisen, ist für die Qualität der Implementierungen relevant, jedoch nicht für den vorliegenden Punkt.
Philipxy
24

Führt das flüchtige Schlüsselwort C ++ einen Speicherzaun ein?

Ein C ++ - Compiler, der der Spezifikation entspricht, ist nicht erforderlich, um einen Speicherzaun einzuführen. Ihr spezieller Compiler könnte; Richten Sie Ihre Frage an die Autoren Ihres Compilers.

Die Funktion "flüchtig" in C ++ hat nichts mit Threading zu tun. Denken Sie daran, dass der Zweck von "flüchtig" darin besteht, Compiler-Optimierungen zu deaktivieren, damit das Lesen aus einem Register, das sich aufgrund exogener Bedingungen ändert, nicht optimiert wird. Ist eine Speicheradresse, in die ein anderer Thread auf einer anderen CPU schreibt, ein Register, das sich aufgrund exogener Bedingungen ändert? Nein. Wenn sich einige Compilerautoren dafür entschieden haben , Speicheradressen, auf die von verschiedenen Threads auf verschiedenen CPUs geschrieben wird, so zu behandeln, als würden sie sich aufgrund exogener Bedingungen ändern, ist dies ihre Sache. Sie sind dazu nicht verpflichtet. Sie müssen auch nicht - selbst wenn ein Speicherzaun eingeführt wird - beispielsweise sicherstellen, dass jeder Thread eine konsistente Funktion aufweist Reihenfolge der flüchtigen Lese- und Schreibvorgänge.

Tatsächlich ist flüchtig für das Threading in C / C ++ so gut wie nutzlos. Best Practice ist es, dies zu vermeiden.

Darüber hinaus: Speicherzäune sind ein Implementierungsdetail bestimmter Prozessorarchitekturen. In C #, wo ausdrücklich flüchtig ist für Multithreading ausgelegt, wird die Spezifikation nicht , dass die Hälfte Zäunen sagen wird eingeführt, weil das Programm könnte auf einer Architektur ausgeführt werden , die Zäune in erster Linie nicht verfügt . Vielmehr gibt die Spezifikation wieder bestimmte (extrem schwache) Garantien darüber, welche Optimierungen vom Compiler, der Laufzeit und der CPU vermieden werden, um bestimmte (extrem schwache) Einschränkungen für die Reihenfolge einiger Nebenwirkungen festzulegen. In der Praxis werden diese Optimierungen durch die Verwendung von Halbzäunen beseitigt. Dies ist jedoch ein Implementierungsdetail, das sich in Zukunft ändern kann.

Die Tatsache, dass Sie sich für die Semantik von flüchtig in jeder Sprache interessieren, da sie sich auf Multithreading bezieht, zeigt an, dass Sie darüber nachdenken, Speicher über Threads hinweg zu teilen. Überlegen Sie einfach, das nicht zu tun. Es macht Ihr Programm viel schwieriger zu verstehen und es ist viel wahrscheinlicher, dass es subtile, nicht reproduzierbare Fehler enthält.

Eric Lippert
quelle
18
"flüchtig ist in C / C ++ so gut wie nutzlos." Überhaupt nicht! Sie haben eine sehr usermode-desktop-zentrierte Ansicht der Welt ... aber der meiste C- und C ++ - Code läuft auf eingebetteten Systemen, auf denen flüchtige Daten für speicherabgebildete E / A dringend benötigt werden.
Ben Voigt
12
Der Grund dafür, dass der flüchtige Zugriff erhalten bleibt, liegt nicht nur darin, dass exogene Bedingungen die Speicherorte ändern können. Der Zugriff selbst kann weitere Aktionen auslösen. Beispielsweise ist es sehr häufig, dass ein Lesevorgang einen FIFO vorschiebt oder ein Interrupt-Flag löscht.
Ben Voigt
3
@ BenVoigt: Nutzlos für den effektiven Umgang mit Threading-Problemen war meine beabsichtigte Bedeutung.
Eric Lippert
4
@DavidSchwartz Der Standard kann offensichtlich nicht garantieren, wie speicherabgebildete E / A funktionieren. Speicherzugeordnete volatileE / A wurde jedoch in den C-Standard eingeführt. Da der Standard jedoch nicht angeben kann, was bei einem "Zugriff" tatsächlich passiert, heißt es: "Was einen Zugriff auf ein Objekt mit einem flüchtig qualifizierten Typ ausmacht, ist implementierungsdefiniert." Viel zu viele Implementierungen bieten heutzutage keine nützliche Definition eines Zugriffs, was meiner Meinung nach gegen den Geist des Standards verstößt, selbst wenn er dem Buchstaben entspricht.
James Kanze
8
Diese Bearbeitung ist definitiv eine Verbesserung, aber Ihre Erklärung konzentriert sich immer noch zu sehr auf das "Gedächtnis könnte exogen verändert werden". volatileDie Semantik ist stärker als diese. Der Compiler muss jeden angeforderten Zugriff generieren (1.9 / 8, 1.9 / 12) und nicht nur garantieren, dass exogene Änderungen schließlich erkannt werden (1.10 / 27). In der Welt der speicherabgebildeten E / A kann ein gelesener Speicher eine beliebige zugeordnete Logik haben, wie ein Eigenschafts-Getter. Sie würden Anrufe an Property Getter nicht gemäß den von Ihnen angegebenen Regeln optimieren volatile, und der Standard erlaubt dies auch nicht.
Ben Voigt
12

Was David übersieht, ist die Tatsache, dass der c ++ - Standard das Verhalten mehrerer Threads spezifiziert, die nur in bestimmten Situationen interagieren, und alles andere zu undefiniertem Verhalten führt. Eine Racebedingung mit mindestens einem Schreibvorgang ist undefiniert, wenn Sie keine atomaren Variablen verwenden.

Folglich hat der Compiler das Recht, auf Synchronisierungsanweisungen zu verzichten, da Ihre CPU nur den Unterschied in einem Programm bemerkt, das aufgrund fehlender Synchronisierung ein undefiniertes Verhalten aufweist.

Voo
quelle
5
Schön erklärt, danke. Der Standard definiert nur die Reihenfolge der Zugriffe auf flüchtige Stoffe als beobachtbar , solange das Programm kein undefiniertes Verhalten aufweist .
Jonathan Wakely
4
Wenn das Programm ein Datenrennen hat, stellt der Standard keine Anforderungen an das beobachtbare Verhalten des Programms. Es wird nicht erwartet, dass der Compiler den flüchtigen Zugriffen Barrieren hinzufügt, um im Programm vorhandene Datenrennen zu verhindern. Dies ist die Aufgabe des Programmierers, entweder durch Verwendung expliziter Barrieren oder durch atomare Operationen.
Jonathan Wakely
Warum übersehen Sie das wohl? Welcher Teil meiner Argumentation macht Ihrer Meinung nach ungültig? Ich stimme zu 100% zu, dass der Compiler das Recht hat, auf jegliche Synchronisation zu verzichten.
David Schwartz
2
Dies ist einfach falsch oder ignoriert zumindest das Wesentliche. volatilehat nichts mit Threads zu tun; Der ursprüngliche Zweck bestand darin, speicherabgebildete E / A zu unterstützen. Zumindest auf einigen Prozessoren würde die Unterstützung von speicherabgebildeten E / A-Vorgängen Zäune erfordern. (Compiler tun dies nicht, aber das ist ein anderes Problem.)
James Kanze
@JamesKanze volatilehat viel mit Threads zu tun: volatilebefasst sich mit Speicher, auf den zugegriffen werden kann, ohne dass der Compiler weiß, dass auf ihn zugegriffen werden kann, und der viele reale Verwendungen von gemeinsam genutzten Daten zwischen Threads auf einer bestimmten CPU abdeckt.
Neugieriger
12

Erstens garantieren die C ++ - Standards nicht die Speicherbarrieren, die zum ordnungsgemäßen Ordnen der nicht atomaren Lese- / Schreibvorgänge erforderlich sind. Für die Verwendung mit MMIO, die Signalverarbeitung usw. werden flüchtige Variablen empfohlen. Bei den meisten Implementierungen ist flüchtig nicht für Multithreading geeignet und wird im Allgemeinen nicht empfohlen.

In Bezug auf die Implementierung flüchtiger Zugriffe ist dies die Wahl des Compilers.

Dieser Artikel , der das Verhalten von gcc beschreibt, zeigt, dass Sie ein flüchtiges Objekt nicht als Speicherbarriere verwenden können, um eine Folge von Schreibvorgängen in den flüchtigen Speicher zu ordnen.

In Bezug auf das ICC- Verhalten fand ich in dieser Quelle auch, dass Volatile keine Garantie für die Bestellung von Speicherzugriffen garantiert.

Der Microsoft VS2013- Compiler hat ein anderes Verhalten. In dieser Dokumentation wird erläutert, wie flüchtig die Release / Acquire-Semantik erzwingt und die Verwendung flüchtiger Objekte in Sperren / Releases in Multithread-Anwendungen ermöglicht.

Ein weiterer Aspekt, der berücksichtigt werden muss, ist, dass derselbe Compiler möglicherweise ein anderes Verhalten aufweist. je nach angestrebter Hardwarearchitektur zu volatil . In diesem Beitrag zum MSVS 2013-Compiler werden die Besonderheiten des Kompilierens mit volatile für ARM-Plattformen klar angegeben.

Also meine Antwort auf:

Führt das flüchtige Schlüsselwort C ++ einen Speicherzaun ein?

wäre: Nicht garantiert, wahrscheinlich nicht, aber einige Compiler könnten es tun. Sie sollten sich nicht darauf verlassen, dass dies der Fall ist.

VAndrei
quelle
2
Es verhindert nicht die Optimierung, sondern nur, dass der Compiler Lasten und Speicher über bestimmte Einschränkungen hinaus ändert.
Dietrich Epp
Es ist nicht klar, was du sagst. Wollen Sie damit sagen, dass dies bei einigen nicht spezifizierten Compilern der Fall ist, volatileder den Compiler daran hindert, Lasten / Speicher neu zu ordnen? Oder sagen Sie, dass der C ++ - Standard dies erfordert? Und wenn letzteres der Fall ist, können Sie auf mein in der ursprünglichen Frage zitiertes gegenteiliges Argument antworten?
David Schwartz
@DavidSchwartz Der Standard verhindert eine Neuordnung (von jeder Quelle) von Zugriffen über einen volatileWert. Da die Definition von "Zugriff" der Implementierung überlassen bleibt, bringt uns dies nicht viel, wenn es der Implementierung egal ist.
James Kanze
Ich denke, einige Versionen von MSC-Compilern haben Zaunsemantik für implementiert volatile, aber es gibt keinen Zaun im generierten Code des Compilers in Visual Studios 2012.
James Kanze
@JamesKanze Was im Grunde bedeutet, dass das einzige tragbare Verhalten das volatileist, das vom Standard speziell aufgezählt wird. ( setjmp, Signale und so weiter.)
David Schwartz
7

Soweit ich weiß, fügt der Compiler nur einen Speicherzaun in die Itanium-Architektur ein.

Das volatileSchlüsselwort wird am besten für asynchrone Änderungen verwendet, z. B. für Signalhandler und speicherabgebildete Register. Es ist normalerweise das falsche Werkzeug für die Multithread-Programmierung.

Dietrich Epp
quelle
1
Art von. 'the compiler' (msvc) fügt einen Speicherzaun ein, wenn eine andere Architektur als ARM als Ziel ausgewählt wird und der Schalter / volatile: ms verwendet wird (Standardeinstellung). Siehe msdn.microsoft.com/en-us/library/12a04hfd.aspx . Andere Compiler fügen meines Wissens keine Zäune für flüchtige Variablen ein. Die Verwendung von flüchtigen Bestandteilen sollte vermieden werden, es sei denn, es handelt sich direkt um Hardware, Signalhandler oder nicht c ++ 11-konforme Compiler.
Stefan
@Stefan Nr. Ist volatileäußerst nützlich für viele Anwendungen, die sich nie mit Hardware befassen. Verwenden Sie immer, wenn die Implementierung CPU-Code generieren soll, der dem C / C ++ - Code genau folgt volatile.
Neugieriger
7

Es kommt darauf an, welcher Compiler "der Compiler" ist. Visual C ++ seit 2005. Der Standard verlangt dies jedoch nicht, so dass einige andere Compiler dies nicht tun.

Ben Voigt
quelle
VC ++ 2012 scheint keinen Zaun einzufügen: int volatile i; int main() { return i; }Erzeugt eine Hauptleitung mit genau zwei Anweisungen : mov eax, i; ret 0;.
James Kanze
@ JamesKanze: Welche Version genau? Und verwenden Sie nicht standardmäßige Kompilierungsoptionen? Ich verlasse mich auf die Dokumentation (erste betroffene Version) und (neueste Version) , in denen definitiv die Semantik zum Erwerb und zur Freigabe erwähnt wird.
Ben Voigt
cl /helpsagt Version 18.00.21005.1. Das Verzeichnis, in dem es sich befindet, ist C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC. Der Header im Befehlsfenster sagt VS 2013. Also in Bezug auf die Version ... Die einzigen Optionen, die ich verwendet habe, waren /c /O2 /Fa. (Ohne das /O2wird auch der lokale Stapelrahmen eingerichtet. Es gibt jedoch noch keine Zaunanweisung.)
James Kanze
@JamesKanze: Ich war mehr an der Architektur interessiert, z. B. "Microsoft (R) C / C ++ - Optimierung des Compilers Version 18.00.30723 für x64". Möglicherweise gibt es keinen Zaun, da x86 und x64 zunächst ziemlich starke Garantien für die Cache-Kohärenz in ihrem Speichermodell haben ?
Ben Voigt
Vielleicht. Ich weiß es nicht wirklich. Die Tatsache, dass ich dies in getan habe main, damit der Compiler das gesamte Programm sehen und wissen konnte, dass es vor mir keine anderen Threads oder zumindest keine anderen Zugriffe auf die Variable gab (es konnte also keine Cache-Probleme geben), könnte dies möglicherweise beeinflussen auch, aber irgendwie bezweifle ich es.
James Kanze
5

Dies ist größtenteils aus dem Speicher und basiert auf Pre-C ++ 11 ohne Threads. Nachdem ich jedoch an Diskussionen über das Threading im Ausschuss teilgenommen habe, kann ich sagen, dass das Komitee nie die Absicht hatte volatile, für die Synchronisierung zwischen Threads verwendet zu werden. Microsoft schlug es vor, aber der Vorschlag trug nicht.

Die Schlüsselspezifikation von volatileist, dass der Zugriff auf ein flüchtiges Element genau wie IO ein "beobachtbares Verhalten" darstellt. Auf die gleiche Weise kann der Compiler bestimmte E / A nicht neu anordnen oder entfernen, er kann keine Zugriffe auf ein flüchtiges Objekt neu anordnen oder entfernen (oder genauer gesagt, Zugriffe über einen lvalue-Ausdruck mit flüchtigem qualifiziertem Typ). Die ursprüngliche Absicht von volatile bestand darin, speicherabgebildete E / A zu unterstützen. Das "Problem" dabei ist jedoch, dass durch die Implementierung definiert wird, was einen "flüchtigen Zugriff" ausmacht. Und viele Compiler implementieren es so, als wäre die Definition "eine Anweisung, die liest oder in den Speicher schreibt, ausgeführt worden". Dies ist eine legale, wenn auch nutzlose Definition, wenn die Implementierung dies spezifiziert. (Ich habe noch nicht die tatsächliche Spezifikation für einen Compiler gefunden.

Wohl (und es ist ein Argument, das ich akzeptiere) verstößt dies gegen die Absicht des Standards, da Sie nicht einmal flüchtig für speicherabgebildete E / A verwenden können, es sei denn, die Hardware erkennt die Adressen als speicherabgebildete E / A und verhindert jegliche Neuordnung usw. Zumindest auf Sparc- oder Intel-Architekturen. Trotzdem gibt keiner der Comiler, die ich mir angesehen habe (Sun CC, g ++ und MSC), Zaun- oder Membar-Anweisungen aus. (Ungefähr zu der Zeit, als Microsoft vorschlug, die Regeln für zu erweitern volatile, haben einige ihrer Compiler ihren Vorschlag umgesetzt und Zaunanweisungen für flüchtige Zugriffe ausgegeben. Ich habe nicht überprüft, was die jüngsten Compiler tun, aber es würde mich nicht überraschen, wenn dies davon abhängt Die von mir überprüfte Version - ich glaube, es war VS6.0 - hat jedoch keine Zäune emittiert.)

James Kanze
quelle
Warum kann der Compiler keine Zugriffe auf flüchtige Objekte neu anordnen oder entfernen? Wenn es sich bei den Zugriffen um ein beobachtbares Verhalten handelt, ist es sicherlich genauso wichtig, zu verhindern, dass die CPU, Schreibpostpuffer, der Speichercontroller und alles andere sie ebenfalls neu anordnen.
David Schwartz
@ DavidSchwartz Weil das der Standard sagt. Aus praktischer Sicht ist das, was die von mir verifizierten Compiler tun, sicherlich völlig nutzlos, aber die Standard-Wieselwörter reichen aus, damit sie weiterhin Konformität beanspruchen können (oder könnten, wenn sie dies tatsächlich dokumentieren).
James Kanze
1
@DavidSchwartz: Für exklusive (oder mutexierte) speicherabgebildete E / A für Peripheriegeräte ist die volatileSemantik vollkommen ausreichend. Im Allgemeinen melden solche Peripheriegeräte ihre Speicherbereiche als nicht zwischenspeicherbar, was bei der Neuordnung auf Hardwareebene hilfreich ist.
Ben Voigt
@BenVoigt Ich habe mich irgendwie darüber gewundert: Die Idee, dass der Prozessor irgendwie "weiß", dass die Adresse, mit der er sich befasst, speicherabgebildete E / A ist. Soweit ich weiß, haben Sparcs keine Unterstützung dafür, so dass Sun CC und g ++ auf einem Sparc für speicherabgebildete E / A immer noch unbrauchbar werden. (Als ich mir das anschaute, interessierte ich mich hauptsächlich für einen Sparc.)
James Kanze
@JamesKanze: Nach meiner kleinen Suche hat Sparc anscheinend dedizierte Adressbereiche für "alternative Ansichten" des Speichers, die nicht zwischengespeichert werden können. Solange Ihre flüchtigen Zugangspunkte in den ASI_REAL_IOTeil des Adressraums weisen , sollten Sie in Ordnung sein. (Altera NIOS verwendet eine ähnliche Technik, wobei hohe Bits der Adresse den MMU-Bypass steuern; ich bin sicher, dass es auch andere gibt)
Ben Voigt
5

Es muss nicht. Flüchtig ist kein Synchronisationsprimitiv. Es deaktiviert nur Optimierungen, dh Sie erhalten eine vorhersehbare Folge von Lese- und Schreibvorgängen innerhalb eines Threads in derselben Reihenfolge, wie sie von der abstrakten Maschine vorgeschrieben wird. Aber Lese- und Schreibvorgänge in verschiedenen Threads haben in erster Linie keine Reihenfolge. Es macht keinen Sinn, davon zu sprechen, ihre Reihenfolge beizubehalten oder nicht beizubehalten. Die Reihenfolge zwischen den Köpfen kann durch Synchronisationsprimitive festgelegt werden. Sie erhalten UB ohne diese.

Ein bisschen Erklärung zu Speicherbarrieren. Eine typische CPU verfügt über mehrere Speicherzugriffsebenen. Es gibt eine Speicherpipeline, mehrere Cache-Ebenen, dann RAM usw.

Membar-Anweisungen spülen die Pipeline. Sie ändern nicht die Reihenfolge, in der Lese- und Schreibvorgänge ausgeführt werden, sondern erzwingen lediglich die Ausführung ausstehender Lesevorgänge zu einem bestimmten Zeitpunkt. Es ist nützlich für Multithread-Programme, aber sonst nicht viel.

Cache (s) sind normalerweise automatisch zwischen den CPUs kohärent. Wenn Sie sicherstellen möchten, dass der Cache mit dem RAM synchronisiert ist, ist eine Cache-Leerung erforderlich. Es unterscheidet sich sehr von einer Membar.

n.m.
quelle
1
Sie sagen also, dass der C ++ - Standard volatilenur Compiler-Optimierungen deaktiviert? Das macht keinen Sinn. Jede Optimierung, die der Compiler vornehmen kann, kann zumindest prinzipiell genauso gut von der CPU durchgeführt werden. Wenn der Standard also sagt, dass er nur Compiler-Optimierungen deaktiviert, würde dies bedeuten, dass er kein Verhalten liefert, auf das man sich in portablem Code überhaupt verlassen kann. Dies ist jedoch offensichtlich nicht der Fall, da sich tragbarer Code auf sein Verhalten in Bezug auf setjmpund Signale verlassen kann.
David Schwartz
1
@ DavidSchwartz Nein, der Standard sagt so etwas nicht. Das Deaktivieren von Optimierungen ist genau das, was üblicherweise zur Implementierung des Standards getan wird. Der Standard verlangt, dass beobachtbares Verhalten in derselben Reihenfolge auftritt, wie sie von der abstrakten Maschine gefordert wird. Wenn die abstrakte Maschine keine Bestellung benötigt, kann die Implementierung jede Bestellung oder gar keine Bestellung verwenden. Der Zugriff auf flüchtige Variablen in verschiedenen Threads wird nur angeordnet, wenn eine zusätzliche Synchronisation angewendet wird.
n. 'Pronomen' m.
1
@ DavidSchwartz Ich entschuldige mich für die ungenaue Formulierung. Der Standard verlangt nicht, dass Optimierungen deaktiviert sind. Es gibt überhaupt keine Vorstellung von Optimierung. Vielmehr wird das Verhalten festgelegt, bei dem Compiler in der Praxis bestimmte Optimierungen so deaktivieren müssen, dass die beobachtbare Folge von Lese- und Schreibvorgängen dem Standard entspricht.
n. 'Pronomen' m.
1
Dies ist jedoch nicht erforderlich, da der Standard es Implementierungen ermöglicht, "beobachtbare Abfolgen von Lese- und Schreibvorgängen" zu definieren, wie sie möchten. Wenn Implementierungen beobachtbare Sequenzen so definieren, dass Optimierungen deaktiviert werden müssen, ist dies der Fall. Wenn nicht, dann nicht. Sie erhalten eine vorhersehbare Folge von Lese- und Schreibvorgängen, wenn und nur wenn die Implementierung dies für Sie festgelegt hat.
David Schwartz
1
Nein, die Implementierung muss definieren, was einen einzelnen Zugriff ausmacht. Die Reihenfolge solcher Zugriffe wird von der abstrakten Maschine vorgegeben. Eine Implementierung muss die Reihenfolge beibehalten. Der Standard besagt ausdrücklich, dass "flüchtig ein Hinweis auf die Implementierung ist, um eine aggressive Optimierung des Objekts zu vermeiden", wenn auch in einem nicht normativen Teil, aber die Absicht ist klar.
n. 'Pronomen' m.
4

Der Compiler muss volatilenur dann einen Speicherzaun um Zugriffe einführen , wenn dies erforderlich ist, um die volatileim Standard angegebenen Verwendungszwecke ( setjmpSignalhandler usw.) auf dieser bestimmten Plattform zu verwenden.

Beachten Sie, dass einige Compiler weit über die Anforderungen des C ++ - Standards hinausgehen, um volatileauf diesen Plattformen leistungsfähiger oder nützlicher zu werden. Portabler Code sollte nicht darauf angewiesen sein volatile, etwas anderes zu tun, als im C ++ - Standard angegeben.

David Schwartz
quelle
2

Ich verwende in Interrupt-Serviceroutinen immer flüchtig, z. B. ändert der ISR (häufig Assembly-Code) einen Speicherort, und der Code höherer Ebene, der außerhalb des Interrupt-Kontexts ausgeführt wird, greift über einen Zeiger auf flüchtig auf den Speicherort zu.

Ich mache dies sowohl für RAM als auch für speicherabgebildete E / A.

Basierend auf der Diskussion hier scheint es, dass dies immer noch eine gültige Verwendung von flüchtig ist, aber nichts mit mehreren Threads oder CPUs zu tun hat. Wenn der Compiler für einen Mikrocontroller "weiß", dass es keine anderen Zugriffe geben kann (z. B. ist alles auf dem Chip, kein Cache und es gibt nur einen Kern), würde ich denken, dass ein Speicherzaun überhaupt nicht impliziert ist, der Compiler muss nur bestimmte Optimierungen verhindern.

Während wir mehr Material in das "System" stapeln, das den Objektcode ausführt, sind fast alle Wetten deaktiviert, zumindest habe ich diese Diskussion so gelesen. Wie könnte ein Compiler jemals alle Basen abdecken?

Andrew Queisser
quelle
0

Ich denke, die Verwirrung um flüchtige und Befehlsumordnungen ergibt sich aus den beiden Begriffen der Neuordnung von CPUs:

  1. Ausführung außerhalb der Reihenfolge.
  2. Sequenz von Speicherlese- / -schreibvorgängen, wie sie von anderen CPUs gesehen werden (Neuordnung in dem Sinne, dass jede CPU möglicherweise eine andere Sequenz sieht).

Flüchtig beeinflusst, wie ein Compiler den Code unter der Annahme einer Single-Threaded-Ausführung generiert (dies schließt Interrupts ein). Es impliziert nichts über Speicherbarriere-Anweisungen, sondern hindert einen Compiler vielmehr daran, bestimmte Arten von Optimierungen im Zusammenhang mit Speicherzugriffen durchzuführen.
Ein typisches Beispiel ist das erneute Abrufen eines Werts aus dem Speicher, anstatt einen in einem Register zwischengespeicherten Wert zu verwenden.

Ausführung außerhalb der Reihenfolge

CPUs können Anweisungen außerhalb der Reihenfolge / spekulativ ausführen, vorausgesetzt, das Endergebnis könnte im ursprünglichen Code aufgetreten sein. CPUs können Transformationen ausführen, die in Compilern nicht zulässig sind, da Compiler nur Transformationen ausführen können, die unter allen Umständen korrekt sind. Im Gegensatz dazu können CPUs die Gültigkeit dieser Optimierungen überprüfen und sie zurücknehmen, wenn sie sich als falsch herausstellen.

Reihenfolge der Lese- / Schreibvorgänge im Speicher, wie sie von anderen CPUs gesehen werden

Das Endergebnis einer Befehlsfolge, die effektive Reihenfolge, muss mit der Semantik des von einem Compiler generierten Codes übereinstimmen. Die von der CPU gewählte tatsächliche Ausführungsreihenfolge kann jedoch unterschiedlich sein. Die effektive Reihenfolge, wie sie in anderen CPUs zu sehen ist (jede CPU kann eine andere Ansicht haben), kann durch Speicherbarrieren eingeschränkt werden.
Ich bin mir nicht sicher, inwieweit sich die effektive und tatsächliche Reihenfolge unterscheiden kann, da ich nicht weiß, inwieweit Speicherbarrieren CPUs daran hindern können, eine Ausführung außerhalb der Reihenfolge durchzuführen.

Quellen:

Pawel Batko
quelle
0

Während ich ein online herunterladbares Video-Tutorial für die Entwicklung von 3D Graphics & Game Engine mit modernem OpenGL durchgearbeitet habe. Wir haben volatilein einer unserer Klassen verwendet. Die Tutorial-Website finden Sie hier und das Video, das mit dem volatileSchlüsselwort arbeitet, befindet sich in der Shader EngineSerie Video 98. Diese Arbeiten sind nicht meine eigenen, sondern akkreditiert Marek A. Krzeminski, MAScund dies ist ein Auszug aus der Video-Download-Seite.

"Da wir unsere Spiele jetzt in mehreren Threads ausführen können, ist es wichtig, Daten zwischen Threads ordnungsgemäß zu synchronisieren. In diesem Video zeige ich, wie eine Klasse für flüchtige Sperren erstellt wird, um sicherzustellen, dass flüchtige Variablen ordnungsgemäß synchronisiert werden ..."

Und wenn Sie seine Website abonniert haben und Zugriff auf seine Videos in diesem Video haben, verweist er auf diesen Artikel über die Verwendung Volatilemit der multithreadingProgrammierung.

Hier ist der Artikel über den obigen Link: http://www.drdobbs.com/cpp/volatile-the-multithreaded-programmers-b/184403766

volatil: Der beste Freund des Multithread-Programmierers

Von Andrei Alexandrescu, 1. Februar 2001

Das flüchtige Schlüsselwort wurde entwickelt, um Compileroptimierungen zu verhindern, die bei bestimmten asynchronen Ereignissen zu falschem Code führen können.

Ich möchte Ihre Stimmung nicht verderben, aber diese Kolumne befasst sich mit dem gefürchteten Thema der Multithread-Programmierung. Wenn - wie in der vorherigen Ausgabe von Generic angegeben - ausnahmesichere Programmierung schwierig ist, ist dies im Vergleich zur Multithread-Programmierung ein Kinderspiel.

Programme, die mehrere Threads verwenden, sind bekanntermaßen schwer zu schreiben, sich als richtig zu erweisen, zu debuggen, zu warten und im Allgemeinen zu zähmen. Falsche Multithread-Programme können jahrelang ohne Probleme ausgeführt werden, um dann unerwartet Amok auszuführen, da einige kritische Timing-Bedingungen erfüllt sind.

Es ist unnötig zu erwähnen, dass eine Programmiererin, die Multithread-Code schreibt, jede Hilfe benötigt, die sie bekommen kann. Diese Kolumne konzentriert sich auf die Rennbedingungen - eine häufige Ursache für Probleme in Multithread-Programmen - und bietet Ihnen Einblicke und Tools, wie Sie diese vermeiden können, und lässt den Compiler erstaunlicherweise hart daran arbeiten, Ihnen dabei zu helfen.

Nur ein kleines Schlüsselwort

Obwohl sowohl C- als auch C ++ - Standards in Bezug auf Threads auffällig leise sind, machen sie dem Multithreading in Form des flüchtigen Schlüsselworts ein kleines Zugeständnis.

Volatile ist genau wie sein bekannteres Gegenstück const ein Typmodifikator. Es soll in Verbindung mit Variablen verwendet werden, auf die in verschiedenen Threads zugegriffen und diese geändert werden. Grundsätzlich wird das Schreiben von Multithread-Programmen ohne Volatile unmöglich oder der Compiler verschwendet enorme Optimierungsmöglichkeiten. Eine Erklärung ist angebracht.

Betrachten Sie den folgenden Code:

class Gadget {
public:
    void Wait() {
        while (!flag_) {
            Sleep(1000); // sleeps for 1000 milliseconds
        }
    }
    void Wakeup() {
        flag_ = true;
    }
    ...
private:
    bool flag_;
};

Der Zweck von Gadget :: Wait oben besteht darin, die Variable flag_ member jede Sekunde zu überprüfen und zurückzugeben, wenn diese Variable von einem anderen Thread auf true gesetzt wurde. Zumindest hat das sein Programmierer beabsichtigt, aber leider ist Warten falsch.

Angenommen, der Compiler stellt fest, dass Sleep (1000) ein Aufruf einer externen Bibliothek ist, die die Mitgliedsvariable flag_ möglicherweise nicht ändern kann. Dann kommt der Compiler zu dem Schluss, dass er flag_ in einem Register zwischenspeichern und dieses Register verwenden kann, anstatt auf den langsameren integrierten Speicher zuzugreifen. Dies ist eine hervorragende Optimierung für Single-Threaded-Code, aber in diesem Fall beeinträchtigt sie die Korrektheit: Nachdem Sie Wait für ein Gadget-Objekt aufgerufen haben, obwohl ein anderer Thread Wakeup aufruft, wird Wait für immer wiederholt. Dies liegt daran, dass die Änderung von flag_ nicht in dem Register wiedergegeben wird, in dem flag_ zwischengespeichert wird. Die Optimierung ist zu ... optimistisch.

Das Zwischenspeichern von Variablen in Registern ist eine sehr wertvolle Optimierung, die die meiste Zeit angewendet wird. Es wäre daher schade, sie zu verschwenden. C und C ++ bieten Ihnen die Möglichkeit, ein solches Caching explizit zu deaktivieren. Wenn Sie den flüchtigen Modifikator für eine Variable verwenden, speichert der Compiler diese Variable nicht in Registern. Jeder Zugriff trifft auf den tatsächlichen Speicherort dieser Variablen. Alles, was Sie tun müssen, um die Wait / Wakeup-Kombination von Gadget zum Laufen zu bringen, ist, flag_ entsprechend zu qualifizieren:

class Gadget {
public:
    ... as above ...
private:
    volatile bool flag_;
};

Die meisten Erklärungen zur Begründung und Verwendung von flüchtigen Bestandteilen hören hier auf und empfehlen Ihnen, die primitiven Typen, die Sie in mehreren Threads verwenden, flüchtig zu qualifizieren. Mit volatile können Sie jedoch noch viel mehr tun, da es Teil des wunderbaren C ++ - Typsystems ist.

Verwenden von volatile mit benutzerdefinierten Typen

Sie können nicht nur primitive Typen, sondern auch benutzerdefinierte Typen flüchtig qualifizieren. In diesem Fall ändert flüchtig den Typ auf ähnliche Weise wie const. (Sie können auch const und flüchtig gleichzeitig auf denselben Typ anwenden.)

Im Gegensatz zu const unterscheidet flüchtig zwischen primitiven Typen und benutzerdefinierten Typen. Im Gegensatz zu Klassen unterstützen primitive Typen nämlich immer noch alle ihre Operationen (Addition, Multiplikation, Zuweisung usw.), wenn sie flüchtig qualifiziert sind. Beispielsweise können Sie einem flüchtigen int ein nichtflüchtiges int zuweisen, einem flüchtigen Objekt jedoch kein nichtflüchtiges Objekt.

Lassen Sie uns anhand eines Beispiels veranschaulichen, wie flüchtig bei benutzerdefinierten Typen funktioniert.

class Gadget {
public:
    void Foo() volatile;
    void Bar();
    ...
private:
    String name_;
    int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;

Wenn Sie der Meinung sind, dass flüchtig bei Objekten nicht so nützlich ist, bereiten Sie sich auf eine Überraschung vor.

volatileGadget.Foo(); // ok, volatile fun called for
                  // volatile object
regularGadget.Foo();  // ok, volatile fun called for
                  // non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
                  // volatile object!

Die Umstellung von einem nicht qualifizierten Typ auf ein volatiles Gegenstück ist trivial. Genau wie bei const können Sie jedoch nicht von volatil zu nicht qualifiziert zurückkehren. Sie müssen eine Besetzung verwenden:

Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok

Eine flüchtig qualifizierte Klasse gewährt nur Zugriff auf eine Teilmenge ihrer Schnittstelle, eine Teilmenge, die vom Klassenimplementierer gesteuert wird. Benutzer können nur mithilfe eines const_cast vollen Zugriff auf die Benutzeroberfläche dieses Typs erhalten. Ebenso wie bei der Konstanz wird die Flüchtigkeit von der Klasse an ihre Mitglieder weitergegeben (z. B. sind volatileGadget.name_ und volatileGadget.state_ flüchtige Variablen).

flüchtige, kritische Abschnitte und Rennbedingungen

Das einfachste und am häufigsten verwendete Synchronisationsgerät in Multithread-Programmen ist der Mutex. Ein Mutex macht die Grundelemente "Erfassen" und "Freigeben" verfügbar. Sobald Sie Acquire in einem Thread aufrufen, wird jeder andere Thread, der Acquire aufruft, blockiert. Später, wenn dieser Thread Release aufruft, wird genau ein Thread freigegeben, der in einem Acquire-Aufruf blockiert ist. Mit anderen Worten, für einen bestimmten Mutex kann nur ein Thread die Prozessorzeit zwischen einem Aufruf von Acquire und einem Aufruf von Release abrufen. Der Ausführungscode zwischen einem Aufruf von Acquire und einem Aufruf von Release wird als kritischer Abschnitt bezeichnet. (Die Windows-Terminologie ist etwas verwirrend, da sie den Mutex selbst als kritischen Abschnitt bezeichnet, während "Mutex" tatsächlich ein Mutex zwischen Prozessen ist. Es wäre schön gewesen, wenn sie als Thread-Mutex und Prozess-Mutex bezeichnet worden wären.)

Mutexe werden verwendet, um Daten vor Rennbedingungen zu schützen. Per Definition tritt eine Race-Bedingung auf, wenn die Auswirkung von mehr Threads auf Daten davon abhängt, wie Threads geplant sind. Rennbedingungen werden angezeigt, wenn zwei oder mehr Threads um die Verwendung derselben Daten konkurrieren. Da sich Threads zu beliebigen Zeitpunkten gegenseitig unterbrechen können, können Daten beschädigt oder falsch interpretiert werden. Folglich müssen Änderungen und manchmal der Zugriff auf Daten sorgfältig mit kritischen Abschnitten geschützt werden. Bei der objektorientierten Programmierung bedeutet dies normalerweise, dass Sie einen Mutex in einer Klasse als Mitgliedsvariable speichern und ihn verwenden, wenn Sie auf den Status dieser Klasse zugreifen.

Erfahrene Multithread-Programmierer haben vielleicht die beiden obigen Absätze gelesen, aber ihr Zweck ist es, ein intellektuelles Training anzubieten, denn jetzt werden wir uns mit der flüchtigen Verbindung verbinden. Dazu zeichnen wir eine Parallele zwischen der Welt der C ++ - Typen und der Welt der Threading-Semantik.

  • Außerhalb eines kritischen Abschnitts kann jeder Thread jederzeit einen anderen unterbrechen. Es gibt keine Steuerung, daher sind Variablen, auf die von mehreren Threads aus zugegriffen werden kann, flüchtig. Dies steht im Einklang mit der ursprünglichen Absicht von flüchtig - das Verhindern, dass der Compiler unabsichtlich Werte zwischenspeichert, die von mehreren Threads gleichzeitig verwendet werden.
  • Innerhalb eines kritischen Abschnitts, der durch einen Mutex definiert ist, hat nur ein Thread Zugriff. Folglich hat der ausführende Code innerhalb eines kritischen Abschnitts eine Single-Threaded-Semantik. Die Regelgröße ist nicht mehr flüchtig - Sie können das flüchtige Qualifikationsmerkmal entfernen.

Kurz gesagt, Daten, die zwischen Threads ausgetauscht werden, sind außerhalb eines kritischen Abschnitts konzeptionell flüchtig und innerhalb eines kritischen Abschnitts nicht flüchtig.

Sie betreten einen kritischen Abschnitt, indem Sie einen Mutex sperren. Sie entfernen das flüchtige Qualifikationsmerkmal aus einem Typ, indem Sie einen const_cast anwenden. Wenn es uns gelingt, diese beiden Operationen zusammenzufügen, stellen wir eine Verbindung zwischen dem C ++ - Typsystem und der Threading-Semantik einer Anwendung her. Wir können den Compiler veranlassen, die Rennbedingungen für uns zu überprüfen.

LockingPtr

Wir brauchen ein Tool, das eine Mutex-Erfassung und einen const_cast sammelt. Lassen Sie uns eine LockingPtr-Klassenvorlage entwickeln, die Sie mit einem flüchtigen Objekt obj und einem Mutex mtx initialisieren. Während seiner Lebensdauer hält ein LockingPtr mtx erworben. Außerdem bietet LockingPtr Zugriff auf das flüchtig gestrippte Objekt. Der Zugriff wird auf intelligente Weise über operator-> und operator * angeboten. Der const_cast wird in LockingPtr ausgeführt. Die Besetzung ist semantisch gültig, da LockingPtr den erfassten Mutex für seine Lebensdauer beibehält.

Definieren wir zunächst das Grundgerüst einer Klasse Mutex, mit der LockingPtr funktioniert:

class Mutex {
public:
    void Acquire();
    void Release();
    ...    
};

Um LockingPtr zu verwenden, implementieren Sie Mutex mithilfe der nativen Datenstrukturen und primitiven Funktionen Ihres Betriebssystems.

LockingPtr wird mit dem Typ der Regelgröße versehen. Wenn Sie beispielsweise ein Widget steuern möchten, verwenden Sie ein LockingPtr, das Sie mit einer Variablen vom Typ flüchtiges Widget initialisieren.

Die Definition von LockingPtr ist sehr einfach. LockingPtr implementiert einen nicht anspruchsvollen Smart Pointer. Es konzentriert sich ausschließlich auf das Sammeln eines const_cast und eines kritischen Abschnitts.

template <typename T>
class LockingPtr {
public:
    // Constructors/destructors
    LockingPtr(volatile T& obj, Mutex& mtx)
      : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {    
        mtx.Lock();    
    }
    ~LockingPtr() {    
        pMtx_->Unlock();    
    }
    // Pointer behavior
    T& operator*() {    
        return *pObj_;    
    }
    T* operator->() {   
        return pObj_;   
    }
private:
    T* pObj_;
    Mutex* pMtx_;
    LockingPtr(const LockingPtr&);
    LockingPtr& operator=(const LockingPtr&);
};

Trotz seiner Einfachheit ist LockingPtr eine sehr nützliche Hilfe beim Schreiben von korrektem Multithread-Code. Sie sollten Objekte, die von Threads gemeinsam genutzt werden, als flüchtig definieren und niemals const_cast mit ihnen verwenden. Verwenden Sie immer automatische LockingPtr-Objekte. Lassen Sie uns dies anhand eines Beispiels veranschaulichen.

Angenommen, Sie haben zwei Threads, die ein Vektorobjekt gemeinsam nutzen:

class SyncBuf {
public:
    void Thread1();
    void Thread2();
private:
    typedef vector<char> BufT;
    volatile BufT buffer_;
    Mutex mtx_; // controls access to buffer_
};

Innerhalb einer Thread-Funktion verwenden Sie einfach ein LockingPtr, um kontrollierten Zugriff auf die Variable buffer_ member zu erhalten:

void SyncBuf::Thread1() {
    LockingPtr<BufT> lpBuf(buffer_, mtx_);
    BufT::iterator i = lpBuf->begin();
    for (; i != lpBuf->end(); ++i) {
        ... use *i ...
    }
}

Der Code ist sehr einfach zu schreiben und zu verstehen. Wenn Sie buffer_ verwenden müssen, müssen Sie einen LockingPtr erstellen, der darauf zeigt. Sobald Sie dies getan haben, haben Sie Zugriff auf die gesamte Schnittstelle des Vektors.

Das Schöne daran ist, dass der Compiler Sie darauf hinweist, wenn Sie einen Fehler machen:

void SyncBuf::Thread2() {
    // Error! Cannot access 'begin' for a volatile object
    BufT::iterator i = buffer_.begin();
    // Error! Cannot access 'end' for a volatile object
    for ( ; i != lpBuf->end(); ++i ) {
        ... use *i ...
    }
}

Sie können auf keine Funktion von buffer_ zugreifen, bis Sie entweder einen const_cast anwenden oder LockingPtr verwenden. Der Unterschied besteht darin, dass LockingPtr eine geordnete Möglichkeit bietet, const_cast auf flüchtige Variablen anzuwenden.

LockingPtr ist bemerkenswert ausdrucksstark. Wenn Sie nur eine Funktion aufrufen müssen, können Sie ein unbenanntes temporäres LockingPtr-Objekt erstellen und direkt verwenden:

unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}

Zurück zu den primitiven Typen

Wir haben gesehen, wie gut flüchtig Objekte vor unkontrolliertem Zugriff schützen und wie LockingPtr eine einfache und effektive Möglichkeit bietet, thread-sicheren Code zu schreiben. Kehren wir nun zu primitiven Typen zurück, die von flüchtig unterschiedlich behandelt werden.

Betrachten wir ein Beispiel, in dem mehrere Threads eine Variable vom Typ int gemeinsam nutzen.

class Counter {
public:
    ...
    void Increment() { ++ctr_; }
    void Decrement() { ctr_; }
private:
    int ctr_;
};

Wenn Increment und Decrement von verschiedenen Threads aufgerufen werden sollen, ist das obige Fragment fehlerhaft. Erstens muss ctr_ flüchtig sein. Zweitens ist sogar eine scheinbar atomare Operation wie ++ ctr_ eine dreistufige Operation. Der Speicher selbst hat keine arithmetischen Fähigkeiten. Beim Inkrementieren einer Variablen muss der Prozessor:

  • Liest diese Variable in einem Register
  • Erhöht den Wert im Register
  • Schreibt das Ergebnis zurück in den Speicher

Diese dreistufige Operation wird als RMW (Read-Modify-Write) bezeichnet. Während des Modify-Teils einer RMW-Operation geben die meisten Prozessoren den Speicherbus frei, um anderen Prozessoren Zugriff auf den Speicher zu gewähren.

Wenn zu diesem Zeitpunkt ein anderer Prozessor eine RMW-Operation für dieselbe Variable ausführt, haben wir eine Race-Bedingung: Der zweite Schreibvorgang überschreibt den Effekt des ersten.

Um dies zu vermeiden, können Sie sich erneut auf LockingPtr verlassen:

class Counter {
public:
    ...
    void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
    void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
    volatile int ctr_;
    Mutex mtx_;
};

Jetzt ist der Code korrekt, aber seine Qualität ist im Vergleich zum SyncBuf-Code minderwertig. Warum? Denn mit Counter warnt Sie der Compiler nicht, wenn Sie versehentlich direkt auf ctr_ zugreifen (ohne es zu sperren). Der Compiler kompiliert ++ ctr_, wenn ctr_ flüchtig ist, obwohl der generierte Code einfach falsch ist. Der Compiler ist nicht mehr Ihr Verbündeter, und nur Ihre Aufmerksamkeit kann Ihnen helfen, Rennbedingungen zu vermeiden.

Was solltest du dann tun? Kapseln Sie einfach die primitiven Daten, die Sie in übergeordneten Strukturen verwenden, und verwenden Sie flüchtige Daten mit diesen Strukturen. Paradoxerweise ist es schlimmer, flüchtig direkt mit integrierten Funktionen zu verwenden, obwohl dies ursprünglich die Verwendungsabsicht von flüchtig war!

flüchtige Mitgliedsfunktionen

Bisher hatten wir Klassen, in denen flüchtige Datenelemente zusammengefasst sind. Denken wir nun daran, Klassen zu entwerfen, die wiederum Teil größerer Objekte sind und von Threads gemeinsam genutzt werden. Hier können flüchtige Elementfunktionen eine große Hilfe sein.

Beim Entwerfen Ihrer Klasse qualifizieren Sie nur die Elementfunktionen, die threadsicher sind, flüchtig. Sie müssen davon ausgehen, dass Code von außen die flüchtigen Funktionen jederzeit von jedem Code aus aufruft. Vergessen Sie nicht: flüchtig entspricht freiem Multithread-Code und keinem kritischen Abschnitt; Nichtflüchtig entspricht einem Single-Thread-Szenario oder einem kritischen Abschnitt.

Sie definieren beispielsweise ein Klassen-Widget, das eine Operation in zwei Varianten implementiert - eine thread-sichere und eine schnelle, ungeschützte.

class Widget {
public:
    void Operation() volatile;
    void Operation();
    ...
private:
    Mutex mtx_;
};

Beachten Sie die Verwendung von Überladung. Jetzt kann der Benutzer von Widget Operation mit einer einheitlichen Syntax aufrufen, entweder für flüchtige Objekte und zur Gewährleistung der Thread-Sicherheit oder für normale Objekte und zur Erzielung der Geschwindigkeit. Der Benutzer muss vorsichtig sein, wenn er die freigegebenen Widget-Objekte als flüchtig definiert.

Bei der Implementierung einer flüchtigen Elementfunktion besteht die erste Operation normalerweise darin, diese mit einem LockingPtr zu sperren. Dann wird die Arbeit mit dem nichtflüchtigen Geschwister erledigt:

void Widget::Operation() volatile {
    LockingPtr<Widget> lpThis(*this, mtx_);
    lpThis->Operation(); // invokes the non-volatile function
}

Zusammenfassung

Wenn Sie Multithread-Programme schreiben, können Sie volatile zu Ihrem Vorteil nutzen. Sie müssen die folgenden Regeln einhalten:

  • Definieren Sie alle freigegebenen Objekte als flüchtig.
  • Verwenden Sie flüchtig nicht direkt mit primitiven Typen.
  • Verwenden Sie beim Definieren gemeinsam genutzter Klassen flüchtige Elementfunktionen, um die Thread-Sicherheit auszudrücken.

Wenn Sie dies tun und die einfache generische Komponente LockingPtr verwenden, können Sie threadsicheren Code schreiben und sich weniger um die Rennbedingungen kümmern, da der Compiler sich um Sie kümmert und fleißig auf die Stellen hinweist, an denen Sie falsch liegen.

Einige Projekte, an denen ich beteiligt war, haben Volatile und LockingPtr mit großer Wirkung eingesetzt. Der Code ist sauber und verständlich. Ich erinnere mich an ein paar Deadlocks, aber ich bevorzuge Deadlocks gegenüber Rennbedingungen, weil sie so viel einfacher zu debuggen sind. Es gab praktisch keine Probleme im Zusammenhang mit den Rennbedingungen. Aber dann weißt du es nie.

Danksagung

Vielen Dank an James Kanze und Sorin Jianu, die mit aufschlussreichen Ideen geholfen haben.


Andrei Alexandrescu ist Entwicklungsleiter bei RealNetworks Inc. (www.realnetworks.com) in Seattle, WA, und Autor des renommierten Buches Modern C ++ Design. Er kann unter www.moderncppdesign.com kontaktiert werden. Andrei ist auch einer der vorgestellten Ausbilder des C ++ - Seminars (www.gotw.ca/cpp_seminar).

Dieser Artikel ist vielleicht etwas veraltet, gibt aber einen guten Einblick in eine hervorragende Verwendung des flüchtigen Modifikators bei der Verwendung von Multithread-Programmierung, um Ereignisse asynchron zu halten, während der Compiler die Rennbedingungen für uns überprüft. Dies beantwortet möglicherweise nicht direkt die ursprüngliche Frage des OP zum Erstellen eines Speicherzauns, aber ich entscheide mich, dies als Antwort für andere zu veröffentlichen, um eine hervorragende Referenz für eine gute Verwendung von Volatile bei der Arbeit mit Multithread-Anwendungen zu finden.

Francis Cugler
quelle
0

Das Schlüsselwort volatilebedeutet im Wesentlichen, dass das Lesen und Schreiben eines Objekts genau so ausgeführt werden sollte , wie es vom Programm geschrieben wurde, und in keiner Weise optimiert werden sollte . Binärcode sollte C- oder C ++ - Code folgen: eine Last, in der dieser gelesen wird, ein Speicher, in dem geschrieben wird.

Dies bedeutet auch, dass von keinem Lesevorgang ein vorhersehbarer Wert erwartet werden sollte: Der Compiler sollte auch unmittelbar nach einem Schreibvorgang in dasselbe flüchtige Objekt nichts über einen Lesevorgang annehmen:

volatile int i;
i = 1;
int j = i; 
if (j == 1) // not assumed to be true

volatileist möglicherweise das wichtigste Werkzeug in der Toolbox "C ist eine Assemblersprache auf hoher Ebene" .

Ob das Deklarieren eines Objekts als flüchtig ausreicht, um das Verhalten von Code sicherzustellen, der sich mit asynchronen Änderungen befasst, hängt von der Plattform ab: Unterschiedliche CPUs bieten unterschiedliche Ebenen der garantierten Synchronisation für normale Lese- und Schreibvorgänge im Speicher. Sie sollten wahrscheinlich nicht versuchen, einen solchen Multithreading-Code auf niedriger Ebene zu schreiben, es sei denn, Sie sind ein Experte auf diesem Gebiet.

Atomprimitive bieten eine schöne übergeordnete Ansicht von Objekten für Multithreading, die es einfach macht, über Code nachzudenken. Fast alle Programmierer sollten entweder atomare Grundelemente oder Grundelemente verwenden, die gegenseitige Ausschlüsse wie Mutexe, Lese- / Schreibsperren, Semaphoren oder andere blockierende Grundelemente bieten.

Neugieriger
quelle