Was genau ist std :: atomic?

172

Ich verstehe, dass dies std::atomic<>ein atomares Objekt ist. Aber inwieweit atomar? Nach meinem Verständnis kann eine Operation atomar sein. Was genau bedeutet es, ein Objekt atomar zu machen? Zum Beispiel, wenn zwei Threads gleichzeitig den folgenden Code ausführen:

a = a + 12;

Ist dann die gesamte Operation (sagen wir add_twelve_to(int)) atomar? Oder werden Änderungen an der Variablen atomar (so operator=()) vorgenommen?

Neugieriger
quelle
8
Sie müssen so etwas wie verwenden, a.fetch_add(12)wenn Sie ein atomares RMW wollen.
Kerrek SB
Ja, das verstehe ich nicht. Was bedeutet es, ein Objekt atomar zu machen? Wenn es eine Schnittstelle gäbe, hätte sie einfach mit einem Mutex oder einem Monitor atomar gemacht werden können.
1
@AaryamanSagar löst ein Problem der Effizienz. Mutexe und Monitore sind mit Rechenaufwand verbunden. Mit kann std::atomicdie Standardbibliothek entscheiden, was zur Erzielung der Atomizität erforderlich ist.
Drew Dormann
1
@AaryamanSagar: std::atomic<T>ist ein Typ, der atomare Operationen ermöglicht. Es macht dein Leben nicht auf magische Weise besser, du musst immer noch wissen, was du damit machen willst. Es ist für einen sehr spezifischen Anwendungsfall gedacht, und die Verwendung von atomaren Operationen (am Objekt) ist im Allgemeinen sehr subtil und muss aus einer nicht lokalen Perspektive betrachtet werden. Wenn Sie das nicht bereits wissen und wissen, warum Sie atomare Operationen wünschen, ist der Typ für Sie wahrscheinlich nicht von großem Nutzen.
Kerrek SB

Antworten:

186

Jede Instanziierung und vollständige Spezialisierung von std :: atomic <> stellt einen Typ dar, mit dem verschiedene Threads gleichzeitig arbeiten können (ihre Instanzen), ohne undefiniertes Verhalten auszulösen:

Objekte vom Atomtyp sind die einzigen C ++ - Objekte, die frei von Datenrassen sind. Das heißt, wenn ein Thread in ein atomares Objekt schreibt, während ein anderer Thread daraus liest, ist das Verhalten genau definiert.

Darüber hinaus können Zugriffe auf atomare Objekte eine Synchronisation zwischen Threads herstellen und nichtatomare Speicherzugriffe anordnen, wie durch angegeben std::memory_order.

std::atomic<>Wraps-Operationen, die in Pre-C ++ 11-mal mit (zum Beispiel) ineinandergreifenden Funktionen mit MSVC oder atomaren Bultinen im Fall von GCC ausgeführt werden mussten.

Außerdem std::atomic<>gibt Ihnen mehr Kontrolle durch verschiedene ermöglicht Speicheraufträge , die Synchronisation und Bestellbeschränkungen festlegen. Wenn Sie mehr über C ++ 11 Atomics und das Speichermodell erfahren möchten, können diese Links hilfreich sein:

Beachten Sie, dass Sie für typische Anwendungsfälle wahrscheinlich überladene arithmetische Operatoren oder einen anderen Satz davon verwenden würden :

std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this

Da Sie mit der Operatorsyntax die Speicherreihenfolge nicht angeben können, werden diese Operationen mit ausgeführt std::memory_order_seq_cst, da dies die Standardreihenfolge für alle atomaren Operationen in C ++ 11 ist. Sie garantiert die sequentielle Konsistenz (globale Gesamtreihenfolge) zwischen allen atomaren Operationen.

In einigen Fällen ist dies jedoch möglicherweise nicht erforderlich (und nichts ist kostenlos). Daher möchten Sie möglicherweise eine explizitere Form verwenden:

std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation

Nun Ihr Beispiel:

a = a + 12;

wird nicht zu einer einzelnen atomaren Operation ausgewertet: Es wird a.load()(was selbst atomar ist), dann Addition zwischen diesem Wert und 12und a.store()(auch atomar) des Endergebnisses. Wie ich bereits erwähnt habe, std::memory_order_seq_cstwird hier verwendet.

Wenn Sie jedoch schreiben a += 12, handelt es sich um eine atomare Operation (wie bereits erwähnt), die in etwa der entspricht a.fetch_add(12, std::memory_order_seq_cst).

Wie für Ihren Kommentar:

Ein Stammgast inthat atomare Lasten und Speicher. Was bringt es, es einzuwickeln atomic<>?

Ihre Aussage gilt nur für Architekturen, die eine solche Atomaritätsgarantie für Geschäfte und / oder Lasten bieten. Es gibt Architekturen, die dies nicht tun. Außerdem ist es normalerweise erforderlich, dass Operationen an wort- / dword-ausgerichteten Adressen ausgeführt werden müssen, um atomar zu sein. Dies std::atomic<>ist etwas, das auf jeder Plattform ohne zusätzliche Anforderungen garantiert atomar ist . Darüber hinaus können Sie Code wie folgt schreiben:

void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;

// Thread 1
void produce()
{
    sharedData = generateData();
    ready_flag.store(1, std::memory_order_release);
}

// Thread 2
void consume()
{
    while (ready_flag.load(std::memory_order_acquire) == 0)
    {
        std::this_thread::yield();
    }

    assert(sharedData != nullptr); // will never trigger
    processData(sharedData);
}

Beachten Sie, dass die Assertionsbedingung immer wahr ist (und daher niemals ausgelöst wird), sodass Sie immer sicher sein können, dass die Daten nach dem whileBeenden der Schleife bereit sind . Das ist, weil:

  • store()to the flag wird ausgeführt, nachdem sharedDatagesetzt wurde (wir gehen davon aus, dass generateData()immer etwas Nützliches zurückgegeben wird, insbesondere nie zurückgegeben wird NULL) und verwendet die std::memory_order_releaseReihenfolge:

memory_order_release

Eine Speicheroperation mit dieser Speicherreihenfolge führt die Freigabeoperation aus : Nach diesem Speicher können keine Lese- oder Schreibvorgänge im aktuellen Thread neu angeordnet werden . Alle Schreibvorgänge im aktuellen Thread sind in anderen Threads sichtbar, die dieselbe atomare Variable erfassen

  • sharedDatawird nach dem whileBeenden der Schleife verwendet und gibt daher nach dem load()from-Flag einen Wert ungleich Null zurück. load()verwendet std::memory_order_acquireReihenfolge:

std::memory_order_acquire

Eine Ladeoperation mit dieser Speicherreihenfolge führt die Erfassungsoperation für den betroffenen Speicherort aus: Vor diesem Laden können keine Lese- oder Schreibvorgänge im aktuellen Thread neu angeordnet werden. Alle Schreibvorgänge in anderen Threads, die dieselbe atomare Variable freigeben, sind im aktuellen Thread sichtbar .

Dies gibt Ihnen eine genaue Kontrolle über die Synchronisation und ermöglicht es Ihnen, explizit anzugeben, wie sich Ihr Code möglicherweise / möglicherweise nicht / wird / nicht verhält. Dies wäre nicht möglich, wenn nur die Atomizität selbst garantiert wäre. Besonders wenn es um sehr interessante Synchronisationsmodelle wie die Release-Consum-Bestellung geht .

Mateusz Grzejek
quelle
2
Gibt es tatsächlich Architekturen, die keine atomaren Lasten und Speicher für Grundelemente wie ints haben?
7
Es geht nicht nur um Atomizität. Es geht auch um Bestellung, Verhalten in Mehrkernsystemen usw. Vielleicht möchten Sie diesen Artikel lesen .
Mateusz Grzejek
4
@AaryamanSagar Wenn ich mich nicht irre, sind Lese- und Schreibvorgänge auch auf x86 NUR atomar, wenn sie an Wortgrenzen ausgerichtet sind.
v.shashenko
@MateuszGrzejek Ich habe einen Verweis auf einen Atomtyp genommen. Könnten Sie bitte überprüfen, ob das Folgende noch atomare Operation auf Objektzuweisung ideone.com/HpSwqo
xAditya3393
2
@TimMB Ja, normalerweise gibt es (mindestens) zwei Situationen, in denen die Ausführungsreihenfolge geändert werden kann: (1) Der Compiler kann die Anweisungen neu anordnen (soweit dies der Standard zulässt), um eine bessere Leistung des Ausgabecodes zu erzielen (basierend auf der Verwendung von CPU-Registern, Vorhersagen usw.) und (2) Die CPU kann Anweisungen in einer anderen Reihenfolge ausführen, um beispielsweise die Anzahl der Cache-Synchronisationspunkte zu minimieren. Die für std::atomic( std::memory_order) vorgesehenen Bestellbeschränkungen dienen genau dem Zweck, die zulässigen Nachbestellungen zu begrenzen.
Mateusz Grzejek
20

Ich verstehe, std::atomic<>dass ein Objekt atomar ist.

Das ist eine Frage der Perspektive ... Sie können es nicht auf beliebige Objekte anwenden und deren Operationen atomar werden lassen, aber die bereitgestellten Spezialisierungen für (die meisten) integralen Typen und Zeiger können verwendet werden.

a = a + 12;

std::atomic<>vereinfacht dies nicht (verwenden Sie Vorlagenausdrücke, um dies zu vereinfachen) zu einer einzelnen atomaren Operation, stattdessen führt das operator T() const volatile noexceptMitglied eine atomare Operation load()aus a, dann werden zwölf hinzugefügt und operator=(T t) noexcepta store(t).

Tony Delroy
quelle
Das wollte ich fragen. Ein reguläres int hat atomare Lasten und Speicher. Was
7
@AaryamanSagar Durch einfaches Ändern einer Normalen intwird nicht sichergestellt, dass die Änderung von anderen Threads aus sichtbar ist, und durch das Lesen wird auch nicht sichergestellt, dass die Änderungen anderer Threads angezeigt werden. Einige Dinge wie my_int += 3werden nicht garantiert atomar ausgeführt, es sei denn, Sie verwenden std::atomic<>sie ein Abruf, dann Hinzufügen, dann Speichern der Sequenz, wobei ein anderer Thread, der versucht, denselben Wert zu aktualisieren, möglicherweise nach dem Abrufen und vor dem Speichern eingeht und die Aktualisierung Ihres Threads blockiert.
Tony Delroy
"Durch einfaches Ändern eines normalen int wird nicht sichergestellt, dass die Änderung von anderen Threads aus sichtbar ist. " Es ist schlimmer als das: Jeder Versuch, diese Sichtbarkeit zu messen, würde zu UB führen.
Neugieriger
8

std::atomic existiert, weil viele ISAs direkte Hardwareunterstützung dafür haben

Was der C ++ - Standard sagt, std::atomicwurde in anderen Antworten analysiert.

Nun wollen wir sehen, was std::atomickompiliert wird, um einen anderen Einblick zu erhalten.

Die wichtigste Erkenntnis aus diesem Experiment ist, dass moderne CPUs direkte Unterstützung für atomare Ganzzahloperationen, beispielsweise das LOCK-Präfix in x86, haben und im std::atomicGrunde genommen als tragbare Schnittstelle zu diesen Anweisungen existieren: Was bedeutet der Befehl "lock" in der x86-Assembly? In aarch64 würde LDADD verwendet.

Diese Unterstützung ermöglicht eine schnelleren Alternativen zu allgemeineren Methoden wie std::mutex, die komplexen Multi-Instruktions Abschnitte Atom machen kann, auf Kosten des Seins langsamer als , std::atomicweil std::mutexes macht futexSystemaufrufe in Linux, die Art und Weise langsamer als die Userland - Anweisungen ist emittieren durch std::atomic, siehe auch: Erstellt std :: mutex einen Zaun?

Betrachten wir das folgende Multithread-Programm, das eine globale Variable über mehrere Threads mit unterschiedlichen Synchronisationsmechanismen inkrementiert, je nachdem, welche Präprozessordefinition verwendet wird.

main.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

size_t niters;

#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
#if LOCK
        __asm__ __volatile__ (
            "lock incq %0;"
            : "+m" (global),
              "+g" (i) // to prevent loop unrolling
            :
            :
        );
#else
        __asm__ __volatile__ (
            ""
            : "+g" (i) // to prevent he loop from being optimized to a single add
            : "g" (global)
            :
        );
        global++;
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    uint64_t expect = nthreads * niters;
    std::cout << "expect " << expect << std::endl;
    std::cout << "global " << global << std::endl;
}

GitHub stromaufwärts .

Kompilieren, ausführen und zerlegen:

comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out                    $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out       -DLOCK       $common

./main_fail.out       4 100000
./main_std_atomic.out 4 100000
./main_lock.out       4 100000

gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out

Sehr wahrscheinlich "falsche" Ausgabe der Rennbedingungen für main_fail.out:

expect 400000
global 100000

und deterministische "richtige" Ausgabe der anderen:

expect 400000
global 400000

Demontage von main_fail.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 <niters>
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 <threadMain()+52>
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 <global>
   0x0000000000002797 <+23>:    xor    %eax,%eax
   0x0000000000002799 <+25>:    nopl   0x0(%rax)
   0x00000000000027a0 <+32>:    add    $0x1,%rax
   0x00000000000027a4 <+36>:    add    $0x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq

Demontage von main_std_atomic.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a6 <threadMain()+38>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq $0x1,0x299f(%rip)        # 0x5138 <global>
   0x0000000000002799 <+25>:    add    $0x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 <niters>
   0x00000000000027a4 <+36>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a6 <+38>:    retq   

Demontage von main_lock.out:

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a5 <threadMain()+37>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 <global>
   0x0000000000002798 <+24>:    add    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq

Schlussfolgerungen:

  • Die nichtatomare Version speichert die globale Version in einem Register und erhöht das Register.

    Daher werden am Ende sehr wahrscheinlich vier Schreibvorgänge mit demselben "falschen" Wert von auf global zurückgeführt 100000.

  • std::atomickompiliert zu lock addq. Das LOCK-Präfix bewirkt, dass der folgende incSpeicher atomar abgerufen, geändert und aktualisiert wird.

  • Unser explizites Inline-Assembly-LOCK-Präfix wird fast genauso kompiliert wie std::atomic, außer dass unser incanstelle verwendet wird add. Ich bin mir nicht sicher, warum GCC sich entschieden hat add, wenn man bedenkt, dass unser INC eine um 1 Byte kleinere Decodierung generiert hat.

ARMv8 kann in neueren CPUs entweder LDAXR + STLXR oder LDADD verwenden: Wie starte ich Threads in normalem C?

Getestet in Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
quelle