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::atomic
per risultati affidabili, ma puoi usarlo memory_order_relaxed
se 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], 1
in 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 lock
prefisso 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 lock
prefisso, 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 lock
te otterresti copie contrastanti della stessa riga della cache. Questo non può mai accadere in un sistema con cache coerenti.
(Se lock
un'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 lock
prefisso 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 lock
prefisso. 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 num
seguito: 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=i586
lo 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 ilX 86 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é flag
non è uniforme volatile
. (E no, C ++ volatile
non è 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> foo
non è 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::atomic
variabile 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 num
sia 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_ptr
viene creata e distrutta una copia di a , se il compilatore può provare che shared_ptr
esiste 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 lock
operazioni separate anche memory_order_relaxed
nel 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
add
è atomico?