Che cos'è esattamente std :: atomic?


174

Capisco che std::atomic<>è un oggetto atomico. Ma atomico fino a che punto? Secondo me un'operazione può essere atomica. Cosa si intende esattamente per rendere atomico un oggetto? Ad esempio, se ci sono due thread che eseguono contemporaneamente il seguente codice:

a = a + 12;

Quindi l'intera operazione (diciamo add_twelve_to(int)) atomica? Oppure vengono apportate modifiche alla variabile atomica (quindi operator=())?


9
È necessario utilizzare qualcosa di simile a.fetch_add(12)se si desidera un RMW atomico.
Kerrek SB,

Sì, è quello che non capisco. Cosa si intende per rendere atomico un oggetto. Se esistesse un'interfaccia, avrebbe semplicemente potuto essere resa atomica con un mutex o un monitor.

2
@AaryamanSagar risolve un problema di efficienza. I mutex e i monitor hanno un sovraccarico computazionale. L'utilizzo std::atomicconsente alla libreria standard di decidere cosa è necessario per raggiungere l'atomicità.
Ha disegnato Dormann il

1
@AaryamanSagar: std::atomic<T>è un tipo che consente operazioni atomiche. Magicamente non ti migliora la vita, devi ancora sapere cosa vuoi farci. È per un caso d'uso molto specifico e gli usi delle operazioni atomiche (sull'oggetto) sono generalmente molto sottili e devono essere pensati da una prospettiva non locale. Quindi, a meno che tu non lo sappia già e perché desideri operazioni atomiche, il tipo probabilmente non è molto utile per te.
Kerrek SB,

Risposte:


188

Ogni istanziazione e specializzazione completa di std :: atomic <> rappresenta un tipo su cui diversi thread possono operare simultaneamente (le loro istanze), senza sollevare comportamenti indefiniti:

Gli oggetti di tipo atomico sono gli unici oggetti C ++ liberi da corse di dati; cioè, se un thread scrive su un oggetto atomico mentre un altro thread legge da esso, il comportamento è ben definito.

Inoltre, gli accessi agli oggetti atomici possono stabilire la sincronizzazione tra thread e ordinare accessi alla memoria non atomici come specificato da std::memory_order.

std::atomic<>termina le operazioni che, in pre-C ++ 11 volte, dovevano essere eseguite utilizzando (ad esempio) funzioni interbloccate con MSVC o bultin atomici in caso di GCC.

Inoltre, std::atomic<>offre un maggiore controllo consentendo vari ordini di memoria che specificano i vincoli di sincronizzazione e ordinamento. Se vuoi saperne di più sull'atomica C ++ 11 e sul modello di memoria, questi link possono essere utili:

Si noti che, per i casi d'uso tipici, si useranno probabilmente operatori aritmetici sovraccaricati o un altro set di essi :

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

Poiché la sintassi dell'operatore non consente di specificare l'ordine di memoria, queste operazioni verranno eseguite con std::memory_order_seq_cst, poiché questo è l'ordine predefinito per tutte le operazioni atomiche in C ++ 11. Garantisce la coerenza sequenziale (ordinamento globale totale) tra tutte le operazioni atomiche.

In alcuni casi, tuttavia, questo potrebbe non essere necessario (e nulla viene fornito gratuitamente), quindi potresti voler utilizzare un modulo più esplicito:

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

Ora, il tuo esempio:

a = a + 12;

non valuterà una singola operazione atomica: si tradurrà in a.load()(che è atomico stesso), quindi in aggiunta tra questo valore 12e a.store()( e anche atomico) del risultato finale. Come ho notato prima, std::memory_order_seq_cstverrà utilizzato qui.

Tuttavia, se scrivi a += 12, sarà un'operazione atomica (come ho notato prima) ed è approssimativamente equivalente a a.fetch_add(12, std::memory_order_seq_cst).

Per quanto riguarda il tuo commento:

Un normale intha carichi e depositi atomici. Qual è il punto di avvolgerlo con atomic<>?

La tua affermazione è vera solo per le architetture che forniscono tale garanzia di atomicità per negozi e / o carichi. Ci sono architetture che non lo fanno. Inoltre, di solito è necessario che le operazioni devono essere eseguite su un indirizzo allineato a parola / parola per essere atomico std::atomic<>è qualcosa che è garantito essere atomico su ogni piattaforma, senza requisiti aggiuntivi. Inoltre, ti permette di scrivere codice in questo modo:

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

Nota che la condizione di asserzione sarà sempre vera (e quindi non si innescherà mai), quindi puoi sempre essere sicuro che i dati siano pronti dopo whilela chiusura del ciclo. Questo perchè:

  • store()al flag viene eseguito dopo aver sharedDataimpostato (supponiamo che generateData()restituisca sempre qualcosa di utile, in particolare, non ritorni mai NULL) e usa l' std::memory_order_releaseordine:

memory_order_release

Un'operazione di memorizzazione con questo ordine di memoria esegue l' operazione di rilascio : nessuna lettura o scrittura nel thread corrente può essere riordinata dopo questa memorizzazione. Tutte le scritture nel thread corrente sono visibili in altri thread che acquisiscono la stessa variabile atomica

  • sharedDataviene utilizzato dopo l' whileuscita dal loop, quindi dopo load()da flag verrà restituito un valore diverso da zero. load()usa l' std::memory_order_acquireordine:

std::memory_order_acquire

Un'operazione di caricamento con questo ordine di memoria esegue l' operazione di acquisizione nella posizione della memoria interessata: nessuna lettura o scrittura nel thread corrente può essere riordinata prima di questo caricamento. Tutte le scritture in altri thread che rilasciano la stessa variabile atomica sono visibili nel thread corrente .

Questo ti dà un controllo preciso sulla sincronizzazione e ti consente di specificare esplicitamente come il tuo codice potrebbe / potrebbe / non / non / si comporterà. Ciò non sarebbe possibile se la sola garanzia fosse l'atomicità stessa. Soprattutto quando si tratta di modelli di sincronizzazione molto interessanti come l' ordinamento rilascio-consumo .


2
Esistono effettivamente architetture che non hanno carichi atomici e depositi per primitivi come ints?

7
Non si tratta solo di atomicità. riguarda anche l'ordinazione, il comportamento nei sistemi multi-core, ecc. Potresti voler leggere questo articolo .
Mateusz Grzejek,

4
@AaryamanSagar Se non sbaglio, anche su x86 le letture e le scritture sono atomiche SOLO se allineate sui confini delle parole.
v.shashenko,

@MateuszGrzejek Ho preso un riferimento a un tipo atomico. Potresti gentilmente verificare se quanto segue garantirebbe comunque il funzionamento atomico sull'assegnazione degli oggetti ideone.com/HpSwqo
xAditya3393

3
@TimMB Sì, normalmente, si avrebbero (almeno) due situazioni, in cui l'ordine di esecuzione potrebbe essere modificato: (1) il compilatore può riordinare le istruzioni (per quanto lo consenta lo standard) al fine di fornire migliori prestazioni del codice di output (basato sull'uso di registri, previsioni, ecc. della CPU) e (2) La CPU può eseguire le istruzioni in un ordine diverso per, ad esempio, ridurre al minimo il numero di punti di sincronizzazione della cache. I vincoli di ordinamento forniti per std::atomic( std::memory_order) servono esattamente allo scopo di limitare i riordini che possono avvenire.
Mateusz Grzejek,

20

Capisco che std::atomic<>rende atomico un oggetto.

È una questione di prospettiva ... non è possibile applicarlo a oggetti arbitrari e le loro operazioni diventano atomiche, ma è possibile utilizzare le specializzazioni fornite per (la maggior parte) tipi e puntatori integrali.

a = a + 12;

std::atomic<>(non usa espressioni di template per) semplifica questa operazione in una singola operazione atomica, invece il operator T() const volatile noexceptmembro fa un atomico load()di a, quindi ne viene aggiunto dodici e operator=(T t) noexceptfa un store(t).


Era quello che volevo chiedere. Un int normale ha carichi e depositi atomici. Qual è il punto di avvolgerlo con <>

8
@AaryamanSagar La semplice modifica di una normale intnon garantisce in modo portabile che la modifica sia visibile da altri thread, né la sua lettura garantisce la visualizzazione delle modifiche di altri thread e alcune cose come my_int += 3non sono garantite per essere eseguite atomicamente a meno che non vengano utilizzate std::atomic<>: potrebbero comportare un recupero, quindi aggiungere, quindi archiviare la sequenza, in cui un altro thread che tenta di aggiornare lo stesso valore potrebbe entrare dopo il recupero e prima del negozio e bloccare l'aggiornamento del thread.
Tony Delroy,

"La semplice modifica di un normale int non garantisce in modo portabile che la modifica sia visibile da altri thread " È peggio di così: qualsiasi tentativo di misurare tale visibilità comporterebbe UB.
curiousguy,

8

std::atomic esiste perché molti ISA hanno il supporto hardware diretto per questo

Ciò che lo standard C ++ dice std::atomicè stato analizzato in altre risposte.

Quindi ora vediamo cosa std::atomiccompila per ottenere un diverso tipo di intuizione.

Il principale takeaway di questo esperimento è che le moderne CPU hanno il supporto diretto per le operazioni con numeri interi atomici, ad esempio il prefisso LOCK in x86, e std::atomicsostanzialmente esiste come interfaccia portatile per quelle istruzioni : cosa significa l'istruzione "lock" nell'assemblaggio x86? In aarch64, sarebbe usato LDADD .

Questo supporto consente alternative più rapide a metodi più generali come std::mutex, che possono rendere atomiche sezioni di istruzioni multiple più complesse, al costo di essere più lente rispetto al std::atomicfatto std::mutexche effettua futexchiamate di sistema in Linux, che è molto più lenta delle istruzioni utente emesse da std::atomic, vedi anche: std :: mutex crea una recinzione?

Consideriamo il seguente programma multi-thread che incrementa una variabile globale su più thread, con diversi meccanismi di sincronizzazione a seconda della definizione del preprocessore utilizzata.

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 a monte .

Compilare, eseguire e disassemblare:

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

Uscita di condizioni di gara "errata" estremamente probabile per main_fail.out:

expect 400000
global 100000

e deterministico "giusto" output degli altri:

expect 400000
global 400000

Smontaggio di 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

Smontaggio di 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   

Smontaggio di 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

conclusioni:

  • la versione non atomica salva il globale in un registro e incrementa il registro.

    Pertanto, alla fine, è molto probabile che quattro scritture tornino al globale con lo stesso valore "sbagliato" di 100000.

  • std::atomiccompila a lock addq. Il prefisso LOCK effettua il seguente increcupero, modifica e aggiornamento atomico della memoria.

  • il nostro prefisso LOCK di assembly inline esplicito viene compilato quasi allo stesso modo di std::atomic, tranne per il fatto che incviene utilizzato invece di add. Non so perché GCC abbia scelto add, considerando che il nostro INC ha generato una decodifica di 1 byte più piccola.

ARMv8 potrebbe utilizzare LDAXR + STLXR o LDADD nelle CPU più recenti: come posso avviare i thread in C semplice?

Testato su Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.