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

153

Im Allgemeinen ist for int num, num++(oder ++num) als Lese-, Änderungs- und Schreiboperation nicht atomar . Aber ich sehe oft, dass Compiler, zum Beispiel GCC , den folgenden Code dafür generieren ( versuchen Sie es hier ):

Geben Sie hier die Bildbeschreibung ein

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

Und wenn ja, bedeutet dies, dass so generierte num++in gleichzeitigen (Multithread-) Szenarien verwendet werden können, ohne dass die Gefahr von Datenrennen besteht (dh wir müssen es beispielsweise nicht schaffen std::atomic<int>und die damit verbundenen Kosten verursachen, da dies der Fall ist Atom sowieso)?

AKTUALISIEREN

Beachten Sie, dass diese Frage ist nicht , ob Zuwachs ist Atom (es ist nicht und das war und ist die erste Zeile der Frage). Es geht darum, ob es sich um bestimmte Szenarien handeln kann, dh ob in bestimmten Fällen eine Anweisung ausgeführt werden kann, um den Overhead des lockPräfixes zu vermeiden . Und wie die akzeptierte Antwort im Abschnitt über Einprozessor-Maschinen erwähnt, sowie diese Antwort , die Konversation in ihren Kommentaren und anderen erklären, kann es (obwohl nicht mit C oder C ++).

Leo Heinsaar
quelle
65
Wer hat dir gesagt, dass adddas atomar ist?
Slava
6
Angesichts der Tatsache, dass eines der Merkmale der Atomik darin besteht, bestimmte Arten der Neuordnung während der Optimierung zu verhindern, nein, unabhängig von der Atomizität der tatsächlichen Operation
jaggedSpire
19
Ich möchte auch darauf hinweisen, dass es keine Garantie dafür gibt, dass es sich auf einer anderen Plattform befindet , wenn dies auf Ihrer Plattform atomar ist. Seien Sie plattformunabhängig und drücken Sie Ihre Absicht mit a aus std::atomic<int>.
NathanOliver
8
Während der Ausführung dieses addBefehls könnte ein anderer Kern diese Speicheradresse aus dem Cache dieses Kerns stehlen und ändern. Auf einer x86-CPU addbenötigt der Befehl ein lockPräfix, wenn die Adresse für die Dauer des Vorgangs im Cache gesperrt werden muss.
David Schwartz
21
Es ist möglich, dass jede Operation "atomar" ist. Alles, was Sie tun müssen, ist Glück zu haben und niemals etwas auszuführen, das zeigen würde, dass es nicht atomar ist. Atomic ist nur als Garantie wertvoll . Angesichts der Tatsache, dass Sie sich mit Assemblycode befassen, stellt sich die Frage, ob diese bestimmte Architektur Ihnen die Garantie bietet und ob der Compiler eine Garantie dafür bietet, dass dies die von ihnen gewählte Implementierung auf Assemblyebene ist.
Cort Ammon

Antworten:

196

Dies ist absolut das, was C ++ als Datenrennen definiert, das undefiniertes Verhalten verursacht, selbst wenn ein Compiler zufällig Code erstellt hat, der das getan hat, was Sie sich auf einem Zielcomputer erhofft haben. Sie müssen es std::atomicfür zuverlässige Ergebnisse verwenden, aber Sie können es verwenden, memory_order_relaxedwenn Sie sich nicht für eine Nachbestellung interessieren. Im Folgenden finden Sie einige Beispiele für die Code- und ASM-Ausgabe mit fetch_add.


Aber zuerst ist die Assemblersprache Teil der Frage:

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

Speicherzielanweisungen (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 sie über ihre ALU sendet . Die eigentliche Registerdatei ist nur ein kleiner Teil des Datenspeichers in selbst der einfachsten CPU, wobei Latches die Ausgänge einer Stufe als Eingänge für eine andere Stufe usw. usw. halten.

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


„Atomic“, aus dem griechischen Wort unteilbar, bedeutet , dass kein Beobachter sieht den Vorgang als getrennte Schritte. Das physische / elektrische sofortige Auftreten für alle Bits gleichzeitig ist nur eine Möglichkeit, dies für eine Last oder einen Speicher zu erreichen, aber dies ist nicht einmal für eine ALU-Operation möglich. In meiner Antwort auf Atomicity auf x86 habe ich viel detaillierter auf reine Lasten und reine Speicher eingegangen , während sich diese Antwort auf Lesen, Ändern, Schreiben konzentriert.

Das lockPräfix kann auf viele Lese-, Änderungs- und Schreibbefehle (Speicherziel) angewendet werden, um die gesamte Operation in Bezug auf alle möglichen Beobachter im System atomar zu machen (andere Kerne und DMA-Geräte, kein Oszilloskop, das an die CPU-Pins angeschlossen ist). Deshalb existiert es. (Siehe auch diese Fragen und Antworten ).

So lock add dword [num], 1 ist atomar . Ein CPU-Kern, der diesen Befehl ausführt, würde die Cache-Zeile in ihrem privaten L1-Cache im modifizierten Zustand fixieren, sobald das Laden Daten aus dem Cache liest, bis der Speicher sein Ergebnis zurück in den Cache schreibt. Dies verhindert, dass ein anderer Cache im System zu einem beliebigen Zeitpunkt vom Laden bis zum Speichern eine Kopie der Cache-Zeile gemäß den Regeln des MESI-Cache-Kohärenzprotokolls (oder der MOESI / MESIF-Versionen davon, die von Multi-Core-AMD / verwendet werden) hat. Intel-CPUs). Daher scheinen Operationen durch andere Kerne entweder vor oder nach, nicht während zu erfolgen.

Ohne das lock Präfix könnte ein anderer Kern das Eigentum an der Cache-Zeile übernehmen und sie nach dem Laden, jedoch vor unserem Geschäft ändern, sodass ein anderes Geschäft zwischen dem Laden und dem Geschäft global sichtbar wird. Mehrere andere Antworten verstehen dies falsch und behaupten, ohne dass lockSie widersprüchliche Kopien derselben Cache-Zeile erhalten würden. Dies kann in einem System mit kohärenten Caches niemals passieren.

(Wenn eine lock ed-Befehl 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 an alle Beobachter ausbreiten, sodass kein Beobachter ein Zerreißen sehen kann müssen den gesamten Speicherbus sperren, bis die Daten auf den Speicher treffen. Richten Sie Ihre atomaren Variablen nicht falsch aus!)

Beachten Sie, dass das lockPräfix einen Befehl auch in eine vollständige Speicherbarriere (wie MFENCE ) verwandelt , wodurch alle Neuordnungen zur Laufzeit gestoppt werden und somit eine sequentielle Konsistenz erzielt wird . (Siehe Jeff Preshings ausgezeichneten Blog-Beitrag . Auch seine anderen Beiträge sind ausgezeichnet und erklären deutlich viele gute Dinge über sperrenfreies Programmieren , von x86 und anderen Hardwaredetails bis hin zu C ++ - Regeln.)


Auf einer Einprozessor - Maschine oder in einem Single-Threaded - Prozess , ein einzelner RMW Befehl tatsächlich ist atomar ohne lockPrä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 mitten in einer Anweisung erfolgen kann. So kann eine Ebene dec dword [num]zwischen einem Single-Thread-Programm und seinen Signalhandlern oder in einem Multi-Thread-Programm, das auf einem Single-Core-Computer ausgeführt wird, synchronisiert werden. Siehe die zweite Hälfte meiner Antwort auf eine andere Frage und die Kommentare darunter, wo ich dies genauer erkläre.


Zurück zu C ++:

Es ist völlig falsch, es zu verwenden, num++ohne dem Compiler mitzuteilen, dass Sie es zum Kompilieren zu einer einzelnen Lese-, Änderungs- und Schreibimplementierung benötigen:

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

Dies ist sehr wahrscheinlich, wenn Sie den Wert von numspäter verwenden: Der Compiler hält ihn nach dem Inkrement in einem Register aktiv. Also auch wenn du prüfst wienum++ Kompilierung selbst durchgeführt wird, kann sich eine Änderung des umgebenden Codes darauf auswirken.

(Wenn der Wert später nicht benötigt wird, inc dword [num]wird er bevorzugt. Moderne x86-CPUs führen einen Speicherziel-RMW-Befehl mindestens so effizient aus wie drei separate Befehle. Unterhaltsame Tatsache: Gibt gcc -O3 -m32 -mtune=i586dies tatsächlich aus , da die superskalare Pipeline von (Pentium) P5 dies nicht tat Dekodieren Sie komplexe Anweisungen nicht in mehrere einfache Mikrooperationen, wie dies bei P6 und späteren Mikroarchitekturen der Fall ist Weitere Informationen finden Befehlstabellen / im Handbuch für Mikroarchitekturen von Agner Fog Tag-Wiki für viele nützliche Links (einschließlich Intels x86 ISA-Handbücher, die frei als PDF verfügbar sind)).


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

Eine Neuordnung zur Kompilierungszeit ist zulässig . Der andere Teil dessen, was Sie mit std :: atomic erhalten, ist die Kontrolle über die Neuordnung zur Kompilierungszeit, um sicherzustellen, dass Ihrenum++erst 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. Obwohl x86 Lade- / Freigabespeicher kostenlos erwirbt, müssen Sie dem Compiler dennoch mitteilen, dass er nicht mithilfe von neu bestellen soll flag.store(1, std::memory_order_release);.

Sie können erwarten, 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, flag++den Funktionsaufruf zu verschieben (wenn er die Funktion einbindet oder weiß, dass sie nicht angezeigt wird flag). Dann kann es die Modifikation komplett wegoptimieren, weil flages nicht gerade ist volatile. (Und nein, C ++ volatileist kein brauchbarer Ersatz für std :: Atom. Std :: Atom der Compiler , dass die Werte im Speicher übernehmen machen kann asynchron ähnlich modifiziert werden volatile, aber es gibt noch viel mehr zu bieten als das. Auch volatile std::atomic<int> foonicht die das gleiche wie std::atomic<int> foomit @Richard Hodges besprochen.)

Durch das Definieren von Datenrassen für nichtatomare Variablen als undefiniertes Verhalten kann der Compiler weiterhin Lasten und Senken aus Schleifen herausheben und viele andere Optimierungen für den Speicher vornehmen, auf die mehrere Threads möglicherweise verweisen. (In diesem LLVM-Blog erfahren Sie mehr darüber, wie UB Compileroptimierungen ermöglicht.)


Wie bereits erwähnt, handelt es sich bei dem x86- lockPräfix um eine vollständige Speicherbarriere. Bei der Verwendung num.fetch_add(1, std::memory_order_relaxed);von x86 wird also derselbe Code generiert num++(der Standardwert ist die sequentielle Konsistenz), bei anderen Architekturen (wie ARM) kann dies jedoch wesentlich effizienter sein. Selbst unter x86 ermöglicht Relaxed eine Neuordnung während der Kompilierungszeit.

Dies ist, was GCC tatsächlich auf x86 für einige Funktionen tut, die mit einer std::atomicglobalen Variablen arbeiten.

Sehen Sie sich den Quell- und Assemblersprachencode an, der im Godbolt-Compiler-Explorer gut formatiert ist . Sie können andere Zielarchitekturen auswählen, einschließlich ARM, MIPS und PowerPC, um zu sehen, welche Art von 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 einer Speicherung mit sequentieller Konsistenz benötigt wird. x86 ist im Allgemeinen stark geordnet, aber eine Neuordnung von StoreLoad ist zulässig. Ein Speicherpuffer ist für eine gute Leistung auf einer Pipeline-CPU ohne Betrieb unerlässlich. Jeff Preshing des Speicher Neuordnen Gefangen in der Tat zeigt die Folgen nicht MFENCE verwenden, mit echtem Code Neuordnungs zeigen auf echte Hardware geschehen.


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

Eine separate Frage und Antwort zu demselben Thema: Warum führen Compiler keine redundanten std :: atomic-Schreibvorgänge zusammen? , wo meine Antwort viel von dem wiedergibt, was ich unten geschrieben habe.

Aktuelle Compiler tun dies (noch) nicht, aber nicht, weil sie es nicht dürfen. C ++ WG21 / P0062R1: Wann sollten Compiler die Atomics optimieren? diskutiert die Erwartung, die viele Programmierer haben, dass Compiler keine "überraschenden" Optimierungen vornehmen, 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 Ausbreitung Dinge einführen fetch_or(0)können, die sich möglicherweise nur in eine load()(aber immer noch erworbene und freigegebene Semantik) verwandeln können , selbst wenn die ursprüngliche Quelle keine offensichtlich redundanten Atomoperationen hatte.

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 jemals etwas falsch zu machen), und (2) er verstößt möglicherweise gegen das 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 zu vermeiden std::shared_ptr<T>, da es keine nicht-atomare Version davon gibt (obwohl eine der Antworten hier eine einfache Möglichkeit bietet, ein shared_ptr_unsynchronized<T>für gcc zu definieren ).


Zurück zum num++; num-=2;Kompilieren, als ob es so wäre num--: Compiler dürfen dies tun, es numsei denn volatile std::atomic<int>. Wenn eine Neuordnung möglich ist, kann der Compiler nach der Als-ob-Regel zur Kompilierungszeit entscheiden, dass dies immer so geschieht. Nichts garantiert, dass ein Beobachter die Zwischenwerte (das num++Ergebnis) sehen kann.

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 für die Zielarchitektur), kann der Compiler lock dec dword [num]anstelle von lock inc dword [num]/ eine einzelne ausgeben lock sub dword [num], 2.

num++; num--kann nicht verschwinden, da es immer noch eine Beziehung zum Synchronisieren mit anderen Threads hat, die es betrachten num, und es ist sowohl ein Erfassungsladen als auch ein Release-Speicher, der die Neuordnung anderer Vorgänge in diesem Thread nicht zulässt. Für x86 kann dies möglicherweise zu einem MFENCE anstelle eines lock add dword [num], 0(dh num += 0) kompiliert werden .

Wie in PR0062 erläutert , kann eine aggressivere Zusammenführung nicht benachbarter Atomoperationen zur Kompilierungszeit schlecht sein (z. B. wird ein Fortschrittszähler am Ende statt jeder Iteration nur einmal aktualisiert), aber auch die Leistung ohne Nachteile verbessern (z. B. das Überspringen der atomic inc / dec of ref zählt, wenn eine Kopie von a shared_ptrerstellt und zerstört wird, wenn der Compiler nachweisen kann, dass ein anderes shared_ptrObjekt für die gesamte Lebensdauer des temporären Objekts vorhanden ist.)

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


Mit den aktuellen gcc6.2 und clang3.9 erhalten Sie lockauch memory_order_relaxedim offensichtlich optimierbaren Fall noch separate ed-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
Peter Cordes
quelle
1
„[mit separaten Anweisungen] verwendet , effizienter zu sein ... aber moderner x86 - CPUs wieder RMW Operationen zumindest so effizient handhaben “ - es immer noch ist effizienter in dem Fall , in dem der aktualisierte Wert wird später in der gleichen Funktion verwendet werden und es gibt ein kostenloses Register, in dem der Compiler es speichern kann (und die Variable ist natürlich nicht als flüchtig markiert). Dies bedeutet, dass es sehr wahrscheinlich ist, ob der Compiler einen einzelnen Befehl oder mehrere für die Operation generiert, vom Rest des Codes in der Funktion abhängt, nicht nur von der betreffenden einzelnen Zeile.
Periata Breatta
@PeriataBreatta: Ja, guter Punkt. In asm können Sie mov eax, 1 xadd [num], eax(ohne Sperrpräfix) Post-Inkrement implementieren num++, aber das tun Compiler nicht.
Peter Cordes
3
@ DavidC.Rankin: Wenn Sie Änderungen vornehmen möchten, fühlen Sie sich frei. Ich möchte dieses CW jedoch nicht machen. Es ist immer noch meine Arbeit (und mein Durcheinander: P). Ich werde einige nach meinem Ultimate [Frisbee] Spiel aufräumen :)
Peter Cordes
1
Wenn nicht Community-Wiki, dann vielleicht ein Link auf das entsprechende Tag-Wiki. (sowohl die x86- als auch die atomaren Tags?). Es ist eine zusätzliche Verknüpfung wert, anstatt eine hoffnungsvolle Rückkehr durch eine generische Suche auf SO (Wenn ich besser wüsste, wo es in dieser Hinsicht passen sollte, würde ich es tun. Ich muss mich weiter mit den Do's & Don't's von Tags befassen Wiki-Verknüpfung)
David C. Rankin
1
Wie immer - tolle Antwort! Gute Unterscheidung zwischen Kohärenz und Atomizität (wo einige andere es falsch verstanden haben)
Leeor
39

... und jetzt aktivieren wir Optimierungen:

f():
        rep ret

OK, geben wir ihm eine Chance:

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

Ergebnis:

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

Ein anderer beobachtender Thread (selbst wenn Cache-Synchronisationsverzögerungen ignoriert werden) 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. Respekt vor ähnlichen Änderungen, die in anderen Threads auftreten.

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

Weitere Informationen

In Bezug auf die Wirkung von Optimierungen von Updates von std::atomics.

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

Die Als-ob-Regel ist konservativ, insbesondere was die Atomik betrifft.

Erwägen:

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

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

void incdec(int&) {
    // nada
}

Dies liegt daran, dass im c ++ - Speichermodell keine Möglichkeit besteht, dass ein anderer Thread das Ergebnis des Inkrements beobachtet. Es wäre natürlich anders, wenn numes so wärevolatile (Macht Einfluss Hardware-Verhalten). 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;
}

numist ein Atom. Änderungen daran müssen für andere Threads, die sie beobachten, sichtbar sein. Änderungen, die diese Threads selbst vornehmen (z. B. das Setzen des Werts auf 100 zwischen Inkrementieren und Dekrementieren), haben sehr weitreichende Auswirkungen auf den möglichen 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
Richard Hodges
quelle
5
Dies nicht zu erklären , dass add dword [rdi], 1ist nicht atomar (ohne lockPräfix). Die Last ist atomar und der Speicher ist atomar, aber nichts hindert einen anderen Thread daran, die Daten zwischen der Last und dem Speicher zu ändern. Der Store kann also auf eine Änderung zugreifen, die von einem anderen Thread vorgenommen wurde. Siehe jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . Außerdem sind Jeff Preshings sperrfreie Artikel extrem gut und er erwähnt das grundlegende RMW-Problem in diesem Intro-Artikel.
Peter Cordes
3
Was hier wirklich vor sich geht, ist, dass niemand diese Optimierung in gcc implementiert hat, weil sie fast nutzlos und wahrscheinlich gefährlicher als hilfreich wäre. (Prinzip der geringsten Überraschung. Vielleicht hat jemand wird erwartet ein vorübergehender Zustand sichtbar manchmal zu sein, und sind in Ordnung mit dem statistischen probabilty. Oder sie werden mit Hardware-Uhr-Punkte auf Änderung zu unterbrechen.) Lock-freien Code Bedürfnisse sorgfältig ausgearbeitet werden, Es gibt also nichts zu optimieren. Es kann nützlich sein, danach zu suchen und eine Warnung auszudrucken, um den Codierer darauf aufmerksam zu machen, dass sein Code möglicherweise nicht bedeutet, was er denkt!
Peter Cordes
2
Dies ist möglicherweise ein Grund für Compiler, dies nicht zu implementieren (Prinzip der geringsten Überraschung usw.). Dies zu beobachten wäre in der Praxis auf realer Hardware möglich. Die C ++ - Speicherordnungsregeln sagen jedoch nichts über eine Garantie aus, dass sich die Ladevorgänge eines Threads "gleichmäßig" mit den Operationen anderer Threads in der abstrakten C ++ - Maschine mischen. Ich denke immer noch, dass es legal wäre, aber programmiererfeindlich.
Peter Cordes
2
Gedankenexperiment: Betrachten Sie eine C ++ - Implementierung auf einem kooperativen Multitasking-System. Es implementiert std :: thread, indem bei Bedarf Fließpunkte eingefügt werden, um Deadlocks zu vermeiden, jedoch nicht zwischen den einzelnen Anweisungen. Ich denke, Sie würden argumentieren, dass etwas im C ++ - Standard eine Streckgrenze zwischen num++und erfordert num--. Wenn Sie im Standard einen Abschnitt finden, der dies erfordert, wird dies geregelt. Ich bin mir ziemlich sicher, dass es nur erforderlich ist, dass kein Beobachter jemals eine falsche Neuordnung sehen kann, was dort keine Ausbeute erfordert. Ich denke, es ist nur ein Problem mit der Qualität der Implementierung.
Peter Cordes
5
Aus Gründen der Endgültigkeit habe ich auf der Standarddiskussions-Mailingliste nachgefragt. Diese Frage ergab zwei Artikel, die sowohl mit Peter übereinstimmen als auch Bedenken hinsichtlich solcher Optimierungen ansprechen : wg21.link/p0062 und wg21.link/n4455 Mein Dank geht an Andy, der mich darauf aufmerksam gemacht hat.
Richard Hodges
38

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

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

AGENT 1          AGENT 2

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

X wird nur einmal erhöht.

Margaret Bloom
quelle
7
@LeoHeinsaar Damit dies der Fall ist, benötigt jeder Speicherchip eine eigene Arithmetic Logic Unit (ALU). Es wäre in der Tat erforderlich , dass jeder Speicherchip war ein Prozessor.
Richard Hodges
6
@LeoHeinsaar: Speicherzielanweisungen sind Lese-, Änderungs- und Schreibvorgänge. Es wird kein Architekturregister geändert, aber die CPU muss die Daten intern speichern, während sie sie über ihre ALU sendet. Die eigentliche Registerdatei ist selbst in der einfachsten CPU nur ein kleiner Teil des Datenspeichers. In Latches werden die Ausgänge einer Stufe als Eingänge für eine andere Stufe usw. usw. gespeichert.
Peter Cordes
@PeterCordes Dein Kommentar ist genau die Antwort, nach der ich gesucht habe. Margarets Antwort ließ mich vermuten, dass so etwas drinnen weitergehen muss.
Leo Heinsaar
Verwandelte diesen Kommentar in eine vollständige Antwort, einschließlich der Behandlung des C ++ - Teils der Frage.
Peter Cordes
1
@ PeterCordes Danke, sehr detailliert und in allen Punkten. Es war offensichtlich ein Datenrennen und daher ein undefiniertes Verhalten nach dem C ++ - Standard. Ich war nur neugierig, ob man in Fällen, in denen der generierte Code das war, was ich gepostet habe, davon ausgehen konnte, dass dies atomar sein könnte usw. usw. Ich habe auch nur überprüft, dass zumindest Intel-Entwickler Handbücher definieren die Atomizität in Bezug auf Speicheroperationen sehr klar und nicht die Unteilbarkeit von Anweisungen, wie ich angenommen habe: "Gesperrte Operationen sind in Bezug auf alle anderen Speicheroperationen und alle von außen sichtbaren Ereignisse atomar."
Leo Heinsaar
11

Die Add-Anweisung ist nicht atomar. Es verweist auf den Speicher, und zwei Prozessorkerne können einen unterschiedlichen lokalen Cache dieses Speichers haben.

IIRC, die atomare Variante des Befehls add, heißt lock xadd

Sven Nilsson
quelle
3
lock xaddimplementiert C ++ std :: atomic fetch_addund gibt den alten Wert zurück. Wenn Sie das nicht benötigen, verwendet der Compiler die normalen Speicherzielanweisungen mit einem lockPräfix. lock addoder lock inc.
Peter Cordes
1
add [mem], 1wäre auf einem SMP-Rechner ohne Cache immer noch nicht atomar, siehe meine Kommentare zu anderen Antworten.
Peter Cordes
In meiner Antwort finden Sie viel mehr Details darüber, wie es nicht atomar ist. Auch das Ende meiner Antwort auf diese verwandte Frage .
Peter Cordes
10

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 zu ziehen, die auf einer durch "Reverse Engineering" erzeugten Baugruppe beruhen. Zum Beispiel scheinen Sie Ihren Code mit deaktivierter Optimierung kompiliert zu haben, andernfalls hätte der Compiler diese Variable weggeworfen oder 1 direkt in sie geladen, ohne sie aufzurufenoperator++ . Da sich die generierte Baugruppe aufgrund von Optimierungsflags, Ziel-CPU usw. erheblich ändern kann, basiert Ihre Schlussfolgerung auf Sand.

Auch Ihre Vorstellung, dass eine Montageanweisung bedeutet, dass eine Operation atomar ist, ist ebenfalls falsch. Dies addist auf Systemen mit mehreren CPUs selbst auf der x86-Architektur nicht atomar.

Slava
quelle
9

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

Aber es ist schlimmer als das. Erstens kann, wie bereits erwähnt, die Anweisung, die der Compiler beim Inkrementieren einer Variablen generiert, von der Optimierungsstufe abhängen. Zweitens kann der Compiler andere Speicherzugriffe neu anordnen, ++numwenn dies numnicht 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();
}

Selbst wenn wir optimistisch davon ausgehen, dass dies ++ready"atomar" ist und der Compiler die Prüfschleife nach Bedarf generiert (wie gesagt, es ist UB und daher kann der Compiler sie entfernen, durch eine Endlosschleife ersetzen usw.) Der Compiler verschiebt möglicherweise immer noch die Zeigerzuweisung oder noch schlimmer die Initialisierung von vectorauf einen Punkt nach der Inkrementierungsoperation, was zu Chaos im neuen Thread führt. In der Praxis wäre ich überhaupt nicht überrascht, wenn ein optimierender Compiler die readyVariable und die Prüfschleife vollständig entfernen würde , da dies das beobachtbare Verhalten unter Sprachregeln nicht beeinträchtigt (im Gegensatz zu Ihren privaten Hoffnungen).

Tatsächlich habe ich auf der letztjährigen Meeting C ++ - Konferenz von zwei gehört Compiler-Entwicklern gehört, dass sie sehr gerne Optimierungen implementieren, die dazu führen, dass sich naiv geschriebene Multithread-Programme schlecht verhalten, sofern die Sprachregeln dies zulassen, wenn auch nur eine geringfügige Leistungsverbesserung festgestellt wird in korrekt geschriebenen Programmen.

Selbst wenn Sie sich nicht um Portabilität gekümmert haben und Ihr Compiler magisch nett war, handelt es sich bei der von Ihnen verwendeten CPU höchstwahrscheinlich um einen superskalaren CISC-Typ, der Anweisungen in Mikrooperationen aufteilt, neu anordnet und / oder spekulativ ausführt. in einem Ausmaß, das nur durch die Synchronisierung von Grundelementen wie (bei Intel) dem LOCKPräfix oder den Speicherzäunen begrenzt ist, um den Betrieb 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 gemäß den Sprachregeln (und insbesondere 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 Weise tun möchten, funktioniert es möglicherweise in einigen Fällen, aber verstehen Sie, dass die Garantie ungültig ist und Sie allein für unerwünschte Ergebnisse verantwortlich sind. :-)

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();
}

Dies ist sicher, weil:

  1. Die Schecks von ready können nicht gemäß den Sprachregeln optimiert werden.
  2. Das ++ready geschieht-vor der Prüfung , die sieht , readywie nicht Null ist , und andere Operationen nicht diese Vorgänge neu geordnet um werden können. Dies liegt daran, dass ++readyund die Prüfung sequentiell konsistent sind. Dies ist ein weiterer Begriff, der im C ++ - Speichermodell beschrieben wird und der diese spezifische Neuordnung verbietet. Daher darf der Compiler die Anweisungen nicht neu anordnen und muss der CPU auch mitteilen, dass er zB den Schreibvorgang nicht vecnach dem Inkrement von aufschieben darf ready. Sequentiell konsistent ist die stärkste Garantie in Bezug auf Atomics im Sprachstandard. Geringere (und theoretisch günstigere) Garantien sind zB über andere Methoden verfügbarstd::atomic<T>, aber diese sind definitiv nur für Experten und werden von den Compiler-Entwicklern möglicherweise nicht viel optimiert, da sie selten verwendet werden.
Arne Vogel
quelle
1
Wenn der Compiler nicht alle Verwendungen von sehen könnte ready, würde er wahrscheinlich while (!ready);zu etwas ähnlichem kompilieren if(!ready) { while(true); }. Upvoted: Ein wesentlicher Bestandteil von std :: atomic ist die Änderung der Semantik, um zu jedem Zeitpunkt eine asynchrone Änderung anzunehmen. Wenn es normalerweise UB ist, können Compiler Lasten heben und Speicher aus Schleifen versenken.
Peter Cordes
9

Auf einem Single-Core-x86-Computer ist ein addBefehl in Bezug auf anderen Code auf der CPU 1 im Allgemeinen atomar . Ein Interrupt kann keinen einzelnen Befehl in der Mitte aufteilen.

Eine Ausführung außerhalb der Reihenfolge ist erforderlich, um die Illusion von Anweisungen zu bewahren, die einzeln in der Reihenfolge innerhalb eines einzelnen Kerns ausgeführt werden. Daher wird jede Anweisung, die auf derselben CPU ausgeführt wird, entweder vollständig vor oder vollständig nach dem Hinzufügen ausgeführt.

Moderne x86-Systeme sind Multi-Core-Systeme, daher gilt der Uniprozessor-Sonderfall nicht.

Wenn man auf einen kleinen eingebetteten PC abzielt und keine Pläne hat, den Code auf etwas anderes zu verschieben, könnte die atomare Natur des Befehls "Hinzufügen" ausgenutzt werden. Auf der anderen Seite werden Plattformen, auf denen Operationen von Natur aus atomar sind, immer knapper.

(Dies hilft Ihnen nicht , wenn Sie schreiben in C ++, though. Compiler keine Möglichkeit hat , zu verlangen , num++zu einem Speicher-Ziel hinzufügen zu kompilieren oder xADD ohne einen lockPräfix. Sie konnten wählen , laden numin ein Register und Speicher das Inkrement-Ergebnis mit einer separaten Anweisung und wird dies wahrscheinlich tun, wenn Sie das Ergebnis verwenden.)


Fußnote 1: Das lockPräfix war sogar auf dem ursprünglichen 8086 vorhanden, da E / A-Geräte gleichzeitig mit der CPU arbeiten. Treiber auf einem Single-Core-System müssen lock addeinen Wert im Gerätespeicher atomar erhöhen , wenn das Gerät ihn auch ändern kann, oder in Bezug auf den DMA-Zugriff.

Superkatze
quelle
Es ist nicht einmal allgemein atomar: Ein anderer Thread kann dieselbe Variable gleichzeitig aktualisieren und nur ein Update wird übernommen.
Fuz
1
Betrachten Sie ein Multi-Core-System. Natürlich ist der Befehl innerhalb eines Kerns atomar, aber nicht atomar in Bezug auf das gesamte System.
Fuz
1
@FUZxxl: Was waren das vierte und fünfte Wort meiner Antwort?
Supercat
1
@supercat Ihre Antwort ist sehr irreführend, da sie nur den heutzutage seltenen Fall eines einzelnen Kerns berücksichtigt und OP ein falsches Sicherheitsgefühl verleiht. Aus diesem Grund habe ich kommentiert, um auch den Multi-Core-Fall zu berücksichtigen.
Fuz
1
@FUZxxl: Ich habe eine Bearbeitung vorgenommen, um potenzielle Verwirrung für Leser zu beseitigen, die nicht bemerkt haben, dass es sich nicht um normale moderne Multicore-CPUs handelt. (Und seien Sie auch genauer über einige Dinge, bei denen Supercat sich nicht sicher war). Übrigens ist alles in dieser Antwort bereits in meinem, außer dem letzten Satz darüber, wie selten Plattformen sind, auf denen Lesen, Ändern, Schreiben atomar "kostenlos" ist.
Peter Cordes
7

Früher, als x86-Computer eine CPU hatten, stellte die Verwendung eines einzelnen Befehls sicher, dass Interrupts das Lesen / Ändern / Schreiben nicht aufteilten, und wenn der Speicher nicht auch als DMA-Puffer verwendet wurde, war er tatsächlich atomar (und C ++ erwähnte keine Threads im Standard, daher wurde dies nicht angesprochen.

Wenn es selten war, einen Dual-Prozessor (z. B. Pentium Pro mit zwei Sockeln) auf einem Kunden-Desktop zu haben, habe ich dies effektiv verwendet, um das LOCK-Präfix auf einem Single-Core-Computer zu vermeiden und die Leistung zu verbessern.

Heutzutage würde es nur gegen mehrere Threads helfen, die alle auf dieselbe CPU-Affinität eingestellt waren, sodass die Threads, um die Sie sich Sorgen machen, nur über die Zeitscheibe ins Spiel kommen, die abläuft und den anderen Thread auf derselben CPU (Kern) ausführt. Das ist nicht realistisch.

Bei modernen x86 / x64-Prozessoren wird der einzelne Befehl in mehrere Mikrooperationen aufgeteilt und außerdem das Lesen und Schreiben des Speichers gepuffert. Unterschiedliche Threads, die auf unterschiedlichen CPUs ausgeführt werden, sehen dies nicht nur als nicht atomar an, sondern können auch inkonsistente Ergebnisse in Bezug darauf anzeigen, was aus dem Speicher gelesen wird und was davon ausgegangen wird, dass andere Threads zu diesem Zeitpunkt gelesen haben: Sie müssen Speicherzäune hinzufügen , um gesund wiederherzustellen Verhalten.

JDługosz
quelle
1
Interrupts immer noch nicht geteilten RMW - Operationen, so dass sie sich einen einzigen Thread mit Handler - Signal , dass Lauf in dem gleichen Thread synchronisiert still. Dies funktioniert natürlich nur, wenn der ASM eine einzelne Anweisung verwendet, nicht das separate Laden / Ändern / Speichern. C ++ 11 könnte diese Hardwarefunktionalität verfügbar machen, tut dies jedoch nicht (wahrscheinlich, weil es nur in Uniprozessor-Kerneln wirklich nützlich war, mit Interrupt-Handlern zu synchronisieren, nicht im Benutzerraum mit Signalhandlern). Außerdem verfügen Architekturen nicht über Anweisungen zum Lesen, Ändern, Schreiben und Speichern von Speicherzielen. Trotzdem konnte es sich wie ein entspanntes atomares RMW auf Nicht-x86 kompilieren
Peter Cordes
Wie ich mich erinnere, war die Verwendung des Lock-Präfixes nicht absurd teuer, bis die Superskalierer kamen. Es gab also keinen Grund zu bemerken, dass der wichtige Code in einem 486 verlangsamt wurde, obwohl er von diesem Programm nicht benötigt wurde.
JDługosz
Ja Entschuldigung! Ich habe nicht wirklich sorgfältig gelesen. Ich sah den Anfang des Absatzes mit dem roten Hering über das Entschlüsseln in Uops und beendete das Lesen nicht, um zu sehen, was Sie tatsächlich gesagt haben. re: 486: Ich glaube, ich habe gelesen, dass das früheste SMP eine Art Compaq 386 war, aber die Semantik der Speicherreihenfolge war nicht die gleiche wie die, die der x86-ISA derzeit sagt. In den aktuellen x86-Handbüchern wird möglicherweise sogar SMP 486 erwähnt. Sie waren jedoch selbst in HPC (Beowulf-Clustern) bis zu PPro / Athlon XP-Tagen sicherlich nicht üblich, denke ich.
Peter Cordes
1
@ PeterCordes Ok. Sicher, vorausgesetzt auch keine DMA / Gerätebeobachter - passten nicht in den Kommentarbereich, um auch diesen einzuschließen. Vielen Dank an JDługosz für die hervorragende Ergänzung (Antwort sowie Kommentare). Die Diskussion wirklich abgeschlossen.
Leo Heinsaar
3
@Leo: Ein wichtiger Punkt, der nicht erwähnt wurde: Nicht in Ordnung befindliche CPUs ordnen die Dinge intern neu an, aber die goldene Regel lautet, dass sie für einen einzelnen Kern die Illusion von Anweisungen bewahren, die nacheinander ausgeführt werden. (Und dies schließt Interrupts ein, die Kontextwechsel auslösen). Werte können nicht in der richtigen Reihenfolge elektrisch im Speicher gespeichert werden, aber der einzelne Kern, auf dem alles läuft, verfolgt alle Neuordnungen, die er selbst vornimmt, um die Illusion zu bewahren. Aus diesem Grund benötigen Sie keine Speicherbarriere, um die a = 1; b = a;gerade gespeicherte 1 korrekt zu laden.
Peter Cordes
4

Nr https://www.youtube.com/watch?v=31g0YE61PLQ (Das ist nur ein Link zur „Nein“ Szene aus „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, kann der Compiler dies zur einzig möglichen Ausgabe für das Programm machen, je nachdem, wie der Compiler dies wünscht. dh ein main (), das nur 100s löscht.

Dies ist die "Als ob" -Regel.

Unabhängig von der Ausgabe können Sie sich die Thread-Synchronisation auf die gleiche Weise vorstellen. Wenn Thread A wiederholt num++; num--;und Thread B numwiederholt liest , besteht eine mögliche gültige Verschachtelung darin, dass Thread B niemals zwischen num++und liest num--. Da diese Verschachtelung gültig ist, kann der Compiler diese als einzig mögliche Verschachtelung festlegen. Und entfernen Sie einfach das Inkr / 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 Folgendes machen:

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

progress += local;

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

Das Komitee arbeitet immer noch an diesem Zeug. Derzeit "funktioniert" es, weil Compiler die Atomics nicht sehr optimieren. Das ändert sich aber.

Und selbst wenn progresses auch volatil wäre, wäre dies immer noch gültig:

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

while (local--)
    progress++;

: - /

Tony
quelle
Diese Antwort scheint nur die Nebenfrage zu beantworten, über die Richard und ich nachgedacht haben. Wir haben es schließlich gelöst: Es stellt sich heraus, dass der C ++ - Standard das Zusammenführen von Operationen an nichtatomaren volatileObjekten ermöglicht, wenn keine anderen Regeln verletzt werden. Zwei Standarddiskussionsdokumente diskutieren genau dies (Links in Richards Kommentar ), eines verwendet dasselbe Beispiel für einen Fortschrittszähler. Es handelt sich also um ein Problem mit der Implementierungsqualität, bis C ++ die Möglichkeiten zur Verhinderung standardisiert.
Peter Cordes
Ja, mein "Nein" ist wirklich eine Antwort auf die ganze Argumentation. Wenn die Frage nur "Kann num ++ auf einem Compiler / einer Implementierung atomar sein" lautet, ist die Antwort sicher. Ein Compiler könnte beispielsweise entscheiden, lockzu jeder Operation etwas hinzuzufügen . Oder eine Compiler + Uniprozessor-Kombination, bei der weder eine Neuordnung (dh "die guten alten Tage") alles atomar ist. Aber worum geht es dabei? Darauf kann man sich nicht wirklich verlassen. Es sei denn, Sie wissen, dass dies das System ist, für das Sie schreiben. (Selbst dann wäre es besser, wenn Atomic <int> keine zusätzlichen Operationen auf diesem System hinzufügt. Sie sollten also immer noch Standardcode schreiben ...)
Tony
1
Beachten Sie, dass And just remove the incr/decr entirely.das nicht ganz richtig ist. Es ist immer noch eine Erfassungs- und Freigabeoperation num. Auf x86 num++;num--konnte nur MFENCE kompiliert werden, aber definitiv nichts. (Es sei denn, die Gesamtprogrammanalyse des Compilers kann beweisen, dass nichts mit dieser Änderung von num synchronisiert ist und dass es keine Rolle spielt, ob einige Speicher von zuvor bis nach dem Laden von danach verzögert werden.) ZB wenn dies ein Entsperren und erneutes Laden war -lock-sofort-Anwendungsfall, Sie haben immer noch zwei separate kritische Abschnitte (möglicherweise mit mo_relaxed), nicht einen großen.
Peter Cordes
@ PeterCordes ah ja, stimmte zu.
Tony
2

Ja aber...

Atomic ist nicht das, was du sagen wolltest. 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. Ohne spezielle Streaming-Anweisungen ohne Caching wird jeder Schreibvorgang durch den Cache geleitet. Komplette Cache-Zeilen werden atomar gelesen und geschrieben, niemals etwas anderes.
Daten, die kleiner als die Cacheline sind, werden natürlich auch atomar geschrieben (da sich die umgebende Cache-Zeile befindet).

Ist es threadsicher?

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

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, aber L1 ist normalerweise pro Kern!) Und ändert diesen Wert gleichzeitig. 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. Das Ergebnis entspricht jedoch möglicherweise nicht Ihren Erwartungen.

Zweitens gibt es eine Speicherreihenfolge oder eine andere Formulierung, bevor dies garantiert wird. Das Wichtigste an atomaren Anweisungen ist nicht so sehr, dass sie atomar sind . Es bestellt.

Sie haben die Möglichkeit, eine Garantie durchzusetzen, dass alles, was in Bezug auf das Gedächtnis geschieht, in einer garantierten, genau definierten Reihenfolge realisiert wird, in der Sie eine "Vorher-passiert" -Garantie haben. Diese Bestellung kann so "entspannt" (gelesen als: überhaupt keine) oder so streng sein, wie Sie es benötigen.

Sie können beispielsweise einen Zeiger auf einen Datenblock setzen (z. B. die Ergebnisse einer Berechnung) und dann das Flag "Daten sind bereit" atomar freigeben . Wer nun dieses Flag erwirbt, wird zu dem Gedanken gebracht, 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.

Damon
quelle
2
Das Laden und das Speichern sind jeweils separat atomar, aber die gesamte Lese-, Änderungs- und Schreiboperation als Ganzes ist definitiv nicht atomar. Caches sind kohärent und können daher niemals widersprüchliche Kopien derselben Zeile enthalten ( en.wikipedia.org/wiki/MESI_protocol ). Ein anderer Kern kann nicht einmal eine schreibgeschützte Kopie haben, während sich dieser Kern im Status "Geändert" befindet. Was es nicht atomar macht, ist, dass der Kern, der das RMW ausführt, den Besitz der Cache-Zeile zwischen dem Laden und dem Speicher verlieren kann.
Peter Cordes
2
Nein, ganze Cache-Zeilen werden nicht immer atomar übertragen. In dieser Antwort wird experimentell gezeigt, dass ein Multi-Socket-Opteron 16B-SSE-Speicher nicht-atomar macht, indem Cache-Zeilen in 8B-Blöcken mit Hypertransport übertragen werden, obwohl sie für Single-Socket-CPUs desselben Typs atomar sind (weil die Last / Speicherhardware hat einen 16B-Pfad zum L1-Cache. x86 garantiert nur Atomizität für separate Lasten oder speichert bis zu 8B.
Peter Cordes
Wenn Sie die Ausrichtung dem Compiler überlassen, bedeutet dies nicht, dass der Speicher an der 4-Byte-Grenze ausgerichtet wird. Compiler können Optionen oder Pragmas haben, um die Ausrichtungsgrenze zu ändern. Dies ist beispielsweise nützlich, um dicht gepackte Daten in Netzwerkströmen zu verarbeiten.
Dmitry Rubanovich
2
Sophistries, sonst nichts. Eine Ganzzahl mit automatischem Speicher, die nicht Teil einer Struktur ist, wie im Beispiel gezeigt, wird absolut positiv korrekt ausgerichtet. Etwas anderes zu behaupten ist einfach albern. Cache-Zeilen sowie alle PODs haben eine PoT-Größe (Power-of-Two) und sind ausgerichtet - auf jeder nicht illusorischen Architektur der Welt. Nach Mathematik passt jedes richtig ausgerichtete PoT genau in ein (nie mehr) eines anderen PoT derselben Größe oder größer. Meine Aussage ist daher richtig.
Damon
1
@Damon, das in der Frage angegebene Beispiel erwähnt keine Struktur, beschränkt die Frage jedoch nicht nur auf Situationen, in denen Ganzzahlen keine Teile von Strukturen sind. PODs können definitiv eine PoT-Größe haben und nicht PoT-ausgerichtet sein. In dieser Antwort finden Sie Beispiele für Syntax: stackoverflow.com/a/11772340/1219722 . Es ist also kaum eine "Sophistik", da PODs, die auf diese Weise deklariert wurden, im Netzwerkcode ziemlich häufig im realen Code verwendet werden.
Dmitry Rubanovich
2

Dass der Ausgang des einzelnen Compiler, auf einer bestimmten CPU - Architektur, mit Optimierungen deaktiviert (da gcc nicht einmal kompilieren , ++um addbei der Optimierung in einem quick & dirty Beispiel ), auf diese Weise zu implizieren scheint Inkrementieren atomar ist, bedeutet nicht , dieses Standard-kompatibel ist ( Sie würden undefiniertes Verhalten verursachen, wenn Sie versuchen, auf numeinen Thread zuzugreifen. Dies ist sowieso falsch, da addes in x86 nicht atomar ist.

Beachten Sie, dass Atomics (unter Verwendung des lockAnweisungspräfix) relativ schwer für x86 sind ( siehe diese relevante Antwort ), aber immer noch bemerkenswert weniger als ein Mutex, was in diesem Anwendungsfall nicht sehr geeignet ist.

Die folgenden Ergebnisse stammen aus clang ++ 3.8 beim Kompilieren mit -Os.

Inkrementieren eines int durch Referenz, die "normale" Art:

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

Dies setzt sich zusammen 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 als der normale Weg ist, wird nur das lockPräfix zur inclAnweisung hinzugefügt - aber Vorsicht, wie bereits erwähnt, ist dies nicht billig. Nur weil die Montage kurz aussieht, heißt das nicht, dass sie schnell ist.

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

Wenn Ihr Compiler nur eine einzige Anweisung für das Inkrement verwendet und Ihr Computer Single-Threaded ist, ist Ihr Code sicher. ^^

Bonita Montero
quelle
-3

Wenn Sie versuchen, denselben Code auf einem Nicht-x86-Computer zu kompilieren, werden schnell sehr unterschiedliche Assembly-Ergebnisse angezeigt.

Der Grund num++ scheint atomar zu sein, weil auf x86-Computern das Inkrementieren einer 32-Bit-Ganzzahl tatsächlich atomar ist (vorausgesetzt, es findet kein Speicherabruf statt). Dies wird jedoch weder durch den c ++ - Standard garantiert, noch ist dies auf einem Computer der Fall, der den x86-Befehlssatz nicht verwendet. Daher ist dieser Code nicht plattformübergreifend vor Rennbedingungen geschützt.

Sie haben auch keine starke Garantie dafür, dass dieser Code selbst auf einer x86-Architektur vor Race Conditions geschützt ist, da x86 keine Ladevorgänge und Speicher im Speicher einrichtet, es sei denn, dies wird ausdrücklich angewiesen. Wenn also mehrere Threads versucht haben, diese Variable gleichzeitig zu aktualisieren, werden möglicherweise zwischengespeicherte (veraltete) Werte erhöht

Der Grund, den wir haben std::atomic<int>und so weiter, ist, dass Sie, wenn Sie mit einer Architektur arbeiten, bei der die Atomizität grundlegender Berechnungen nicht garantiert ist, über einen Mechanismus verfügen, der den Compiler zwingt, atomaren Code zu generieren.

Xirema
quelle
"liegt daran, dass auf x86-Computern das Inkrementieren einer 32-Bit-Ganzzahl tatsächlich atomar ist." Können Sie einen Link zu einer Dokumentation bereitstellen, die dies belegt?
Slava
8
Es ist auch auf x86 nicht atomar. Es ist Single-Core-sicher, aber wenn es mehrere Kerne gibt (und es gibt), ist es überhaupt nicht atomar.
Harold
Ist x86 addtatsächlich atomar garantiert? Es würde mich nicht wundern, wenn Registerinkremente atomar wären, aber das ist kaum nützlich. Um das Registerinkrement für einen anderen Thread sichtbar zu machen, muss es sich im Speicher befinden. Dies würde zusätzliche Anweisungen zum Laden und Speichern erfordern, wodurch die Atomizität beseitigt wird. Ich verstehe, dass aus diesem Grund das lockPräfix für Anweisungen vorhanden ist. Das einzig nützliche Atom addgilt für den dereferenzierten Speicher und verwendet das lockPräfix, um sicherzustellen, dass die Cache-Zeile für die Dauer des Vorgangs gesperrt ist .
ShadowRanger
@Slava @Harold @ShadowRanger Ich habe die Antwort aktualisiert. addist atomar, aber ich habe klargestellt, dass dies nicht bedeutet, dass der Code rennsicher ist, da Änderungen nicht sofort global sichtbar werden.
Xirema
3
@ Xirema, das es per Definition "nicht atomar" macht
Harold