Num ++ può essere atomico per "int num"?


153

In generale, per int num, num++(o ++num), come operazione di lettura-modifica-scrittura, non è atomico . Ma vedo spesso compilatori, ad esempio GCC , generare il seguente codice per esso ( prova qui ):

Inserisci qui la descrizione dell'immagine

Dato che la riga 5, che corrisponde a num++un'istruzione, possiamo concludere che in questo caso num++ è atomico ?

E se è così, significa che così generato num++può essere usato in scenari simultanei (multi-thread) senza alcun pericolo di corse di dati (cioè non abbiamo bisogno di farlo, per esempio, std::atomic<int>e imporre i costi associati, dal momento che è atomico comunque)?

AGGIORNARE

Si noti che questa domanda non è se l'incremento è atomico (non lo è e quello era ed è la linea di apertura della domanda). Indica se può essere in scenari particolari, ovvero se in alcuni casi è possibile sfruttare la natura con una sola istruzione per evitare il sovraccarico del lockprefisso. E, come la risposta accettata menziona nella sezione sulle macchine uniprocessore, così come questa risposta , la conversazione nei suoi commenti e altri spiegano, può (sebbene non con C o C ++).


65
Chi ti ha detto che addè atomico?
Slava,

6
dato che una delle caratteristiche dell'atomica è la prevenzione di specifici tipi di riordino durante l'ottimizzazione, no, indipendentemente
dall'atomicità

19
Vorrei anche sottolineare che se questo è atomico sulla tua piattaforma non c'è garanzia che sarà su un'altra piattaforma. Sii indipendente dalla piattaforma ed esprimi la tua intenzione usando a std::atomic<int>.
NathanOliver,

8
Durante l'esecuzione di tale addistruzione, un altro core potrebbe rubare quell'indirizzo di memoria dalla cache di questo core e modificarlo. Su una CPU x86, l' addistruzione necessita di un lockprefisso se l'indirizzo deve essere bloccato nella cache per la durata dell'operazione.
David Schwartz,

21
È possibile che qualsiasi operazione sia "atomica". Tutto quello che devi fare è essere fortunato e non eseguire mai nulla che possa rivelare che non è atomico. Atomic ha valore solo come garanzia . Dato che stai osservando il codice assembly, la domanda è se quella particolare architettura ti fornisce la garanzia e se il compilatore fornisce una garanzia che è l'implementazione a livello di assembly che scelgono.
Cort Ammon,

Risposte:


197

Questo è assolutamente ciò che C ++ definisce una Data Race che causa un comportamento indefinito, anche se un compilatore è riuscito a produrre codice che ha fatto quello che speravi su un computer di destinazione. Devi usarlo std::atomicper risultati affidabili, ma puoi usarlo memory_order_relaxedse non ti interessa riordinare. Vedi sotto per alcuni esempi di codice e output asm usando fetch_add.


Ma prima, il linguaggio assembly della parte della domanda:

Poiché num ++ è un'istruzione ( add dword [num], 1), possiamo concludere che num ++ è atomico in questo caso?

Le istruzioni di destinazione della memoria (diverse dai negozi puri) sono operazioni di lettura-modifica-scrittura che si verificano in più passaggi interni . Nessun registro architettonico viene modificato, ma la CPU deve conservare i dati internamente mentre li invia attraverso la sua ALU . Il file di registro effettivo è solo una piccola parte della memorizzazione dei dati all'interno anche della CPU più semplice, con i fermi che trattengono le uscite di uno stadio come input per un altro stadio, ecc., Ecc.

Le operazioni di memoria da altre CPU possono diventare globalmente visibili tra il carico e l'archivio. Vale a dire che due thread in esecuzione add dword [num], 1in un ciclo si farebbero un passo nei negozi dell'altro. (Vedi la risposta di @ Margaret per un bel diagramma). Dopo incrementi di 40k da ciascuno dei due thread, il contatore potrebbe essere aumentato di ~ 60k (non 80k) su hardware x86 multi-core reale.


"Atomico", dalla parola greca che significa indivisibile, significa che nessun osservatore può vedere l'operazione come passi separati. Accedere fisicamente / elettricamente istantaneamente per tutti i bit contemporaneamente è solo un modo per raggiungere questo obiettivo per un carico o un deposito, ma ciò non è nemmeno possibile per un'operazione ALU. Ho approfondito molto di più i carichi puri e i negozi puri nella mia risposta a Atomicity su x86 , mentre questa risposta si concentra sulla lettura-modifica-scrittura.

Il lockprefisso può essere applicato a molte istruzioni di lettura-modifica-scrittura (destinazione della memoria) per rendere atomica l'intera operazione rispetto a tutti i possibili osservatori nel sistema (altri core e dispositivi DMA, non un oscilloscopio collegato ai pin della CPU). Ecco perché esiste. (Vedi anche queste domande e risposte ).

Quindi lock add dword [num], 1 è atomico . Un core della CPU che esegue tale istruzione manterrebbe la riga della cache bloccata nello stato Modified nella sua cache L1 privata da quando il carico legge i dati dalla cache fino a quando l'archivio non ripristina i suoi risultati nella cache. Ciò impedisce a qualsiasi altra cache nel sistema di avere una copia della linea della cache in qualsiasi momento dal caricamento all'archivio , secondo le regole del protocollo di coerenza della cache MESI (o delle versioni MOESI / MESIF utilizzate da AMD multi-core / CPU Intel, rispettivamente). Pertanto, le operazioni di altri core sembrano avvenire prima o dopo, non durante.

Senza il lockprefisso, un altro core potrebbe prendere la proprietà della linea della cache e modificarla dopo il nostro caricamento ma prima del nostro store, in modo che altri store diventino visibili globalmente tra il nostro carico e il nostro store. Diverse altre risposte sbagliano e sostengono che senza di lockte otterresti copie contrastanti della stessa riga della cache. Questo non può mai accadere in un sistema con cache coerenti.

(Se lockun'istruzione ed opera su memoria che si estende su due linee della cache, ci vuole molto più lavoro per assicurarsi che le modifiche a entrambe le parti dell'oggetto rimangano atomiche mentre si propagano a tutti gli osservatori, quindi nessun osservatore può vedere lacerare. La CPU potrebbe devi bloccare l'intero bus di memoria fino a quando i dati non colpiscono la memoria. Non disallineare le tue variabili atomiche!)

Si noti che il lockprefisso trasforma anche un'istruzione in una barriera di memoria piena (come MFENCE ), arrestando tutto il riordino di runtime e dando così coerenza sequenziale. (Vedi l'eccellente post sul blog di Jeff Preshing . Anche gli altri suoi post sono eccellenti e spiegano chiaramente molte cose buone sulla programmazione senza blocco , da x86 e altri dettagli hardware alle regole C ++.)


Su una macchina uniprocessore o in un processo a thread singolo, una singola istruzione RMW è in realtà atomica senza lockprefisso. L'unico modo per accedere ad altro codice alla variabile condivisa è che la CPU esegua un cambio di contesto, cosa che non può avvenire nel mezzo di un'istruzione. Quindi un piano dec dword [num]può sincronizzarsi tra un programma a thread singolo e i suoi gestori di segnali o in un programma a thread multipli in esecuzione su una macchina single-core. Vedi la seconda metà della mia risposta su un'altra domanda , e i commenti sotto di essa, dove spiego questo in modo più dettagliato.


Torna a C ++:

È totalmente falso da usare num++senza dire al compilatore che è necessario per compilare in un'unica implementazione lettura-modifica-scrittura:

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

Ciò è molto probabile se si utilizza il valore di numseguito: il compilatore lo manterrà attivo in un registro dopo l'incremento. Quindi, anche se controlli come num++compila da solo, la modifica del codice circostante può influire su di esso.

(Se il valore non è necessario in un secondo momento, inc dword [num]è preferibile; le moderne CPU x86 eseguiranno un'istruzione RMW di destinazione della memoria almeno in modo efficiente come usando tre istruzioni separate. Fatto divertente: gcc -O3 -m32 -mtune=i586lo emetterà effettivamente , perché la pipeline superscalare di (Pentium) P5 non ha decodifica istruzioni complesse in più semplici micro-operazioni come fanno le P6 e le successive microarchitettura. Vedi le tabelle di istruzioni / la guida di microarchitettura dell'Agner Fog per maggiori informazioni, e il tagga wiki per molti link utili (inclusi i manuali ISA x86 di Intel, che sono disponibili gratuitamente in PDF)).


Non confondere il modello di memoria di destinazione (x86) con il modello di memoria C ++

Il riordino in fase di compilazione è consentito . L'altra parte di ciò che ottieni con std :: atomic è il controllo sul riordino in fase di compilazione, per assicurarti chenum++diventi visibile a livello globale solo dopo qualche altra operazione.

Esempio classico: memorizzazione di alcuni dati in un buffer per un altro thread da guardare, quindi impostazione di un flag. Anche se x86 acquisisce i carichi / rilascia negozi gratuitamente, devi comunque dire al compilatore di non riordinare usando flag.store(1, std::memory_order_release);.

Potresti aspettarti che questo codice si sincronizzerà con altri thread:

// 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++;

Ma non lo farà. Il compilatore è libero di spostare la flag++chiamata attraverso la funzione (se incorpora la funzione o sa che non guarda flag). Quindi può ottimizzare completamente la modifica, perché flagnon è uniforme volatile. (E no, C ++ volatilenon è un utile sostituto di std :: atomic. Std :: atomic fa supporre al compilatore che i valori in memoria possano essere modificati in modo asincrono simile volatile, ma c'è molto di più rispetto a quello. Inoltre, volatile std::atomic<int> foonon è il lo stesso std::atomic<int> foo, come discusso con @Richard Hodges.)

Definire le corse dei dati su variabili non atomiche come comportamento indefinito è ciò che consente al compilatore di sollevare ancora carichi e affondare gli archivi dai loop e molte altre ottimizzazioni per la memoria a cui più thread potrebbero fare riferimento. (Vedi questo blog LLVM per ulteriori informazioni su come UB abilita le ottimizzazioni del compilatore.)


Come ho già detto, il prefisso x86lock è una barriera di memoria completa, quindi l'utilizzo num.fetch_add(1, std::memory_order_relaxed);genera su x86 lo stesso codice di num++(l'impostazione predefinita è la coerenza sequenziale), ma può essere molto più efficiente su altre architetture (come ARM). Anche su x86, relaxed consente un maggiore riordino in fase di compilazione.

Questo è ciò che GCC effettivamente fa su x86, per alcune funzioni che operano su una std::atomicvariabile globale.

Vedi il codice del linguaggio source + assembly formattato correttamente sull'esploratore del compilatore Godbolt . È possibile selezionare altre architetture di destinazione, tra cui ARM, MIPS e PowerPC, per vedere quale tipo di codice linguaggio assembly si ottiene dall'atomica per quelle destinazioni.

#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

Si noti come è necessario MFENCE (una barriera completa) dopo un archivio a coerenza sequenziale. x86 è fortemente ordinato in generale, ma è consentito il riordino StoreLoad. Avere un buffer di archivio è essenziale per ottenere buone prestazioni su una CPU out-of-order pipeline. Il riordino della memoria di Jeff Preshing Caught in the Act mostra le conseguenze del non utilizzo di MFENCE, con un codice reale per mostrare il riordino in corso su hardware reale.


Ri: discussione nei commenti sulla risposta di @Richard Hodges sui compilatori che uniscono num++; num-=2;operazioni std :: atomic in un'unica num--;istruzione :

Domande e risposte separate su questo stesso argomento: perché i compilatori non uniscono le scritture std :: atomic ridondanti? , in cui la mia risposta ribadisce molto di ciò che ho scritto di seguito.

I compilatori attuali non lo fanno (ancora), ma non perché non sono autorizzati a farlo. C ++ WG21 / P0062R1: quando i compilatori dovrebbero ottimizzare l'atomica? discute le aspettative che molti programmatori hanno che i compilatori non realizzeranno ottimizzazioni "sorprendenti" e cosa può fare lo standard per dare il controllo ai programmatori. N4455 discute molti esempi di cose che possono essere ottimizzati, incluso questo. Sottolinea che la propagazione in linea e costante può introdurre cose come quelle fetch_or(0)che potrebbero essere in grado di trasformarsi in solo una load()(ma ha ancora acquisito e rilasciato semantica), anche quando la fonte originale non aveva operazioni atomiche ovviamente ridondanti.

Le vere ragioni per cui i compilatori non lo fanno (ancora) sono: (1) nessuno ha scritto il codice complicato che consentirebbe al compilatore di farlo in modo sicuro (senza mai sbagliare), e (2) potenzialmente viola il principio del minimo sorpresa . Il codice senza blocco è abbastanza difficile da scrivere correttamente in primo luogo. Quindi non essere casuale nell'uso delle armi atomiche: non sono economiche e non ottimizzano molto. std::shared_ptr<T>Tuttavia, non è sempre facile evitare operazioni atomiche ridondanti , poiché non esiste una versione non atomica (anche se una delle risposte qui fornisce un modo semplice per definire un shared_ptr_unsynchronized<T>per gcc).


Tornare alla num++; num-=2;compilazione come se fosse num--: ai compilatori è permesso farlo, a meno che non lo numsia volatile std::atomic<int>. Se è possibile un riordino, la regola as-if consente al compilatore di decidere in fase di compilazione che ciò avvenga sempre in quel modo. Nulla garantisce che un osservatore possa vedere i valori intermedi (il num++risultato).

Vale a dire se l'ordinamento in cui nulla diventa globalmente visibile tra queste operazioni è compatibile con i requisiti di ordinamento del sorgente (secondo le regole C ++ per la macchina astratta, non per l'architettura di destinazione), il compilatore può emettere un singolo lock dec dword [num]invece di lock inc dword [num]/ lock sub dword [num], 2.

num++; num--non può scomparire, perché ha ancora una relazione Sincronizza con altri thread che guardano num, ed è sia un carico di acquisizione che un negozio di rilascio che non consente il riordino di altre operazioni in questo thread. Per x86, questo potrebbe essere in grado di compilare in un MFENCE, invece di un lock add dword [num], 0(cioè num += 0).

Come discusso in PR0062 , la fusione più aggressiva di operazioni atomiche non adiacenti in fase di compilazione può essere negativa (ad esempio un contatore di progressi viene aggiornato solo una volta alla fine invece di ogni iterazione), ma può anche aiutare le prestazioni senza inconvenienti (ad esempio saltare la inc / dec atomico di ref conta quando shared_ptrviene creata e distrutta una copia di a , se il compilatore può provare che shared_ptresiste un altro oggetto per l'intera durata del temporaneo.)

Anche la num++; num--fusione potrebbe danneggiare l'imparzialità dell'implementazione di un blocco quando un thread si sblocca e ri-blocca immediatamente. Se non viene mai effettivamente rilasciato nell'asm, anche i meccanismi di arbitrato hardware non daranno a un altro thread la possibilità di afferrare il blocco in quel punto.


Con l'attuale gcc6.2 e clang3.9, si ottengono comunque lockoperazioni separate anche memory_order_relaxednel caso più evidentemente ottimizzabile. ( Godbolt compilatore Explorer in modo da poter vedere se le ultime versioni sono diverse.)

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

1
"[usando istruzioni separate] era più efficiente ... ma le moderne CPU x86 ancora una volta gestiscono le operazioni RMW almeno in modo altrettanto efficiente" - è ancora più efficiente nel caso in cui il valore aggiornato verrà utilizzato successivamente nella stessa funzione e c'è un registro gratuito disponibile per il compilatore in cui memorizzarlo (e la variabile non è marcata volatile, ovviamente). Ciò significa che è molto probabile che il fatto che il compilatore generi una singola istruzione o multiplo per l'operazione dipenda dal resto del codice nella funzione, non solo dalla singola riga in questione.
Periata Breatta,

@PeriataBreatta: sì, buon punto. In asm potresti usare mov eax, 1 xadd [num], eax(senza prefisso di blocco) per implementare post-incremento num++, ma non è quello che fanno i compilatori.
Peter Cordes,

3
@ DavidC.Rankin: se hai qualche modifica che desideri apportare, sentiti libero. Non voglio fare questo CW, però. È ancora il mio lavoro (e il mio pasticcio: P). Riordinerò alcuni dopo il mio gioco Ultimate [frisbee] :)
Peter Cordes,

1
Se non è un wiki della comunità, forse un link sul tag wiki appropriato. (sia i tag x86 che atomici?). Vale la pena un collegamento aggiuntivo piuttosto che un promettente ritorno da una ricerca generica su SO (Se sapessi meglio dove dovrebbe adattarsi a tale riguardo, lo farei. Dovrò scavare più a fondo nel tag do e non collegamento wiki)
David C. Rankin

1
Come sempre - ottima risposta! Buona distinzione tra coerenza e atomicità (dove alcuni hanno sbagliato)
Leeor

39

... e ora abilitiamo le ottimizzazioni:

f():
        rep ret

OK, diamo una possibilità:

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

risultato:

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

un altro thread di osservazione (anche ignorando i ritardi di sincronizzazione della cache) non ha alcuna possibilità di osservare le singole modifiche.

confrontare con:

#include <atomic>

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

dove il risultato è:

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

Ora, ogni modifica è: -

  1. osservabile in un altro thread e
  2. rispettoso di modifiche simili che si verificano in altri thread.

l'atomicità non è solo a livello di istruzione, coinvolge l'intera pipeline dal processore, attraverso le cache, alla memoria e viceversa.

Ulteriori informazioni

Per quanto riguarda l'effetto delle ottimizzazioni degli aggiornamenti di std::atomics.

Lo standard c ++ ha la regola "come se", in base alla quale è consentito al compilatore di riordinare il codice e persino riscrivere il codice a condizione che il risultato abbia gli stessi effetti osservabili (inclusi gli effetti collaterali) come se avesse semplicemente eseguito il codice.

La regola as-if è conservativa, in particolare per quanto riguarda l'atomica.

tener conto di:

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

Poiché non ci sono blocchi di mutex, atomica o altri costrutti che influenzano il sequenziamento tra thread, direi che il compilatore è libero di riscrivere questa funzione come NOP, ad esempio:

void incdec(int&) {
    // nada
}

Questo perché nel modello di memoria c ++ non è possibile che un altro thread osservi il risultato dell'incremento. Ovviamente sarebbe diverso se lo numfosse volatile(potrebbe influenzare il comportamento dell'hardware). Ma in questo caso, questa funzione sarà l'unica funzione che modifica questa memoria (altrimenti il ​​programma è mal formato).

Tuttavia, questo è un gioco con la palla diverso:

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

numè un atomico. Le modifiche devono essere osservabili ad altri thread che stanno guardando. Le modifiche apportate da questi thread (come l'impostazione del valore su 100 tra l'incremento e il decremento) avranno effetti di vasta portata sull'eventuale valore di num.

Ecco una 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;
    }
}

uscita campione:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99

5
Questo non spiega che nonadd dword [rdi], 1 è atomico (senza il prefisso). Il carico è atomico e l'archivio è atomico, ma nulla impedisce a un altro thread di modificare i dati tra il carico e l'archivio. In questo modo il negozio può eseguire una modifica apportata da un altro thread. Vedi jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . Inoltre, gli articoli senza blocco di Jeff Preshing sono estremamente validi e menziona il problema di base di RMW in quell'articolo introduttivo. lock
Peter Cordes,

3
Quello che sta realmente succedendo qui è che nessuno ha implementato questa ottimizzazione in gcc, perché sarebbe quasi inutile e probabilmente più pericoloso che utile. (Principio della minima sorpresa. Forse qualcuno si aspetta che uno stato temporaneo sia visibile a volte, e che stia bene con la probabilità statistica. O stanno usando punti di controllo hardware per interrompere la modifica.) Il codice senza blocco deve essere creato con cura, quindi non ci sarà nulla da ottimizzare. Potrebbe essere utile cercarlo e stampare un avviso, per avvisare il programmatore che il loro codice potrebbe non significare quello che pensano!
Peter Cordes,

2
Questo è forse un motivo per cui i compilatori non devono implementarlo (principio della minima sorpresa e così via). Osservando che sarebbe possibile in pratica su hardware reale. Tuttavia, le regole di ordinamento della memoria C ++ non dicono nulla in merito alla garanzia che i carichi di un thread si mescolino "in modo uniforme" con le operazioni di altri thread nella macchina astratta C ++. Penso ancora che sarebbe legale, ma programmatore-ostile.
Peter Cordes,

2
Esperimento di pensiero: considera un'implementazione C ++ su un sistema cooperativo multi-tasking. Implementa std :: thread inserendo i punti di snervamento dove necessario per evitare deadlock, ma non tra tutte le istruzioni. Immagino che sosterresti che qualcosa nello standard C ++ richiede un limite di snervamento tra num++e num--. Se riesci a trovare una sezione nello standard che lo richiede, risolverà ciò. Sono abbastanza sicuro che richiede solo che nessun osservatore possa mai vedere un riordino sbagliato, che non richiede una resa lì. Quindi penso che sia solo un problema di qualità di implementazione.
Peter Cordes,

5
Per motivi di finalità, ho chiesto alla mailing list di discussione standard. Questa domanda ha prodotto 2 articoli che sembrano concordare sia con Peter, sia per rispondere alle preoccupazioni che ho di tali ottimizzazioni: wg21.link/p0062 e wg21.link/n4455 I miei ringraziamenti ad Andy che mi hanno portato alla mia attenzione.
Richard Hodges,

38

Senza molte complicazioni un'istruzione simile add DWORD PTR [rbp-4], 1è molto in stile CISC.

Esegue tre operazioni: caricare l'operando dalla memoria, incrementarlo, salvare l'operando in memoria.
Durante queste operazioni la CPU acquisisce e rilascia il bus due volte, tra cui qualsiasi altro agente può acquisirlo e questo viola l'atomicità.

AGENT 1          AGENT 2

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

X viene incrementato una sola volta.


7
@LeoHeinsaar Affinché ciò avvenga, ogni chip di memoria avrebbe bisogno della propria Arithmetic Logic Unit (ALU). In effetti, richiederebbe che ciascun chip di memoria fosse un processore.
Richard Hodges,

6
@LeoHeinsaar: le istruzioni di destinazione della memoria sono operazioni di lettura-modifica-scrittura. Nessun registro architettonico viene modificato, ma la CPU deve conservare i dati internamente mentre li invia attraverso la sua ALU. Il file di registro effettivo è solo una piccola parte della memorizzazione dei dati anche all'interno della CPU più semplice, con i fermi che trattengono le uscite di uno stadio come input per un altro stadio, ecc. Ecc.
Peter Cordes

@PeterCordes Il tuo commento è esattamente la risposta che stavo cercando. La risposta di Margaret mi fece sospettare che qualcosa del genere dovesse succedere dentro.
Leo Heinsaar,

Trasformato quel commento in una risposta completa, incluso indirizzare la parte C ++ della domanda.
Peter Cordes,

1
@PeterCordes Grazie, molto dettagliato e su tutti i punti. Era ovviamente una corsa ai dati e quindi un comportamento indefinito dallo standard C ++, ero solo curioso di sapere se nei casi in cui il codice generato era quello che avevo pubblicato si potesse supporre che potesse essere atomico ecc. Ecc. Ho anche solo controllato che almeno lo sviluppatore Intel i manuali definiscono chiaramente l' atomicità rispetto alle operazioni di memoria e non l'indivisibilità delle istruzioni, come ho ipotizzato: "Le operazioni bloccate sono atomiche rispetto a tutte le altre operazioni di memoria e a tutti gli eventi visibili esternamente".
Leo Heinsaar,

11

L'istruzione add non lo è atomica. Fa riferimento alla memoria e due core del processore possono avere cache locali diverse di quella memoria.

IIRC la variante atomica dell'istruzione add si chiama lock xadd


3
lock xaddimplementa C ++ std :: atomic fetch_add, restituendo il vecchio valore. Se non è necessario, il compilatore utilizzerà le normali istruzioni di destinazione della memoria con un lockprefisso. lock addo lock inc.
Peter Cordes,

1
add [mem], 1non sarebbe comunque atomico su una macchina SMP senza cache, vedere i miei commenti su altre risposte.
Peter Cordes,

Vedi la mia risposta per ulteriori dettagli su come non sia atomico. Anche la fine della mia risposta su questa domanda correlata .
Peter Cordes,

10

Poiché la riga 5, che corrisponde a num ++ è un'istruzione, possiamo concludere che num ++ è atomico in questo caso?

È pericoloso trarre conclusioni basate sull'assemblaggio generato da "reverse engineering". Ad esempio, sembra che tu abbia compilato il tuo codice con l'ottimizzazione disabilitata, altrimenti il ​​compilatore avrebbe gettato via quella variabile o caricato 1 direttamente su di esso senza invocareoperator++ . Poiché l'assembly generato può cambiare in modo significativo, in base a flag di ottimizzazione, CPU di destinazione, ecc., La conclusione si basa sulla sabbia.

Inoltre, l'idea che un'istruzione di assemblaggio significhi che un'operazione atomica è sbagliata. Questo addnon sarà atomico sui sistemi multi-CPU, nemmeno sull'architettura x86.


9

Anche se il tuo compilatore emettesse sempre questo come un'operazione atomica, l'accesso numda qualsiasi altro thread contemporaneamente costituirebbe una corsa ai dati secondo gli standard C ++ 11 e C ++ 14 e il programma avrebbe un comportamento indefinito.

Ma è peggio di così. Innanzitutto, come è stato menzionato, l'istruzione generata dal compilatore durante l'incremento di una variabile può dipendere dal livello di ottimizzazione. In secondo luogo, il compilatore può riordinare altri accessi alla memoria ++numse numnon è atomico, ad es

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

Anche se assumiamo ottimisticamente che ++readyè "atomico" e che il compilatore genera il ciclo di controllo in base alle esigenze (come ho detto, è UB e quindi il compilatore è libero di rimuoverlo, sostituirlo con un ciclo infinito, ecc.), il compilatore potrebbe comunque spostare l'assegnazione del puntatore o, peggio ancora, l'inizializzazione di vectorun punto dopo l'operazione di incremento, causando il caos nel nuovo thread. In pratica, non sarei affatto sorpreso se un compilatore ottimizzante rimuovesse ilready variabile e il ciclo di verifica, poiché ciò non influisce sul comportamento osservabile secondo le regole del linguaggio (al contrario delle tue speranze private).

In effetti, alla conferenza Meeting C ++ dell'anno scorso, ne ho sentito due sviluppatori di compilatori che implementano molto volentieri ottimizzazioni che rendono mal funzionanti i programmi multi-thread scritti ingenuamente, purché le regole del linguaggio lo consentano, se si nota anche un miglioramento delle prestazioni minore in programmi scritti correttamente.

Infine, anche se non ti importava della portabilità e il tuo compilatore era magicamente bello, la CPU che stai utilizzando è molto probabilmente di tipo CISC superscalare e suddivide le istruzioni in micro-operazioni, riordina e / o le esegui in modo speculativo, solo in misura limitata dalla sincronizzazione di primitivi come (su Intel) il LOCKprefisso o i recinti di memoria, al fine di massimizzare le operazioni al secondo.

Per farla breve, le responsabilità naturali della programmazione thread-safe sono:

  1. Il tuo dovere è di scrivere un codice che abbia un comportamento ben definito secondo le regole del linguaggio (e in particolare il modello di memoria standard del linguaggio).
  2. Il compito del compilatore è generare codice macchina che abbia lo stesso comportamento ben definito (osservabile) nel modello di memoria dell'architettura di destinazione.
  3. Il compito della CPU è eseguire questo codice in modo che il comportamento osservato sia compatibile con il modello di memoria della propria architettura.

Se vuoi farlo a modo tuo, in alcuni casi potrebbe funzionare, ma capisci che la garanzia è nulla e sarai il solo responsabile per qualsiasi indesiderato risultati . :-)

PS: esempio scritto correttamente:

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

Questo è sicuro perché:

  1. I controlli di ready non possono essere ottimizzati in base alle regole della lingua.
  2. Il ++ready accade-prima del controllo che vede readycome zero, e altre operazioni non possono essere riordinate intorno a queste operazioni. Questo perché ++readye il controllo è sequenzialmente coerente , che è un altro termine descritto nel modello di memoria C ++ e che proibisce questo riordino specifico. Pertanto il compilatore non deve riordinare le istruzioni e deve anche dire alla CPU che non deve ad esempio posticipare la scrittura veca dopo l'incremento di ready. Coerentemente sequenziale è la più forte garanzia per quanto riguarda l'atomica nello standard linguistico. Sono disponibili garanzie minori (e teoricamente più economiche), ad esempio tramite altri metodi distd::atomic<T>, ma questi sono sicuramente solo per esperti e potrebbero non essere ottimizzati molto dagli sviluppatori del compilatore, perché sono usati raramente.

1
Se il compilatore non fosse in grado di vedere tutti gli usi di ready, probabilmente si sarebbe compilato while (!ready);in qualcosa di più simile if(!ready) { while(true); }. Upvoted: una parte chiave di std :: atomic sta cambiando la semantica per assumere modifiche asincrone in qualsiasi momento. Avere UB normalmente è ciò che consente ai compilatori di sollevare carichi e affondare i negozi dai circuiti.
Peter Cordes,

9

Su una macchina x86 single-core, addun'istruzione sarà generalmente atomica rispetto ad altri codici sulla CPU 1 . Un interrupt non può dividere una singola istruzione nel mezzo.

L'esecuzione fuori servizio è necessaria per preservare l'illusione delle istruzioni eseguite una alla volta in ordine all'interno di un singolo core, quindi qualsiasi istruzione in esecuzione sulla stessa CPU avverrà completamente prima o completamente dopo l'aggiunta.

I moderni sistemi x86 sono multi-core, quindi non si applica il caso speciale uniprocessore.

Se si sta prendendo di mira un piccolo PC incorporato e non si ha intenzione di spostare il codice su qualcos'altro, la natura atomica dell'istruzione "add" potrebbe essere sfruttata. D'altra parte, le piattaforme in cui le operazioni sono intrinsecamente atomiche stanno diventando sempre più scarse.

(Questo non ti aiuta se stai scrivendo in C ++, però. I compilatori non hanno un'opzione da richiedere num++per compilare in una destinazione di memoria aggiungere o xadd senza un lockprefisso. Potrebbero scegliere di caricare numin un registro e archiviare il risultato dell'incremento con un'istruzione separata, e probabilmente lo farà se si utilizza il risultato.)


Nota 1: il lockprefisso esisteva anche sull'8086 originale perché i dispositivi I / O funzionano contemporaneamente con la CPU; i driver su un sistema single-core devono lock addincrementare atomicamente un valore nella memoria del dispositivo se il dispositivo può anche modificarlo o rispetto all'accesso DMA.


Non è nemmeno generalmente atomico: un altro thread può aggiornare la stessa variabile contemporaneamente e viene acquisito un solo aggiornamento.
fuz,

1
Prendi in considerazione un sistema multi-core. Naturalmente, all'interno di un nucleo, l'istruzione è atomica, ma non è atomica rispetto all'intero sistema.
fuz,

1
@FUZxxl: quali sono state la quarta e la quinta parola della mia risposta?
Supercat

1
@supercat La tua risposta è molto fuorviante perché considera solo il raro caso attuale di un singolo core e dà a OP un falso senso di sicurezza. Ecco perché ho commentato di considerare anche il caso multi-core.
fuz,

1
@FUZxxl: ho fatto una modifica per chiarire la potenziale confusione per i lettori che non si sono accorti che non si tratta di normali CPU multicore moderne. (Ed essere anche più specifici su alcune cose di cui Supercat non era sicuro). A proposito, tutto in questa risposta è già nel mio, tranne l'ultima frase su come le piattaforme in cui lettura-modifica-scrittura è atomica "gratis" sono rare.
Peter Cordes,

7

Ai tempi in cui i computer x86 avevano una CPU, l'uso di una singola istruzione garantiva che gli interrupt non avrebbero diviso la lettura / modifica / scrittura e che se la memoria non fosse stata utilizzata anche come buffer DMA, in effetti era atomica (e C ++ non ha menzionato i thread nello standard, quindi questo non è stato risolto).

Quando era raro avere un doppio processore (ad esempio Pentium Pro a doppio socket) sul desktop di un cliente, l'ho usato efficacemente per evitare il prefisso LOCK su una macchina single-core e migliorare le prestazioni.

Oggi sarebbe d'aiuto solo contro più thread che erano tutti impostati sulla stessa affinità della CPU, quindi i thread di cui ti preoccupavi entrerebbero in gioco solo dopo la scadenza del time lap ed eseguendo l'altro thread sulla stessa CPU (core). Questo non è realistico.

Con i moderni processori x86 / x64, la singola istruzione è suddivisa in più micro-op e inoltre la memoria e la lettura e la scrittura della memoria sono memorizzate. Quindi thread diversi in esecuzione su CPU diverse non solo vedranno questo come non atomico, ma potrebbero vedere risultati incoerenti su ciò che legge dalla memoria e ciò che presume che altri thread abbiano letto fino a quel momento: è necessario aggiungere recinzioni di memoria per ripristinare la normalità comportamento.


1
Interrupt ancora non operazioni RMW parti separate, in modo che non ancora sincronizzare un singolo filo con gestori di segnale che vengono eseguiti nello stesso thread. Naturalmente, questo funziona solo se l'asm utilizza una singola istruzione, non caricamento / modifica / memorizzazione separati. C ++ 11 potrebbe esporre questa funzionalità hardware, ma non lo fa (probabilmente perché era davvero utile solo nei kernel Uniprocessor per sincronizzarsi con i gestori di interrupt, non nello spazio utente con i gestori di segnale). Inoltre le architetture non hanno istruzioni di destinazione della memoria di lettura-modifica-scrittura. Tuttavia, potrebbe semplicemente compilare come un RMW atomico rilassato su non-x86
Peter Cordes,

Anche se, per quanto ricordo, usare il prefisso Lock non era assurdamente costoso fino a quando non arrivarono i superscaler. Quindi non c'era motivo di notare che rallentava il codice importante in un 486, anche se non era necessario per quel programma.
JDługosz,

Sì scusa! In realtà non ho letto attentamente. Ho visto l'inizio del paragrafo con le aringhe rosse sulla decodifica in upops e non ho finito di leggere per vedere cosa hai effettivamente detto. ri: 486: Penso di aver letto che il primo SMP era una specie di Compaq 386, ma la sua semantica di ordinamento della memoria non era la stessa di quella che dice attualmente l'ISA x86. Gli attuali manuali x86 potrebbero anche menzionare SMP 486. Di certo non erano comuni nemmeno in HPC (cluster Beowulf) fino ai giorni di PPro / Athlon XP, penso.
Peter Cordes,

1
@PeterCordes Ok. Certo, supponendo anche che nessun osservatore DMA / dispositivo - non rientrasse nell'area dei commenti per includere anche quello. Grazie JDługosz per l'aggiunta eccellente (risposta e commenti). Completato davvero la discussione.
Leo Heinsaar,

3
@Leo: un punto chiave che non è stato menzionato: le CPU fuori servizio riordinano le cose internamente, ma la regola d'oro è che per un singolo core , conservano l'illusione delle istruzioni che vengono eseguite una alla volta, in ordine. (E questo include gli interrupt che attivano i cambi di contesto). I valori potrebbero essere archiviati elettricamente nella memoria fuori servizio, ma il singolo core su cui tutto è in esecuzione tiene traccia di tutto il riordino che fa, per preservare l'illusione. Questo è il motivo per cui non è necessaria una barriera di memoria per l'asm equivalente di a = 1; b = a;caricare correttamente l'1 appena memorizzato.
Peter Cordes,

4

No. https://www.youtube.com/watch?v=31g0YE61PLQ (Questo è solo un link alla scena "No" di "The Office")

Sei d'accordo che questo sarebbe un possibile risultato per il programma:

uscita campione:

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

In tal caso, il compilatore è libero di rendere l' unico output possibile per il programma, in qualunque modo il compilatore lo desideri. vale a dire un main () che mette appena fuori 100s.

Questa è la regola "come se".

E indipendentemente dall'output, puoi pensare alla sincronizzazione del thread allo stesso modo: se il thread A lo fa num++; num--;e il thread B legge numripetutamente, allora una possibile interleaving valida è che il thread B non legge mai tra num++e num--. Poiché l'interleaving è valido, il compilatore è libero di renderlo l' unico interleaving possibile. E rimuovi completamente incr / decr.

Ci sono alcune implicazioni interessanti qui:

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

(ad esempio, immagina che alcuni altri thread aggiornino un'interfaccia utente della barra di avanzamento basata su progress)

Il compilatore può trasformarlo in:

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

progress += local;

probabilmente è valido. Ma probabilmente non quello che il programmatore sperava :-(

Il comitato sta ancora lavorando su queste cose. Attualmente "funziona" perché i compilatori non ottimizzano molto l'atomica. Ma questo sta cambiando.

E anche se progressfosse anche volatile, questo sarebbe comunque valido:

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

while (local--)
    progress++;

: - /


Questa risposta sembra rispondere solo alla domanda secondaria che Richard e io stavamo meditando. Abbiamo finalmente risolto è: scopre che sì, lo standard C ++ fa permettono la fusione delle operazioni sui non- volatileoggetti atomiche, quando non si rompe qualsiasi altra regola. Due documenti di discussione sugli standard discutono esattamente questo (link nel commento di Richard ), uno usando lo stesso esempio di contatore di progressi. Quindi è un problema di qualità dell'implementazione fino a quando il C ++ non standardizza i modi per prevenirlo.
Peter Cordes,

Sì, il mio "No" è in realtà una risposta all'intera linea di ragionamento. Se la domanda è semplicemente "può num ++ essere atomico su alcuni compilatori / implementazioni", la risposta è certa. Ad esempio, un compilatore potrebbe decidere di aggiungere lockad ogni operazione. O una combinazione di compilatore + uniprocessore in cui né il riordino (ovvero "i bei vecchi tempi") tutto è atomico. Ma che senso ha? Non puoi davvero fare affidamento su di esso. A meno che tu non sappia che è il sistema per cui stai scrivendo. (Anche allora, meglio sarebbe che <int> atomico non aggiungesse ulteriori operazioni su quel sistema. Quindi dovresti ancora scrivere il codice standard ...)
tony

1
Nota che And just remove the incr/decr entirely.non è del tutto giusto. È ancora un'operazione di acquisizione e rilascio num. Su x86, num++;num--potrebbe compilare solo su MFENCE, ma sicuramente non niente. (A meno che l'analisi dell'intero programma del compilatore non possa dimostrare che nulla si sincronizza con quella modifica di num, e che non importa se alcuni negozi di prima vengono ritardati fino a dopo carichi da dopo.) Ad esempio se questo era uno sblocco e ri -lock-right-use case-case, hai ancora due sezioni critiche separate (forse usando mo_relaxed), non una grande.
Peter Cordes,

@PeterCordes ah sì, d'accordo.
tony,

2

Si ma...

Atomic non è ciò che intendevi dire. Probabilmente stai chiedendo la cosa sbagliata.

L'incremento è sicuramente atomico . A meno che lo spazio di archiviazione non sia disallineato (e poiché non è stato allineato al compilatore, non lo è), è necessariamente allineato all'interno di una singola riga della cache. A corto di speciali istruzioni di streaming senza cache, ogni singola scrittura passa attraverso la cache. Le righe della cache complete vengono lette e scritte atomicamente, mai nulla di diverso.
Naturalmente, anche i dati più piccoli della cache sono scritti atomicamente (poiché la linea di cache circostante è).

È sicuro per i thread?

Questa è una domanda diversa, e ci sono almeno due buoni motivi per rispondere con un preciso "No!" .

Innanzitutto, c'è la possibilità che un altro core possa avere una copia di quella linea di cache in L1 (L2 e verso l'alto sono generalmente condivisi, ma L1 è normalmente per core!) E modifica contemporaneamente quel valore. Ovviamente ciò accade anche atomicamente, ma ora hai due valori "corretti" (correttamente, atomicamente, modificati) - quale è quello veramente corretto adesso?
La CPU lo risolverà in qualche modo, ovviamente. Ma il risultato potrebbe non essere quello che ti aspetti.

In secondo luogo, esiste un ordinamento della memoria, o una formulazione in modo diverso accade prima delle garanzie. La cosa più importante delle istruzioni atomiche non è tanto che sono atomiche . Sta ordinando.

Hai la possibilità di far valere la garanzia che tutto ciò che accade in termini di memoria sia realizzato in un ordine garantito e ben definito in cui hai una garanzia "successo prima". Questo ordine può essere "rilassato" (leggi come: nessuno) o rigoroso di cui hai bisogno.

Ad esempio, è possibile impostare un puntatore su un blocco di dati (ad esempio, i risultati di alcuni calcoli) e quindi rilasciare atomicamente il flag "dati pronti". Ora, chiunque acquisisca questa bandiera sarà indotto a pensare che il puntatore sia valido. E infatti, sarà sempre un puntatore valido, mai niente di diverso. Questo perché la scrittura sul puntatore è avvenuta prima dell'operazione atomica.


2
Il carico e il negozio sono ciascuno atomico separatamente, ma l'intera operazione di lettura-modifica-scrittura nel suo complesso è sicuramente non atomico. Le cache sono coerenti, quindi non possono mai contenere copie contrastanti della stessa riga ( en.wikipedia.org/wiki/MESI_protocol ). Un altro core non può nemmeno avere una copia di sola lettura mentre questo core ha lo stato modificato. Ciò che lo rende non atomico è che il core che fa l'RMW può perdere la proprietà della linea di cache tra il carico e l'archivio.
Peter Cordes,

2
Inoltre, no, le intere righe della cache non vengono sempre trasferite atomicamente. Vedi questa risposta , dove è dimostrato sperimentalmente che un Opteron multi-socket rende gli archivi SSE 16B non atomici trasferendo le linee cache in blocchi 8B con hypertransport, anche se sono atomici per CPU single-socket dello stesso tipo (perché il carico / store hardware ha un percorso 16B per la cache L1). x86 garantisce atomicità solo per carichi separati o immagazzina fino a 8B.
Peter Cordes,

Lasciare l'allineamento al compilatore non significa che la memoria sarà allineata sul limite di 4 byte. I compilatori possono avere opzioni o pragmi per modificare il confine di allineamento. Ciò è utile, ad esempio, per operare su dati strettamente compressi nei flussi di rete.
Dmitry Rubanovich,

2
Sofisterie, nient'altro. Un numero intero con memorizzazione automatica che non fa parte di una struttura come mostrato nell'esempio sarà assolutamente allineato correttamente. Sostenere qualcosa di diverso è semplicemente stupido. Le linee della cache e tutti i POD sono di dimensioni PoT (potenza di due) e allineati - su qualsiasi architettura non illusoria nel mondo. La matematica vuole che qualsiasi PoT correttamente allineato si inserisca esattamente in uno (mai più) di qualsiasi altro PoT della stessa dimensione o più grande. La mia affermazione è quindi corretta.
Damon,

1
@Damon, l'esempio fornito nella domanda non menziona una struttura, ma non restringe la domanda solo alle situazioni in cui gli interi non sono parti di strutture. I POD sicuramente possono avere dimensioni PoT e non essere allineati PoT. Dai un'occhiata a questa risposta per esempi di sintassi: stackoverflow.com/a/11772340/1219722 . Quindi non è quasi un "sofisma" perché i POD dichiarati in questo modo sono usati un po 'nel codice di rete nel codice della vita reale.
Dmitry Rubanovich,

2

Che l'output di un singolo compilatore, su una specifica architettura della CPU, con le ottimizzazioni disabilitate (poiché gcc non si compila nemmeno ++ a addquando l'ottimizzazione in un esempio veloce e sporco ), sembra implicare incrementando in questo modo è atomico non significa questo è conforme allo standard ( si provocherebbe un comportamento indefinito quando si tenta di accedere numa un thread) e si sbaglia comunque, perché nonadd è atomico in x86.

Nota che gli atomici (usando il lockprefisso dell'istruzione) sono relativamente pesanti su x86 ( vedi questa risposta pertinente ), ma comunque notevolmente meno di un mutex, il che non è molto appropriato in questo caso d'uso.

I seguenti risultati sono presi da clang ++ 3.8 durante la compilazione -Os.

Incrementare un int per riferimento, il modo "normale":

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

Questo si compila in:

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

Incrementare un int passato per riferimento, la via atomica:

#include <atomic>

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

Questo esempio, che non è molto più complesso del modo normale, lockaggiunge semplicemente il prefisso inclall'istruzione, ma attenzione, come precedentemente affermato, non è economico. Solo perché l'assemblaggio sembra corto non significa che sia veloce.

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

-2

Quando il compilatore utilizza solo una singola istruzione per l'incremento e la macchina è a thread singolo, il codice è sicuro. ^^


-3

Prova a compilare lo stesso codice su una macchina non x86 e vedrai rapidamente risultati di assemblaggio molto diversi.

Il motivo num++ sembra essere atomico perché nelle macchine x86, l'incremento di un numero intero a 32 bit è, di fatto, atomico (supponendo che non avvenga alcun recupero di memoria). Ma questo non è garantito dallo standard c ++, né è probabile che sia il caso su una macchina che non utilizza il set di istruzioni x86. Quindi questo codice non è sicuro per tutte le condizioni di gara.

Inoltre, non hai una forte garanzia che questo codice sia al sicuro dalle condizioni di gara, anche su un'architettura x86, perché x86 non imposta carichi e archivi in ​​memoria se non diversamente specificato. Pertanto, se più thread provano ad aggiornare questa variabile contemporaneamente, potrebbero finire per incrementare i valori memorizzati nella cache (non aggiornati)

Il motivo, quindi, che abbiamo std::atomic<int>e così via è che quando si lavora con un'architettura in cui l'atomicità dei calcoli di base non è garantita, si dispone di un meccanismo che costringerà il compilatore a generare codice atomico.


"è perché su macchine x86, l'incremento di un numero intero a 32 bit è, di fatto, atomico." puoi fornire link alla documentazione che lo prova?
Slava,

8
Non è nemmeno atomico su x86. È single-core-sicuro, ma se ci sono più core (e ce ne sono) non è affatto atomico.
Harold,

X86 è addeffettivamente garantito atomico? Non sarei sorpreso se gli incrementi del registro fossero atomici, ma non è molto utile; per rendere visibile l'incremento del registro su un altro thread, è necessario che sia in memoria, il che richiederebbe istruzioni aggiuntive per caricarlo e memorizzarlo, rimuovendo l'atomicità. La mia comprensione è che questo è il motivo lockper cui esiste il prefisso per le istruzioni; l'unico atomico utile si addapplica alla memoria senza riferimenti e utilizza il lockprefisso per garantire che la riga della cache sia bloccata per la durata dell'operazione .
ShadowRanger

@Slava @Harold @ShadowRanger Ho aggiornato la risposta. addè atomico, ma ho chiarito che ciò non implica che il codice sia sicuro per le condizioni di gara, poiché i cambiamenti non diventano immediatamente visibili a livello globale.
Xirema,

3
@Xirema che lo rende "non atomico" per definizione
harold
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.