Nebenläufigkeit: Atomare und flüchtige in C ++ 11 Speichermodell

Eine globale Variable wird von zwei gleichzeitig ausgeführten Threads auf zwei verschiedenen coreen gemeinsam genutzt. Die Threads schreiben und lesen von den Variablen. Für die atomare Variable kann ein Thread einen veralteten Wert lesen? Jeder core kann einen Wert der gemeinsam genutzten Variablen in seinem Cache haben, und wenn ein Thread seine Kopie in einen Cache schreibt, kann der andere Thread in einem anderen core den veralteten Wert aus seinem eigenen Cache lesen. Oder der Compiler macht eine starke Speicherordnung, um den letzten Wert aus dem anderen Cache zu lesen? Die C ++ 11 Standardbibliothek hat std :: atomic support. Wie unterscheidet sich das von dem volatilen Schlüsselwort? Wie sich volatile und atomare Typen im obigen Szenario anders verhalten?

Erstens bedeutet volatile keinen atomaren Zugang. Es ist für Dinge wie Speicher-Mapping-I / O und Signal-Handling ausgelegt. volatile ist bei Verwendung von std::atomic völlig unnötig, und wenn Ihre Plattform nichts anderes dokumentiert, hat volatile keinen Einfluss auf den atomaren Zugriff oder die Speicherordnung zwischen Threads.

Wenn Sie eine globale Variable haben, die zwischen Threads geteilt wird:

 std::atomic ai; 

dann hängen die Sichtbarkeits- und Sortierbedingungen von dem Speicherordnungsparameter ab, den Sie für Operationen verwenden, und den Synchronisationseffekten von Sperren, Threads und Zugriffen auf andere atomare Variablen.

In Abwesenheit einer zusätzlichen Synchronisation, wenn ein Thread einen Wert in ai schreibt, gibt es nichts, das garantiert, dass ein anderer Thread den Wert in einem bestimmten Zeitraum sehen wird. Der Standard gibt an, dass er “in einem angemessenen Zeitraum” sichtbar sein sollte, aber jeder Zugriff kann einen veralteten Wert zurückgeben.

Die Standardspeicherordnung von std::memory_order_seq_cst stellt eine einzige globale Gesamtbestellung für alle std::memory_order_seq_cst Operationen für alle Variablen std::memory_order_seq_cst . Dies bedeutet nicht, dass Sie keine veralteten Werte erhalten können, aber das bedeutet, dass der Wert, den Sie erhalten, bestimmt wird und davon abhängt, wo in dieser Gesamtbestellung Ihre Operation liegt.

Wenn Sie 2 gemeinsame Variablen x und y , anfänglich Null, und einen Thread schreiben 1 zu x und einen anderen schreiben 2 zu y , dann sieht ein dritter Thread, der beide liest, entweder (0,0), (1,0), (0,2) oder (1,2), da zwischen den Operationen keine Ordnungsbeschränkung besteht und somit die Operationen in beliebiger Reihenfolge in der globalen Reihenfolge auftreten können.

Wenn beide Schreibvorgänge aus demselben Thread stammen, der x=1 vor y=2 und der Lese-Thread y vor x liest, dann ist (0,2) keine gültige Option mehr, da das Lesen von y==2 bedeutet, dass früherer Schreibzugriff auf x ist sichtbar. Die anderen 3 Paarungen (0,0), (1,0) und (1,2) sind immer noch möglich, abhängig davon, wie die 2 Interleave mit den 2 Writes liest.

Wenn Sie andere Speicherordnungen wie std::memory_order_relaxed oder std::memory_order_acquire die Constraints noch weiter gelockert und die einzelne globale Reihenfolge ist nicht mehr std::memory_order_acquire . Threads müssen sich nicht einmal unbedingt auf die Anordnung von zwei Speichern für getrennte Variablen einigen, wenn keine zusätzliche Synchronisation vorhanden ist.

Die einzige Möglichkeit, den “neuesten” Wert zu garantieren, besteht darin, eine read-modify-write-Operation wie exchange() , compare_exchange_strong() oder fetch_add() . Read-Modify-Write-Operationen haben eine zusätzliche Einschränkung, dass sie immer mit dem “neusten” Wert arbeiten, sodass eine Folge von ai.fetch_add(1) durch eine Reihe von Threads eine Sequenz von Werten ohne Duplikate oder Lücken ai.fetch_add(1) . In Ermangelung zusätzlicher Einschränkungen gibt es noch keine Garantie, welche Threads welche Werte sehen werden.

Das Arbeiten mit atomaren Operationen ist ein komplexes Thema. Ich schlage vor, Sie lesen viel Hintergrundmaterial und untersuchen veröffentlichten Code, bevor Sie Produktionscode mit Atomics schreiben. In den meisten Fällen ist es einfacher, Code zu schreiben, der Sperren verwendet, und nicht merklich weniger effizient.

volatile und die atomaren Operationen haben einen anderen Hintergrund und wurden mit einer anderen Absicht eingeführt.

volatile Daten von weit zurück, und ist in erster Linie entworfen, um Compiler-Optimierungen beim Zugriff auf Memory-Mapped IO zu verhindern. Moderne Compiler tendieren dazu, Optimierungen für volatile Komponenten nicht mehr zu unterdrücken, obwohl dies auf einigen Maschinen nicht einmal für Speicher-IOs ausreicht. Abgesehen von dem speziellen Fall von Signalbehandlern und setjmp , longjmp und getjmp Folgen (wo der C-Standard und im Fall von Signalen der Posix-Standard zusätzliche Garantien gibt), muss es auf einer modernen Maschine als nutzlos betrachtet werden, wo ohne spezielle zusätzliche statementen (Zäune oder Speicherbarrieren), kann die Hardware bestimmte Zugriffe neu ordnen oder sogar unterdrücken. Da Sie setjmp et al. Nicht verwenden setjmp . In C ++ bleiben mehr oder weniger Signal-Handler übrig, und in einer Multithread-Umgebung, zumindest unter Unix, gibt es auch bessere Lösungen. Und möglicherweise Memory-Mapped IO, wenn Sie an coreel-Code arbeiten und sicherstellen können, dass der Compiler erzeugt, was auch immer für die betreffende Plattform benötigt wird. (Laut dem Standard ist volatile Zugriff ein beobachtbares Verhalten, das der Compiler beachten muss. Der Compiler muss jedoch definieren, was mit “Zugriff” gemeint ist, und die meisten definieren ihn als “eine Lade- oder Speichermaschinenanweisung wurde ausgeführt”. Was auf einem modernen processor nicht einmal bedeutet, dass es unbedingt einen Lese- oder Schreibzyklus auf dem Bus gibt, viel weniger, als dass es in der von Ihnen erwarteten Reihenfolge ist.)

Angesichts dieser Situation hat der C ++ – Standard einen atomaren Zugriff hinzugefügt, der eine gewisse Anzahl von Garantien über Threads bietet; insbesondere enthält der um einen atomaren Zugriff erzeugte Code die notwendigen zusätzlichen statementen, um zu verhindern, dass die Hardware die Zugriffe neu anordnet, und um sicherzustellen, dass sich die Zugriffe auf den globalen Speicher verteilen, der zwischen coreen auf einer Multicore-Maschine geteilt wird. (An einem Punkt der Standardisierungsbemühungen schlug Microsoft vor, diese Semantik zu volatile hinzuzufügen, und ich denke, einige ihrer C ++ – Compiler tun dies. Nach Erörterung der Probleme im Ausschuss bestand der allgemeine Konsens – einschließlich des Microsoft-Vertreters – jedoch darin Es ist besser, volatile mit seiner ursprünglichen Bedeutung zu lassen und die atomaren Typen zu definieren.) Oder benutzen Sie einfach die System-Level-Primitiven, wie Mutexe, die alle statementen ausführen, die in ihrem Code benötigt werden. (Sie müssen. Sie können einen Mutex ohne einige Garantien bezüglich der Reihenfolge der Speicherzugriffe implementieren.)

Volatile und Atomic dienen verschiedenen Zwecken.

Flüchtig: Informiert den Compiler, um eine Optimierung zu vermeiden. Dieses Schlüsselwort wird für Variablen verwendet, die sich unerwartet ändern sollen. Somit kann es verwendet werden, um die Hardware-Statusregister, Variablen von ISR, Variablen, die in einer Multithread-Anwendung gemeinsam genutzt werden, darzustellen.

Atomic: Es wird auch im Falle einer Multithread-Anwendung verwendet. Dies stellt jedoch sicher, dass es während der Verwendung in einer Multithread-Anwendung keine Sperre / Blockierung gibt. Atomare Operationen sind frei von Rassen und Indivisibel. Einiges des Schlüsselszenarios der Verwendung besteht darin, zu prüfen, ob eine Sperre frei ist oder verwendet wird, den Wert atomar zu addieren und den Mehrwert usw. in einer Multithread-Anwendung zurückzugeben.

Hier ist eine grundlegende Zusammenfassung dessen, was die 2 Dinge sind:

1) Flüchtiges Schlüsselwort:
Weist den Compiler an, dass dieser Wert jederzeit geändert werden kann und deshalb sollte er nicht in einem Register zwischengespeichert werden. Suchen Sie in C nach dem alten Schlüsselwort “register”. “Volatile” ist im Prinzip der Operator “-“, um “+” zu registrieren. Moderne Compiler machen jetzt die Optimierung, bei der “Register” standardmäßig explizit angefordert wird, sodass Sie nur noch “flüchtig” sehen. Die Verwendung des flüchtigen Qualifizierers garantiert, dass Ihre Verarbeitung niemals einen veralteten Wert verwendet, aber nicht mehr.

2) Atom:
Atomare Operationen modifizieren Daten in einem einzigen Takt-Tick, so dass es für JEDEN anderen Thread unmöglich ist, auf die Daten mitten in einem solchen Update zuzugreifen. Sie beschränken sich normalerweise auf die assemblyanweisungen, die die Hardware unterstützt. Dinge wie ++, – und zwei pointers vertauschen. Beachten Sie, dass dies nichts über die ORDER sagt, die verschiedenen Threads werden die atomaren statementen ausführen, nur dass sie niemals parallel laufen werden. Deshalb haben Sie all diese zusätzlichen Optionen, um eine Bestellung zu erzwingen.