webentwicklung-frage-antwort-db.com.de

Kann num ++ für 'int num' atomar sein?

Im Allgemeinen ist für int numnum++ (Oder ++num) Als Lese-, Änderungs- und Schreiboperation nicht atomar . Aber ich sehe oft Compiler, zum Beispiel GCC , die den folgenden Code dafür generieren ( hier probieren ):

Enter image description here

Da Zeile 5, die num++ Entspricht, eine Anweisung ist, können wir daraus schließen, dass num++ in diesem Fall atomar ist?

Und wenn ja, bedeutet , dass das so erzeugte num++ In gleichzeitigen (Multithread-) Szenarien verwendet werden kann, ohne dass die Gefahr von Datenrassen besteht (dh wir ziehen an) Müssen Sie es zum Beispiel nicht machen std::atomic<int> und die damit verbundenen Kosten auferlegen, da es sowieso atomar ist?

[~ # ~] Update [~ # ~]

Beachten Sie, dass diese Frage nicht ist, ob Inkrement ist atomar (es ist nicht und das war und ist die erste Zeile der Frage). Es ist, ob es kann in bestimmten Szenarien ist, d. H. Ob die Ein-Befehl-Natur in bestimmten Fällen ausgenutzt werden kann, um den Overhead des Präfixes lock zu vermeiden. Und, wie die akzeptierte Antwort in dem Abschnitt über Einprozessor-Maschinen sowie diese Antwort erwähnt, erklärt die Konversation in ihren Kommentaren und anderen, kann es (obwohl nicht mit C oder C++).

148
Leo Heinsaar

Dies ist absolut das, was C++ als ein Datenrennen definiert, das ein undefiniertes Verhalten verursacht, selbst wenn ein Compiler zufällig Code erzeugt, der genau das tut, was Sie auf einem Zielcomputer erhofft haben. Sie müssen std::atomic Verwenden, um zuverlässige Ergebnisse zu erzielen. Sie können es jedoch auch mit memory_order_relaxed Verwenden, wenn Sie sich nicht für eine Neuordnung interessieren. Im Folgenden finden Sie einige Beispiele für Code- und ASM-Ausgaben mit fetch_add.


Aber zuerst ist die Assemblersprache Teil der Frage:

Da num ++ eine Anweisung ist (add dword [num], 1), Können wir daraus schließen, dass num ++ in diesem Fall atomar ist?

Speicherzielbefehle (außer reinen Speichern) sind Lese-, Änderungs- und Schreibvorgänge, die in mehreren internen Schritten ausgeführt werden. Es wird kein Architekturregister geändert, aber die CPU muss die Daten intern speichern, während sie über ihr ALU gesendet wird. Die eigentliche Registerdatei ist nur ein kleiner Teil des Datenspeichers in der einfachsten CPU, wobei Latches Ausgänge einer Stufe als Eingänge für eine andere Stufe usw. usw. enthalten.

Speicheroperationen von anderen CPUs können zwischen Laden und Speichern global sichtbar werden. Das heißt zwei Threads, die add dword [num], 1 in einer Schleife ausführen, würden in den Speichern des jeweils anderen vorgehen. (Siehe @ Margarets Antwort für ein schönes Diagramm). Nach 40.000 Schritten von jedem der beiden Threads ist der Zähler auf echter x86-Hardware mit mehreren Kernen möglicherweise nur um ~ 60.000 (nicht um 80.000) gestiegen.


"Atom", vom griechischen Wort für unteilbar, bedeutet, dass kein Beobachterdie Operation als separate Schritte sehen kann. Physikalisch/elektrisch augenblicklich für alle Bits gleichzeitig zu geschehen, ist nur eine Möglichkeit, dies für ein Laden oder Speichern zu erreichen, aber dies ist nicht einmal für eine ALU-Operation möglich. Ich habe mich eingehender mit pure befasst Lädt und speichert in meiner Antwort aufAtomicity on x86, während sich diese Antwort auf das Lesen, Ändern und Schreiben konzentriert.

Das Präfix lock kann auf viele Anweisungen zum Lesen, Ändern und Schreiben (Speicherziel) angewendet werden, um die gesamte Operation in Bezug auf alle möglichen Beobachter im System (andere Kerne und DMA Geräte, kein an die CPU-Pins angeschlossenes Oszilloskop). Deshalb existiert es. (Siehe auch this Q & A ).

Also lock add dword [num], 1istatomar . Ein CPU-Kern, der diese Anweisung ausführt, würde die Cache-Zeile in ihrem privaten L1-Cache im modifizierten Zustand belassen, von dem Zeitpunkt an, an dem die Last Daten aus dem Cache liest, bis der Speicher sein Ergebnis zurück in den Cache schreibt. Auf diese Weise wird verhindert, dass ein anderer Cache im System zu irgendeinem Zeitpunkt vom Laden bis zum Speichern eine Kopie der Cache-Zeile erhält, und zwar gemäß den Regeln des MESI-Cache-Kohärenzprotokoll (oder der von ihm verwendeten MOESI/MESIF-Versionen) durch Mehrkern-AMD/Intel-CPUs). Daher scheinen Operationen durch andere Kerne entweder vorher oder nachher statt währenddessen zu erfolgen.

Ohne das lock -Präfix könnte ein anderer Core die Cache-Zeile übernehmen und sie nach dem Laden, jedoch vor dem Laden ändern, sodass der andere Speicher zwischen Laden und Laden global sichtbar wird. Bei mehreren anderen Antworten ist dies falsch und es wird behauptet, dass Sie ohne lock widersprüchliche Kopien derselben Cache-Zeile erhalten würden. Dies kann in einem System mit kohärenten Caches niemals passieren.

(Wenn eine locked-Anweisung in einem Speicher ausgeführt wird, der zwei Cache-Zeilen umfasst, ist viel mehr Arbeit erforderlich, um sicherzustellen, dass die Änderungen an beiden Teilen des Objekts atomar bleiben, da sie sich auf alle Beobachter übertragen, sodass kein Beobachter sie sehen kann Die CPU muss möglicherweise den gesamten Speicherbus sperren, bis die Daten in den Speicher gelangen.

Beachten Sie, dass das lock -Präfix einen Befehl auch in eine vollständige Speicherbarriere verwandelt (wie MFENCE ), wodurch die gesamte Laufzeitumordnung gestoppt und somit sequentiell ausgeführt wird Konsistenz. (Siehe Jeff Preshings exzellenter Blog-Beitrag . Seine anderen Beiträge sind ebenfalls exzellent und erklären klar und deutlich einelotvon guten Dingen über sperrenfreie Programmierung . _, von x86 und anderen Hardwaredetails bis hin zu C++ - Regeln.)


Auf einem Einprozessor-Computer oder in einem Singlethread-Prozess wird ein einzelnes RMW Anweisung tatsächlichistatomic ohne ein lock Präfix. Die einzige Möglichkeit für anderen Code, auf die gemeinsam genutzte Variable zuzugreifen, besteht darin, dass die CPU einen Kontextwechsel durchführt, der nicht in der Mitte eines Befehls erfolgen kann. Ein einfaches dec dword [num] Kann also zwischen einem Single-Threaded-Programm und seinen Signal-Handlern oder in einem Multi-Threaded-Programm synchronisieren, das auf einem Single-Core-Computer ausgeführt wird. Siehe die zweite Hälfte meiner Antwort auf eine andere Frage und die Kommentare darunter, wo ich dies genauer erläutere.


Zurück zu C++:

Es ist völlig falsch, num++ Zu verwenden, ohne dem Compiler mitzuteilen, dass Sie es für die Kompilierung einer einzelnen Lese-Änderungs-Schreib-Implementierung benötigen:

;; Valid compiler output for num++
mov   eax, [num]
inc   eax
mov   [num], eax

Dies ist sehr wahrscheinlich, wenn Sie den Wert von num später verwenden: Der Compiler behält ihn nach dem Inkrement in einem Register bei. Selbst wenn Sie überprüfen, wie num++ Von selbst kompiliert wird, kann sich eine Änderung des umgebenden Codes darauf auswirken.

(Wenn der Wert später nicht benötigt wird, wird inc dword [num] Bevorzugt; moderne x86-CPUs führen einen Speicherziel-RMW-Befehl mindestens so effizient aus wie die Verwendung von drei separaten Befehlen. Spaßfaktor: gcc -O3 -m32 -mtune=i586 wird dies tatsächlich ausgeben , da die superskalare Pipeline von (Pentium) P5 komplexe Anweisungen nicht wie in P6 und späteren Mikroarchitekturen in mehrere einfache Mikrooperationen dekodiert hat. Siehe Anleitungstabellen/Mikroarchitektur-Handbuch von Agner Fog für weitere Informationen und das x86 Tag-Wiki für viele nützliche Links (einschließlich der x86 ISA Handbücher von Intel, die als PDF frei verfügbar sind).


Verwechseln Sie das Zielspeichermodell (x86) nicht mit dem C++ - Speichermodell

Neuordnung zur Kompilierungszeit ist zulässig . Der andere Teil dessen, was Sie mit std :: atomic erhalten, ist die Kontrolle über die Neuordnung beim Kompilieren, um sicherzustellen, dass Ihr num++ Nur nach einer anderen Operation global sichtbar wird.

Klassisches Beispiel: Speichern einiger Daten in einem Puffer, damit ein anderer Thread sie anzeigen kann, und Setzen eines Flags. Auch wenn x86 kostenlos Loads/Release-Stores erwirbt, müssen Sie den Compiler anweisen, die Reihenfolge nicht mit flag.store(1, std::memory_order_release); zu ändern.

Möglicherweise erwarten Sie, dass dieser Code mit anderen Threads synchronisiert wird:

// flag is just a plain int global, not std::atomic<int>.
flag--;       // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo);    // doesn't look at flag, and the compilers knows this.  (Assume it can see the function def).  Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;

Aber das wird es nicht. Dem Compiler steht es frei, den flag++ Über den Funktionsaufruf zu bewegen (wenn er die Funktion einfügt oder weiß, dass er flag nicht ansieht). Dann kann die Modifikation vollständig wegoptimiert werden, da flag nicht einmal volatile ist. (Und nein, C++ volatile ist kein nützlicher Ersatz für std :: atomic. Std :: atomic lässt den Compiler annehmen, dass Werte im Speicher asynchron geändert werden können, ähnlich wie volatile, aber es gibt viel mehr als das. Außerdem ist volatile std::atomic<int> foo nicht dasselbe wie std::atomic<int> foo, wie mit @Richard Hodges besprochen.)

Das Definieren von Datenrassen für nichtatomare Variablen als undefiniertes Verhalten ermöglicht es dem Compiler, weiterhin Speicher aus Schleifen zu laden und abzusenken, und viele andere Optimierungen für den Speicher, auf die mehrere Threads möglicherweise verweisen. (Weitere Informationen darüber, wie UB Compiler-Optimierungen ermöglicht, finden Sie in diesem LLVM-Blog .)


Wie bereits erwähnt, ist das Präfix x86 lock eine vollständige Speichersperre. Wenn Sie also num.fetch_add(1, std::memory_order_relaxed); verwenden, wird auf x86 derselbe Code wie bei num++ Generiert (Standardeinstellung) ist sequentielle Konsistenz), aber es kann auf anderen Architekturen (wie ARM) viel effizienter sein. Selbst auf x86 ermöglicht "Relaxed" eine schnellere Neuordnung bei der Kompilierung.

Dies ist das, was GCC auf x86 für einige Funktionen ausführt, die mit einer globalen Variablen std::atomic Arbeiten.

Siehe den Quellcode + Assembler-Code, der im Godbolt-Compiler-Explorer gut formatiert ist. Sie können andere Zielarchitekturen auswählen, einschließlich ARM, MIPS und PowerPC, um zu sehen, welchen Assembler-Code Sie von Atomics für diese Ziele erhalten.

#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
  num.fetch_add(1, std::memory_order_relaxed);
}

int load_num() { return num; }            // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
  num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.
# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
    lock add        DWORD PTR num[rip], 1      #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
    ret
inc_seq_cst():
    lock add        DWORD PTR num[rip], 1
    ret
load_num():
    mov     eax, DWORD PTR num[rip]
    ret
store_num(int):
    mov     DWORD PTR num[rip], edi
    mfence                          ##### seq_cst stores need an mfence
    ret
store_num_release(int):
    mov     DWORD PTR num[rip], edi
    ret                             ##### Release and weaker doesn't.
store_num_relaxed(int):
    mov     DWORD PTR num[rip], edi
    ret

Beachten Sie, wie MFENCE (eine vollständige Barriere) nach dem Speichern einer sequentiellen Konsistenz benötigt wird. x86 wird im Allgemeinen streng bestellt, aber StoreLoad-Neuordnungen sind zulässig. Ein Speicherpuffer ist für eine gute Leistung auf einer in Pipelines ausgelagerten, nicht ordnungsgemäßen CPU unerlässlich. Jeff Preshings Memory Reordering Caught in the Act zeigt die Konsequenzen vonnotusing MFENCE, with echter Code, um anzuzeigen, dass die Neuordnung auf echter Hardware stattfindet.


Betreff: Diskussion in Kommentaren zur Antwort von @Richard Hodges über Compiler, die std :: atomic num++; num-=2; - Operationen zu einer num--; - Anweisung zusammenführen :

Eine separate Frage & Antwort zu demselben Thema: Warum führen Compiler nicht redundante std :: atomic-Schreibvorgänge zusammen?, wo meine Antwort erneut lautet viel von dem, was ich unten geschrieben habe.

Aktuelle Compiler machen das (noch) nicht, aber nicht, weil sie das nicht dürfen. C++ WG21/P0062R1: Wann sollten Compiler Atomics optimieren? diskutiert die Erwartung, die viele Programmierer haben, dass Compiler "nicht überraschen" "optimierungen und was der standard tun kann, um programmierern die kontrolle zu geben. N4455 beschreibt viele Beispiele für Dinge, die optimiert werden können, einschließlich dieses. Es wird darauf hingewiesen, dass Inlining und konstante Propagierung Dinge wie fetch_or(0) einführen können, die möglicherweise nur zu einer load() werden können (aber dennoch Semantik erwerben und freigeben), selbst wenn das Original vorliegt source hatte keine offensichtlich redundanten atomaren Operationen.

Die wahren Gründe, warum Compiler dies (noch) nicht tun, sind: (1) Niemand hat den komplizierten Code geschrieben, der es dem Compiler ermöglichen würde, dies sicher zu tun (ohne es jemals falsch zu verstehen), und (2) es verstößt möglicherweise gegen Prinzip der geringsten Überraschung . Sperrfreier Code ist schwer genug, um überhaupt richtig zu schreiben. Seien Sie also nicht lässig im Umgang mit Atomwaffen: Sie sind nicht billig und optimieren nicht viel. Es ist jedoch nicht immer einfach, redundante atomare Operationen mit std::shared_ptr<T> Zu vermeiden, da es keine nicht-atomare Version davon gibt (obwohl eine der Antworten hier eine einfache Möglichkeit zum Definieren eines shared_ptr_unsynchronized<T> Für gcc).


Zurück zum Kompilieren von num++; num-=2;, Als wäre es num--: Compilerdürfen dies, es sei denn, num ist volatile std::atomic<int>. Wenn eine Neuordnung möglich ist, kann der Compiler nach der As-If-Regel zum Kompilierungszeitpunkt entscheiden, dassalwaysso vorkommt. Nichts garantiert, dass ein Beobachter die Zwischenwerte sehen kann (das Ergebnis num++).

Das heißt Wenn die Reihenfolge, in der zwischen diesen Operationen nichts global sichtbar wird, mit den Bestellanforderungen der Quelle kompatibel ist (gemäß den C++ - Regeln für die abstrakte Maschine, nicht der Zielarchitektur), kann der Compiler stattdessen ein einzelnes lock dec dword [num] ausgeben von lock inc dword [num]/lock sub dword [num], 2.

num++; num-- Kann nicht verschwinden, da es immer noch eine Synchronizes With-Beziehung zu anderen Threads hat, die sich mit num befassen, und es ist sowohl ein Erfassungs- als auch ein Freigabespeicher, der die Neuordnung anderer Vorgänge verbietet in diesem Thread. Für x86 ist dies möglicherweise in der Lage, anstelle von lock add dword [num], 0 (D. H. num += 0) Eine MFENCE zu kompilieren.

Wie in PR0062 erläutert, kann ein aggressiveres Zusammenführen nicht benachbarter atomarer Operationen zur Kompilierungszeit schlecht sein (z. B. ein Fortschrittszähler wird nur einmal am Ende anstelle jeder Iteration aktualisiert), kann jedoch auch die Leistung verbessern ohne Nachteile (z. B. Überspringen der atomaren Inc/Dec von Ref Count, wenn eine Kopie eines shared_ptr - Objekts erstellt und zerstört wird, wenn der Compiler nachweisen kann, dass ein anderes shared_ptr - Objekt für die gesamte Lebensdauer des temporären Objekts vorhanden ist .)

Selbst das Zusammenführen von num++; num-- Kann die Fairness einer Sperrenimplementierung beeinträchtigen, wenn ein Thread sofort entsperrt und erneut gesperrt wird. Wenn es im asm nie veröffentlicht wird, geben selbst Hardware-Arbitrierungsmechanismen keinem anderen Thread die Chance, an diesem Punkt die Sperre zu übernehmen.


Mit den aktuellen Versionen gcc6.2 und clang3.9 erhalten Sie auch mit memory_order_relaxed Im offensichtlich optimierbaren Fall separate locked -Operationen. ( Godbolt-Compiler-Explorer , damit Sie sehen können, ob die neuesten Versionen unterschiedlich sind.)

void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
  num.fetch_add( 1, std::memory_order_relaxed);
  num.fetch_add(-1, std::memory_order_relaxed);
  num.fetch_add( 6, std::memory_order_relaxed);
  num.fetch_add(-5, std::memory_order_relaxed);
  //num.fetch_add(-1, std::memory_order_relaxed);
}

multiple_ops_relaxed(std::atomic<unsigned int>&):
    lock add        DWORD PTR [rdi], 1
    lock sub        DWORD PTR [rdi], 1
    lock add        DWORD PTR [rdi], 6
    lock sub        DWORD PTR [rdi], 5
    ret
185
Peter Cordes

... und jetzt ermöglichen wir Optimierungen:

f():
        rep ret

OK, lass es uns versuchen:

void f(int& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

ergebnis:

f(int&):
        mov     DWORD PTR [rdi], 0
        ret

ein anderer Beobachtungsthread (der Cache-Synchronisationsverzögerungen ignoriert) hat keine Möglichkeit, die einzelnen Änderungen zu beobachten.

vergleichen mit:

#include <atomic>

void f(std::atomic<int>& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

wo das Ergebnis ist:

f(std::atomic<int>&):
        mov     DWORD PTR [rdi], 0
        mfence
        lock add        DWORD PTR [rdi], 1
        lock sub        DWORD PTR [rdi], 1
        lock add        DWORD PTR [rdi], 6
        lock sub        DWORD PTR [rdi], 5
        lock sub        DWORD PTR [rdi], 1
        ret

Jetzt ist jede Änderung:

  1. in einem anderen Thread beobachtbar, und
  2. achtung vor ähnlichen Modifikationen, die in anderen Threads vorkommen.

atomicity ist nicht nur auf Befehlsebene, sondern umfasst die gesamte Pipeline vom Prozessor über die Caches bis zum Speicher und zurück.

Weitere Informationen

In Bezug auf den Effekt von Optimierungen von Aktualisierungen von std::atomics.

Der c ++ - Standard hat die 'als ob'-Regel, nach der der Compiler Code neu ordnen und sogar Code neu schreiben darf, vorausgesetzt, das Ergebnis hat die genau das gleiche beobachtbare -Effekte (einschließlich Nebenwirkungen) als ob es einfach Ihren Code ausgeführt hätte.

Die Als-ob-Regel ist konservativ, insbesondere unter Einbeziehung der Atomik.

erwägen:

void incdec(int& num) {
    ++num;
    --num;
}

Da es keine Mutex-Sperren, Atomics oder andere Konstrukte gibt, die die Inter-Thread-Sequenzierung beeinflussen, würde ich argumentieren, dass der Compiler diese Funktion als NOP neu schreiben kann, z.

void incdec(int&) {
    // nada
}

Dies liegt daran, dass es im c ++ - Speichermodell keine Möglichkeit gibt, dass ein anderer Thread das Ergebnis des Inkrements beobachtet. Es wäre natürlich anders, wenn numvolatile wäre (könnte das Hardwareverhalten beeinflussen). In diesem Fall ist diese Funktion jedoch die einzige Funktion, die diesen Speicher ändert (andernfalls ist das Programm fehlerhaft).

Dies ist jedoch ein anderes Ballspiel:

void incdec(std::atomic<int>& num) {
    ++num;
    --num;
}

num ist ein Atom. Änderungen daran muss können von anderen Threads beobachtet werden. Änderungen, die diese Threads selbst vornehmen (z. B. das Setzen des Werts zwischen Inkrement und Dekrement auf 100), haben weitreichende Auswirkungen auf den endgültigen Wert von num.

Hier ist eine Demo:

#include <thread>
#include <atomic>

int main()
{
    for (int iter = 0 ; iter < 20 ; ++iter)
    {
        std::atomic<int> num = { 0 };
        std::thread t1([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                ++num;
                --num;
            }
        });
        std::thread t2([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                num = 100;
            }
        });

        t2.join();
        t1.join();
        std::cout << num << std::endl;
    }
}

beispielausgabe:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99
39
Richard Hodges

Ohne viele Komplikationen eine Anweisung wie add DWORD PTR [rbp-4], 1 ist sehr CISC-artig.

Es werden drei Operationen ausgeführt: Laden des Operanden aus dem Speicher, Inkrementieren des Operanden und Zurückspeichern des Operanden in den Speicher.
Während dieser Operationen erfasst und gibt die CPU den Bus zweimal frei, dazwischen kann es auch jeder andere Agent erfassen und dies verletzt die Atomizität.

AGENT 1          AGENT 2

load X              
inc C
                 load X
                 inc C
                 store X
store X

X wird nur einmal erhöht.

37
Margaret Bloom

Die Add-Anweisung lautet nicht atomic. Es verweist auf den Speicher, und zwei Prozessorkerne verfügen möglicherweise über einen unterschiedlichen lokalen Cache dieses Speichers.

IIRC Die atomare Variante der Add-Anweisung heißt lock xadd

11
Sven Nilsson

Da Zeile 5, die num ++ entspricht, eine Anweisung ist, können wir daraus schließen, dass num ++ in diesem Fall atomar ist?

Es ist gefährlich, Schlussfolgerungen auf der Grundlage der durch "Reverse Engineering" erzeugten Baugruppe zu ziehen. Anscheinend haben Sie Ihren Code mit deaktivierter Optimierung kompiliert, andernfalls hätte der Compiler diese Variable verworfen oder 1 direkt in sie geladen, ohne operator++ Aufzurufen. Da sich die generierte Assembly basierend auf Optimierungsflags, Ziel-CPU usw. möglicherweise erheblich ändert, basiert Ihre Schlussfolgerung auf Sand.

Auch Ihre Vorstellung, dass eine Montageanweisung bedeutet, dass eine Operation atomar ist, ist falsch. Dieses add ist auf Multi-CPU-Systemen selbst in der x86-Architektur nicht atomar.

10
Slava

Auf einem Single-Core-x86-Rechner ist eine add -Anweisung im Allgemeinen in Bezug auf anderen Code auf der CPU atomar1. Ein Interrupt kann einen einzelnen Befehl nicht in der Mitte aufteilen.

Out-of-Order-Ausführung ist erforderlich, um die Illusion von Befehlen zu bewahren, die einzeln in der Reihenfolge innerhalb eines einzelnen Kerns ausgeführt werden, sodass Befehle, die auf derselben CPU ausgeführt werden, entweder vollständig vor oder vollständig nach dem Hinzufügen ausgeführt werden.

Moderne x86-Systeme sind Multi-Core-Systeme, sodass der Einprozessor-Sonderfall nicht zutrifft.

Wenn man auf einen kleinen Embedded-PC abzielt und nicht vorhat, den Code an eine andere Stelle zu verschieben, kann die atomare Natur des Befehls "add" ausgenutzt werden. Auf der anderen Seite werden Plattformen, auf denen Operationen von Natur aus atomar sind, immer knapper.

(Dies hilft Ihnen jedoch nicht, wenn Sie in C++ schreiben. Compiler haben keine Option, num++ zum Kompilieren in ein Speicherziel add oder xadd ohne ein lock Präfix. Sie könnten wählen, num in ein Register zu laden und das Inkrementierungsergebnis mit einer separaten Anweisung zu speichern, und werden dies wahrscheinlich tun, wenn Sie das Ergebnis verwenden.)


Fußnote 1: Das lock -Präfix gab es sogar im ursprünglichen 8086, da E/A-Geräte gleichzeitig mit der CPU arbeiten. Treiber auf einem Single-Core-System benötigen lock add, um einen Wert im Gerätespeicher atomar zu erhöhen, wenn das Gerät ihn auch ändern kann, oder in Bezug auf DMA Zugriff.

9
supercat

Selbst wenn Ihr Compiler dies immer als atomare Operation ausgegeben hätte, würde der gleichzeitige Zugriff auf num von einem beliebigen anderen Thread aus ein Datenrennen gemäß den Standards C++ 11 und C++ 14 darstellen, und das Programm würde ein undefiniertes Verhalten aufweisen.

Aber es ist schlimmer als das. Erstens kann, wie bereits erwähnt, die vom Compiler beim Inkrementieren einer Variablen erzeugte Anweisung von der Optimierungsstufe abhängen. Zweitens kann der Compiler other Speicherzugriffe um ++num Neu anordnen, wenn num nicht atomar ist, z.

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  int ready = 0;
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Auch wenn wir optimistisch davon ausgehen, dass ++ready "Atomar" ist und der Compiler die Prüfschleife nach Bedarf generiert (wie gesagt, es ist UB, und der Compiler kann sie daher frei entfernen), ersetzen Sie sie durch eine Endlosschleife usw.) kann der Compiler die Zeigerzuweisung oder noch schlimmer die Initialisierung von vector auf einen Punkt nach der Inkrementierungsoperation verschieben und Chaos im neuen Thread verursachen. In der Praxis wäre ich überhaupt nicht überrascht, wenn ein optimierender Compiler die Variable ready und die Prüfschleife vollständig entfernt hätte, da dies das beobachtbare Verhalten unter Sprachregeln (im Gegensatz zu Ihren privaten Hoffnungen) nicht beeinträchtigt.

Tatsächlich habe ich auf der letztjährigen Meeting C++ - Konferenz von two Compiler-Entwicklern gehört, dass sie sehr gerne Optimierungen implementieren, die dazu führen, dass naiv geschriebene Multithread-Programme sich so lange wie die Sprache schlecht verhalten Regeln erlauben es, wenn in korrekt geschriebenen Programmen auch nur eine geringfügige Leistungsverbesserung zu sehen ist.

Schließlich haben Sie auch if nicht auf Portabilität geachtet, und Ihr Compiler war magisch gut. Die von Ihnen verwendete CPU ist höchstwahrscheinlich ein superskalarer CISC-Typ und wird Anweisungen aufteilen Micro-Ops können neu angeordnet und/oder spekulativ ausgeführt werden, sofern dies nur durch die Synchronisierung von Primitiven wie (bei Intel) dem Präfix LOCK oder Speicherzäunen eingeschränkt ist, um die Operationen pro Sekunde zu maximieren.

Um es kurz zu machen, die natürlichen Verantwortlichkeiten der thread-sicheren Programmierung sind:

  1. Ihre Aufgabe ist es, Code zu schreiben, der nach Sprachregeln (und insbesondere nach dem Sprachstandard-Speichermodell) ein genau definiertes Verhalten aufweist.
  2. Die Aufgabe Ihres Compilers besteht darin, Maschinencode zu generieren, der im Speichermodell der Zielarchitektur dasselbe genau definierte (beobachtbare) Verhalten aufweist.
  3. Die Aufgabe Ihrer CPU besteht darin, diesen Code so auszuführen, dass das beobachtete Verhalten mit dem Speichermodell der eigenen Architektur kompatibel ist.

Wenn Sie es auf Ihre eigene Art und Weise tun möchten, funktioniert es möglicherweise nur in einigen Fällen. Verstehen Sie jedoch, dass die Garantie ungültig ist, und Sie sind allein verantwortlich für alle unerwünschten Ergebnisse. :-)

PS: Richtig geschriebenes Beispiel:

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  std::atomic<int> ready{0}; // NOTE the use of the std::atomic template
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Das ist sicher, weil:

  1. Die Prüfungen von ready können nach Sprachregeln nicht wegoptimiert werden.
  2. Der ++ready happen-before die Prüfung, die ready als nicht Null ansieht, und andere Operationen können um diese Operationen nicht neu angeordnet werden. Dies liegt daran, dass ++ready Und die Prüfung sequentiell konsistent sind. Dies ist ein weiterer Begriff, der im C++ - Speichermodell beschrieben wird und der diese spezielle Neuordnung verbietet. Daher darf der Compiler die Anweisungen nicht neu anordnen und muss auch der CPU mitteilen, dass es nicht z. Verschieben Sie den Schreibvorgang auf vec nach dem Inkrementieren von ready. Sequentiell konsistent ist die stärkste Garantie in Bezug auf Atomik im Sprachstandard. Geringere (und theoretisch billigere) Garantien sind z. über andere Methoden von std::atomic<T>, aber diese sind definitiv nur für Experten gedacht und werden von den Compiler-Entwicklern möglicherweise nicht stark optimiert, da sie selten verwendet werden.
9
Arne Vogel

Damals, als x86-Computer über eine CPU verfügten, stellte die Verwendung eines einzelnen Befehls sicher, dass Interrupts das Lesen/Ändern/Schreiben nicht aufteilen und der Speicher nicht als DMA Puffer verwendet wird Auch es war in der Tat atomar (und C++ erwähnte keine Threads im Standard, so dass dies nicht angesprochen wurde).

Wenn es selten vorkam, dass ein Kunden-Desktop über einen Doppelprozessor (z. B. Pentium Pro mit zwei Sockeln) verfügte, nutzte ich diesen, um das Präfix LOCK auf einem Einzelkerncomputer zu vermeiden und die Leistung zu verbessern.

Heutzutage hilft dies nur bei mehreren Threads, die alle auf die gleiche CPU-Affinität eingestellt sind. Die Threads, um die Sie sich Sorgen machen, werden also nur durch das Ablaufen der Zeitscheibe und das Ausführen des anderen Threads auf derselben CPU (Kern) ins Spiel gebracht. Das ist nicht realistisch.

Bei modernen x86/x64-Prozessoren wird der einzelne Befehl in mehrere Mikrooperationen aufgeteilt und das Lesen und Schreiben des Speichers wird außerdem gepuffert. Verschiedene Threads, die auf verschiedenen CPUs ausgeführt werden, sehen dies nicht nur als nicht-atomar, sondern können auch inkonsistente Ergebnisse hinsichtlich dessen anzeigen, was aus dem Speicher gelesen wird und was andere Threads bis zu diesem Zeitpunkt gelesen haben: Sie müssen memory hinzufügen Zäune um das gesunde Verhalten wiederherzustellen.

7
JDługosz

Nein. https://www.youtube.com/watch?v=31g0YE61PLQ (Dies ist nur ein Link zur "Nein" -Szene von "The Office".)

Stimmen Sie zu, dass dies eine mögliche Ausgabe für das Programm wäre:

beispielausgabe:

100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100

Wenn ja, dann kann der Compiler frei machen, dass die nur mögliche Ausgabe für das Programm erfolgt, auf welche Weise der Compiler dies wünscht. dh ein main (), das nur 100s ausgibt.

Dies ist die "Als-ob" -Regel.

Und unabhängig von der Ausgabe können Sie die Thread-Synchronisation auf dieselbe Weise betrachten - wenn Thread A num++; num--; und Thread B liest num wiederholt, dann ist eine mögliche gültige Verschachtelung, dass Thread B niemals zwischen num++ und num--. Da dieses Interleaving gültig ist, kann der Compiler das nur mögliche Interleaving machen. Und entfernen Sie einfach das Incr/Decr vollständig.

Hier gibt es einige interessante Implikationen:

while (working())
    progress++;  // atomic, global

(Stellen Sie sich vor, ein anderer Thread aktualisiert eine Fortschrittsbalken-Benutzeroberfläche basierend auf progress)

Kann der Compiler daraus machen:

int local = 0;
while (working())
    local++;

progress += local;

wahrscheinlich ist das gültig. Aber wahrscheinlich nicht das, was der Programmierer erhofft hatte :-(

Das Komitee arbeitet immer noch an diesem Zeug. Derzeit "funktioniert" es, weil Compiler Atomics nicht viel optimieren. Aber das ändert sich.

Und selbst wenn progress ebenfalls flüchtig wäre, wäre dies immer noch gültig:

int local = 0;
while (working())
    local++;

while (local--)
    progress++;

: - /

4
tony

Die Ausgabe eines einzelnen Compilers auf einer bestimmten CPU-Architektur mit deaktivierten Optimierungen (da gcc beim Optimieren nicht einmal ++ Zu add kompiliert in einem schnellen und unsauberen Beispiel ) , scheint zu implizieren, dass das Inkrementieren auf diese Weise atomar ist, bedeutet nicht, dass dies standardkonform ist (Sie würden undefiniertes Verhalten verursachen, wenn Sie versuchen, auf num in einem Thread zuzugreifen), und ist sowieso falsch, weil add ist nicht atomar in x86.

Beachten Sie, dass Atomics (mit dem Befehlspräfix lock) auf x86 relativ umfangreich sind ( siehe diese relevante Antwort ), aber immer noch bemerkenswert kleiner als ein Mutex, was in dieser Hinsicht nicht sehr angemessen ist Anwendungsfall.

Die folgenden Ergebnisse stammen aus clang ++ 3.8, wenn mit -Os Kompiliert wird.

Erhöhen eines Int durch Verweis auf die "reguläre" Weise:

void inc(int& x)
{
    ++x;
}

Dies kompiliert in:

inc(int&):
    incl    (%rdi)
    retq

Inkrementieren eines als Referenz übergebenen Int auf atomare Weise:

#include <atomic>

void inc(std::atomic<int>& x)
{
    ++x;
}

In diesem Beispiel, das nicht viel komplexer ist als der normale Weg, wird nur das Präfix lock zur Anweisung incl hinzugefügt - aber Vorsicht, wie bereits erwähnt, ist dies nicht billig. Nur weil Assembly kurz aussieht, heißt das nicht, dass es schnell ist.

inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq
2
Asu

Ja aber...

Atomic ist nicht das, was Sie sagen wollten. Sie fragen wahrscheinlich das Falsche.

Das Inkrement ist sicherlich atomar . Sofern der Speicher nicht falsch ausgerichtet ist (und da Sie die Ausrichtung dem Compiler überlassen haben, ist dies nicht der Fall), wird er notwendigerweise innerhalb einer einzelnen Cache-Zeile ausgerichtet. Abgesehen von speziellen Nicht-Caching-Streaming-Anweisungen durchläuft jeder einzelne Schreibvorgang den Cache. Komplette Cache-Zeilen werden atomar gelesen und geschrieben, niemals etwas anderes.
Daten, die kleiner als die Cache-Zeile sind, werden natürlich auch atomar geschrieben (da die umgebende Cache-Zeile ist).

Ist es threadsicher?

Dies ist eine andere Frage, und es gibt mindestens zwei gute Gründe, die mit einem eindeutigen "Nein!" zu beantworten.

Erstens besteht die Möglichkeit, dass ein anderer Kern eine Kopie dieser Cache-Zeile in L1 hat (L2 und höher wird normalerweise gemeinsam genutzt, L1 ist jedoch normalerweise pro Kern!) Und diesen Wert gleichzeitig ändert. Natürlich passiert das auch atomar, aber jetzt haben Sie zwei "richtige" (richtig, atomar, modifizierte) Werte - welcher ist jetzt der wirklich richtige?
Die CPU wird das natürlich irgendwie klären. Aber das Ergebnis entspricht möglicherweise nicht Ihren Erwartungen.

Zweitens gibt es Speicherordnung oder anders formulierte Ereignisse - bevor Garantien. Das Wichtigste an atomaren Anweisungen ist nicht so sehr, dass sie atomar sind . Es bestellt.

Sie haben die Möglichkeit, eine Garantie zu erzwingen, dass alles, was in Bezug auf den Speicher geschieht, in einer garantierten, genau definierten Reihenfolge ausgeführt wird, in der Sie eine Garantie haben, die "vor" liegt. Diese Reihenfolge kann so "entspannt" (gelesen als: überhaupt keine) oder so streng sein, wie Sie es benötigen.

Beispielsweise können Sie einen Zeiger auf einen Datenblock setzen (z. B. das Ergebnis einer Berechnung) und dann die "Daten sind bereit" atomar freigeben . Flagge. Nun wird derjenige, der dieses Flag erwirbt zu der Annahme geführt, dass der Zeiger gültig ist. Und in der Tat wird es immer ein gültiger Zeiger sein, niemals etwas anderes. Das liegt daran, dass das Schreiben in den Zeiger vor der atomaren Operation stattgefunden hat.

2
Damon