In C ++ 11, normalmente non utilizzare mai volatile
per il threading, solo per MMIO
Ma TL: DR, "funziona" in qualche modo come su un atomico con mo_relaxed
hardware con cache coerenti (cioè tutto); è sufficiente impedire ai compilatori di mantenere i registri nei vari registri. atomic
non servono barriere di memoria per creare atomicità o visibilità inter-thread, solo per far sì che il thread corrente attenda prima / dopo un'operazione per creare un ordine tra gli accessi di questo thread a diverse variabili. mo_relaxed
non ha mai bisogno di barriere, basta caricare, archiviare o RMW.
Per gli atomici roll-your-own con volatile
(e inline-asm per barriere) nei brutti vecchi tempi prima di C ++ 11 std::atomic
, volatile
era l'unico modo buono per far funzionare alcune cose . Ma dipendeva da molte ipotesi su come funzionavano le implementazioni e non era mai garantito da alcuno standard.
Ad esempio, il kernel Linux utilizza ancora la propria atomica a rotazione manuale con volatile
, ma supporta solo alcune implementazioni C specifiche (GNU C, clang e forse ICC). In parte è a causa delle estensioni GNU C e della sintassi asm inline e della semantica, ma anche perché dipende da alcuni presupposti su come funzionano i compilatori.
È quasi sempre la scelta sbagliata per i nuovi progetti; puoi usare std::atomic
(with std::memory_order_relaxed
) per ottenere un compilatore per emettere lo stesso codice macchina efficiente che potresti avere volatile
. std::atomic
con mo_relaxed
obsoletes volatile
per scopi di threading. (tranne forse per aggirare i bug di ottimizzazione persi con atomic<double>
alcuni compilatori .)
L'implementazione interna di std::atomic
compilatori tradizionali (come gcc e clang) non si limita a utilizzare volatile
internamente; i compilatori espongono direttamente le funzioni integrate di caricamento atomico, archivio e RMW. (es. built -in GNU C__atomic
che operano su oggetti "semplici").
Volatile è utilizzabile in pratica (ma non farlo)
Detto questo, volatile
è utilizzabile in pratica per cose come una exit_now
bandiera su tutte le (?) Implementazioni C ++ esistenti su CPU reali, a causa del modo in cui funzionano le CPU (cache coerenti) e condivide ipotesi su come volatile
dovrebbe funzionare. Ma non molto altro, e non è raccomandato. Lo scopo di questa risposta è spiegare come funzionano effettivamente le CPU e le implementazioni C ++ esistenti. Se non ti interessa, tutto quello che devi sapere è che std::atomic
con i viola mo_relax volatile
per il threading.
(Lo standard ISO C ++ è piuttosto vago su di esso, solo dicendo che gli volatile
accessi dovrebbero essere valutati rigorosamente secondo le regole della macchina astratta C ++, non ottimizzati lontano. Dato che le implementazioni reali usano lo spazio di indirizzi di memoria della macchina per modellare lo spazio di indirizzi C ++, questo significa che le volatile
letture e le assegnazioni devono essere compilate per caricare / memorizzare le istruzioni per accedere alla rappresentazione dell'oggetto in memoria.)
Come sottolinea un'altra risposta, un exit_now
flag è un semplice caso di comunicazione tra thread che non necessita di sincronizzazione : non sta pubblicando che i contenuti dell'array sono pronti o qualcosa del genere. Solo un negozio notato prontamente da un carico non ottimizzato in un altro thread.
// global
bool exit_now = false;
// in one thread
while (!exit_now) { do_stuff; }
// in another thread, or signal handler in this thread
exit_now = true;
Senza volatile o atomico, la regola as-if e l'assunzione di nessun UB data-race consente a un compilatore di ottimizzarlo in asm che controlla il flag solo una volta , prima di entrare (o meno) in un loop infinito. Questo è esattamente ciò che accade nella vita reale per i veri compilatori. (E di solito ottimizza molto do_stuff
perché il ciclo non esce mai, quindi qualsiasi codice successivo che potrebbe aver usato il risultato non è raggiungibile se entriamo nel ciclo).
// Optimizing compilers transform the loop into asm like this
if (!exit_now) { // check once before entering loop
while(1) do_stuff; // infinite loop
}
Il programma di multithreading bloccato in modalità ottimizzata ma funziona normalmente in -O0 è un esempio (con descrizione dell'output asm di GCC) di come esattamente ciò accada con GCC su x86-64. Anche la programmazione MCU - l'ottimizzazione O ++ C ++ si interrompe mentre si esegue il loop su electronics.SE mostra un altro esempio.
Normalmente desideriamo ottimizzazioni aggressive che CSE e paranco caricano dai loop, anche per le variabili globali.
Prima di C ++ 11, volatile bool exit_now
c'era un modo per farlo funzionare come previsto (su normali implementazioni C ++). Ma in C ++ 11, UB data-race si applica ancora, volatile
quindi in realtà non è garantito dallo standard ISO per funzionare ovunque, anche assumendo cache coerenti HW.
Si noti che per i tipi più ampi, volatile
non garantisce la mancanza di lacerazione. Ho ignorato questa distinzione qui bool
perché è un problema sulle normali implementazioni. Ma questo è anche parte del motivo per cui volatile
è ancora soggetto a UB data-race anziché essere equivalente a atomico rilassato.
Si noti che "come previsto" non significa che il thread in exit_now
attesa attende che l'altro thread esca effettivamente. O anche che attende che il exit_now=true
negozio volatile sia persino visibile a livello globale prima di continuare con le operazioni successive in questo thread. ( atomic<bool>
con il valore predefinito mo_seq_cst
lo farebbe attendere prima di caricare seq_cst in un secondo momento. In molti ISA otterresti una barriera completa dopo il negozio).
C ++ 11 fornisce un modo non UB che compila lo stesso
Un flag "continua a correre" o "esci adesso" dovrebbe essere usato std::atomic<bool> flag
conmo_relaxed
utilizzando
flag.store(true, std::memory_order_relaxed)
while( !flag.load(std::memory_order_relaxed) ) { ... }
ti darà esattamente la stessa domanda (senza costose istruzioni sulla barriera) da cui potresti ottenere volatile flag
.
Oltre a non strappare, atomic
ti dà anche la possibilità di archiviare in un thread e caricarlo in un altro senza UB, quindi il compilatore non può sollevare il carico da un loop. (L'assunzione di nessun UB di data-race è ciò che consente le ottimizzazioni aggressive che desideriamo per gli oggetti non volatili non atomici.) Questa caratteristica atomic<T>
è praticamente la stessa di quella volatile
dei carichi puri e dei negozi puri.
atomic<T>
anche fare +=
e così via in operazioni atomiche RMW (significativamente più costose di un carico atomico in un temporaneo, operare, quindi un negozio atomico separato. Se non si desidera un RMW atomico, scrivere il codice con un temporaneo locale).
Con l' seq_cst
ordinamento predefinito che otterresti while(!flag)
, aggiunge anche garanzie d'ordine. accessi non atomici e ad altri accessi atomici.
(In teoria, lo standard ISO C ++ non esclude l'ottimizzazione in fase di compilazione della Atomics. Ma in pratica compilatori non lo fanno perché non c'è modo di controllo quando non sarebbe ok. Ci sono alcuni casi in cui addirittura volatile atomic<T>
non potrebbe avere abbastanza controllo sull'ottimizzazione dell'atomica se i compilatori hanno ottimizzato, quindi per ora i compilatori no. Vedi Perché i compilatori non uniscono std ridondanti :: scritture atomiche? Nota che wg21 / p0062 consiglia di non utilizzare volatile atomic
nel codice corrente per evitare l'ottimizzazione di Atomics.)
volatile
funziona davvero per questo su CPU reali (ma ancora non lo usa)
anche con modelli di memoria debolmente ordinati (non x86) . Ma in realtà non usarlo, l'uso atomic<T>
con mo_relaxed
invece !! Il punto di questa sezione è quello di affrontare le idee sbagliate su come funzionano le CPU reali, non per giustificare volatile
. Se stai scrivendo codice senza blocco, probabilmente ti preoccupi delle prestazioni. Comprendere le cache e i costi della comunicazione tra thread è generalmente importante per una buona prestazione.
Le CPU reali hanno cache / memoria condivisa coerenti: dopo che un archivio da un core diventa visibile a livello globale, nessun altro core può caricare un valore non aggiornato. (Vedi anche Myths Programmersatomic<T>
Crede nella cache della CPU che parla di volatili Java, equivalente a C ++ con ordine di memoria seq_cst.)
Quando dico load , intendo un'istruzione asm che accede alla memoria. Questo è ciò che volatile
garantisce un accesso, e non è la stessa cosa della conversione da lvalue a rvalue di una variabile C ++ non atomica / non volatile. (es. local_tmp = flag
o while(!flag)
).
L'unica cosa che devi sconfiggere sono le ottimizzazioni in fase di compilazione che non si ricaricano affatto dopo il primo controllo. Qualsiasi carico + controllo su ogni iterazione è sufficiente, senza alcun ordine. Senza sincronizzazione tra questo thread e il thread principale, non è significativo parlare di quando si è verificato esattamente l'archivio o dell'ordinamento del carico wrt. altre operazioni nel loop. Solo quando è visibile a questo thread è ciò che conta. Quando vedi il flag exit_now impostato, esci. La latenza inter-core su un tipico Xeon x86 può essere qualcosa come 40 ns tra nuclei fisici separati .
In teoria: thread C ++ su hardware senza cache coerenti
Non vedo in alcun modo che ciò potrebbe essere remotamente efficiente, con solo ISO C ++ puro senza richiedere al programmatore di eseguire flush espliciti nel codice sorgente.
In teoria potresti avere un'implementazione C ++ su una macchina che non era così, richiedendo flush espliciti generati dal compilatore per rendere le cose visibili ad altri thread su altri core . (O per le letture di non usare una copia forse stantia). Lo standard C ++ non lo rende impossibile, ma il modello di memoria di C ++ è progettato per essere efficiente su macchine a memoria condivisa coerenti. Ad esempio lo standard C ++ parla anche di "coerenza lettura-lettura", "coerenza scrittura-lettura", ecc. Una nota nello standard indica anche la connessione all'hardware:
http://eel.is/c++draft/intro.races#19
[Nota: i quattro requisiti di coerenza precedenti impediscono efficacemente il riordino del compilatore delle operazioni atomiche su un singolo oggetto, anche se entrambe le operazioni sono carichi rilassati. Ciò rende effettivamente la garanzia di coerenza della cache fornita dalla maggior parte dell'hardware disponibile per le operazioni atomiche C ++. - nota finale]
Non esiste un meccanismo per un release
negozio di svuotare solo se stesso e alcuni intervalli di indirizzi selezionati: dovrebbe sincronizzare tutto perché non saprebbe quali altri thread potrebbero voler leggere se il loro carico di acquisizione vedesse questo negozio di rilascio (formando un sequenza di rilascio che stabilisce una relazione accada prima tra i thread, garantendo che le operazioni non atomiche precedenti eseguite dal thread di scrittura siano ora sicure da leggere. A meno che non le scriva ulteriormente dopo il negozio di rilascio ...) O i compilatori avrebbero per essere davvero intelligente per dimostrare che solo alcune righe della cache necessitavano di svuotamento.
Correlati: la mia risposta su Mov + mfence è sicuro su NUMA? entra nel dettaglio della non esistenza di sistemi x86 senza una memoria condivisa coerente. Anche correlato: carica e archivia i riordini su ARM per ulteriori informazioni sui carichi / depositi nella stessa posizione.
Penso che ci siano cluster con memoria condivisa non coerente, ma non sono macchine a sistema singolo. Ogni dominio di coerenza esegue un kernel separato, quindi non è possibile eseguire thread di un singolo programma C ++ su di esso. Invece si eseguono istanze separate del programma (ognuna con il proprio spazio di indirizzi: i puntatori in un'istanza non sono validi nell'altra).
Per indurli a comunicare tra loro tramite flush espliciti, in genere si utilizza MPI o altre API di passaggio messaggi per fare in modo che il programma specifichi quali intervalli di indirizzi devono essere scaricati.
L'hardware reale non corre std::thread
oltre i limiti di coerenza della cache:
Esistono alcuni chip ARM asimmetrici, con spazio di indirizzi fisici condiviso ma non domini di cache condivisibili all'interno. Quindi non coerente. (ad es. thread di commento un core A8 e un Cortex-M3 come TI Sitara AM335x).
Ma kernel diversi verrebbero eseguiti su quei core, non una singola immagine di sistema in grado di eseguire thread su entrambi i core. Non sono a conoscenza di implementazioni C ++ che eseguono std::thread
thread tra i core della CPU senza cache coerenti.
Per ARM in particolare, GCC e clang generano codice presupponendo che tutti i thread vengano eseguiti nello stesso dominio condivisibile all'interno. In effetti, dice il manuale ISA ARMv7
Questa architettura (ARMv7) è scritta con l'aspettativa che tutti i processori che utilizzano lo stesso sistema operativo o hypervisor siano nello stesso dominio di condivisibilità condivisibile interno
Quindi la memoria condivisa non coerente tra domini separati è solo una cosa per l'uso esplicito specifico del sistema delle aree di memoria condivisa per la comunicazione tra processi diversi sotto kernel diversi.
Vedi anche questa discussione CoreCLR sul code-gen usando le barriere di memoria dmb ish
(Inner Shareable barrier) vs. dmb sy
(System) in quel compilatore.
Faccio l'affermazione che nessuna implementazione C ++ per altri ISA viene eseguita std::thread
su core con cache non coerenti. Non ho la prova che tale implementazione non esiste, ma sembra altamente improbabile. A meno che tu non stia prendendo di mira uno specifico pezzo esotico di HW che funziona in questo modo, il tuo pensiero sulle prestazioni dovrebbe assumere la coerenza della cache simile a MESI tra tutti i thread. (Preferibilmente usare atomic<T>
in modo tale da garantire la correttezza!)
Le cache coerenti lo rendono semplice
Ma su un sistema multi-core con cache coerenti, implementare un release-store significa semplicemente ordinare il commit nella cache per i negozi di questo thread, senza eseguire alcun flush esplicito. ( https://preshing.com/20120913/acquire-and-release-semantics/ e https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/ ). (E un carico di acquisizione significa ordinare l'accesso alla cache nell'altro core).
Un'istruzione di barriera della memoria blocca solo i carichi e / o gli archivi del thread corrente fino allo svuotamento del buffer di archiviazione; ciò accade sempre il più velocemente possibile da solo. ( Una barriera di memoria garantisce che la coerenza della cache sia stata completata? Risolve questo malinteso). Quindi, se non hai bisogno di ordinare, basta richiedere visibilità in altri thread, mo_relaxed
va bene. (E così èvolatile
, ma non farlo.)
Vedi anche mappature C / C ++ 11 ai processori
Curiosità: su x86, ogni asm store è un release-store perché il modello di memoria x86 è sostanzialmente seq-cst più un buffer di archivio (con inoltro di archivio).
Relativo ai semi: buffer di archiviazione, visibilità globale e coerenza: C ++ 11 garantisce pochissimo. La maggior parte degli ISA reali (tranne PowerPC) garantisce che tutti i thread possano concordare l'ordine di un aspetto di due negozi con altri due thread. (Nella terminologia del modello di memoria formale dell'architettura del computer, sono "multi-copia atomica").
Un altro malinteso è che le istruzioni recinzione memoria ASM sono necessarie per svuotare il buffer deposito per altri nuclei di vedere i nostri negozi a tutti . In realtà il buffer di archivio cerca sempre di svuotarsi (eseguire il commit nella cache L1d) il più velocemente possibile, altrimenti si riempirebbe e fermerebbe l'esecuzione. Ciò che fa una barriera / recinzione completa è bloccare il thread corrente fino a quando il buffer del negozio non viene svuotato , quindi i nostri carichi successivi compaiono nell'ordine globale dopo i nostri negozi precedenti.
(Il modello di memoria asm fortemente ordinato di x86 significa che volatile
su x86 potrebbe finire per darti più vicino mo_acq_rel
, tranne per il fatto che il riordino in fase di compilazione con variabili non atomiche può ancora accadere. Ma la maggior parte dei non-x86 ha modelli di memoria debolmente ordinati così volatile
e relaxed
sono circa debole come mo_relaxed
consente.)