Perché volatile non è considerato utile nella programmazione C o C ++ multithread?

Come dimostrato in questa risposta che ho postato di recente, mi sembra di essere confuso circa l’utilità (o la sua mancanza) di volatile nei contesti di programmazione multi-thread.

La mia comprensione è questa: ogni volta che una variabile può essere cambiata al di fuori del stream di controllo di un pezzo di codice che vi accede, tale variabile dovrebbe essere dichiarata volatile . I gestori di segnale, i registri I / O e le variabili modificate da un altro thread costituiscono tutte queste situazioni.

Quindi, se si ha un int globale globale, e foo viene letto da un thread e impostato atomicamente da un altro thread (probabilmente usando un’istruzione di macchina appropriata), il thread di lettura vede questa situazione nello stesso modo in cui vede una variabile regolata da un segnale gestore o modificato da una condizione hardware esterna e quindi foo deve essere dichiarato volatile (o, per le situazioni multithreaded, a cui si accede con carico a memoria controllata, che è probabilmente una soluzione migliore).

Come e dove mi sbaglio?

Il problema con volatile in un contesto multithreading è che non fornisce tutte le garanzie di cui abbiamo bisogno. Ha alcune proprietà che ci servono, ma non tutte, quindi non possiamo fare affidamento solo su volatile .

Tuttavia, i primitivi che dovremmo usare per le restanti proprietà forniscono anche quelli volatile , quindi non è necessario.

Per accessi thread-safe ai dati condivisi, abbiamo bisogno di una garanzia che:

  • la lettura / scrittura in realtà accade (che il compilatore non memorizzerà semplicemente il valore in un registro e rimanderà l’aggiornamento della memoria principale fino a molto più tardi)
  • che non avviene alcun riordino. Supponiamo di utilizzare una variabile volatile come flag per indicare se alcuni dati sono pronti per essere letti. Nel nostro codice, semplicemente impostiamo la bandiera dopo aver preparato i dati, quindi tutto sembra a posto. Ma cosa succede se le istruzioni vengono riordinate in modo che la bandiera sia impostata per prima ?

volatile garantisce il primo punto. Garantisce inoltre che non si verifichi alcun riordino tra diverse letture / scritture volatili . Tutti volatile accessi alla memoria volatile si verificheranno nell’ordine in cui sono specificati. Questo è tutto ciò di cui abbiamo bisogno per ciò che volatile è destinato: manipolare i registri I / O o l’hardware mappato in memoria, ma non ci aiuta nel codice multithread in cui l’object volatile viene spesso usato solo per sincronizzare l’accesso ai dati non volatili. Questi accessi possono ancora essere riordinati rispetto a quelli volatile .

La soluzione per evitare il riordino è usare una barriera di memoria , che indica sia al compilatore che alla CPU che nessun accesso alla memoria può essere riordinato attraverso questo punto . Posizionare tali barriere attorno al nostro accesso variabile alle variabili assicura che anche gli accessi non volatili non vengano riordinati attraverso quello volatile, permettendoci di scrivere codice thread-safe.

Tuttavia, le barriere della memoria assicurano anche che tutte le letture / scritture in sospeso vengano eseguite quando viene raggiunta la barriera, quindi ci fornisce effettivamente tutto ciò di cui abbiamo bisogno da soli, rendendo inutile la volatile . Possiamo solo rimuovere completamente il qualificatore volatile .

Dal momento che C ++ 11, le variabili atomiche ( std::atomic ) ci danno tutte le garanzie rilevanti.

Si potrebbe anche prendere in considerazione questo dalla documentazione del kernel di Linux .

I programmatori C si sono spesso resi volatili nel dire che la variabile poteva essere cambiata al di fuori dell’attuale thread di esecuzione; di conseguenza, a volte sono tentati di usarlo nel codice del kernel quando vengono utilizzate strutture dati condivise. In altre parole, sono noti per trattare i tipi volatili come una sorta di variabile atomica facile, che non sono. L’uso di volatile nel codice del kernel non è quasi mai corretto; questo documento descrive perché.

Il punto chiave da comprendere per quanto riguarda la volatilità è che il suo scopo è sopprimere l’ottimizzazione, che non è quasi mai ciò che si vuole veramente fare. Nel kernel, è necessario proteggere le strutture dati condivise da accessi concorrenti indesiderati, che è un’attività molto diversa. Il processo di protezione da concorrenza indesiderata eviterà anche quasi tutti i problemi relativi all’ottimizzazione in un modo più efficiente.

Come volatile, le primitive del kernel che rendono simultaneo l’accesso ai dati sicuri (spinlock, mutex, barriere della memoria, ecc.) Sono progettate per prevenire l’ottimizzazione indesiderata. Se vengono utilizzati correttamente, non sarà necessario utilizzare anche volatili. Se volatile è ancora necessario, c’è quasi sicuramente un bug nel codice da qualche parte. Nel codice del kernel correttamente scritto, volatile può servire solo a rallentare le cose.

Si consideri un blocco tipico del codice del kernel:

 spin_lock(&the_lock); do_something_on(&shared_data); do_something_else_with(&shared_data); spin_unlock(&the_lock); 

Se tutto il codice segue le regole di blocco, il valore di shared_data non può cambiare in modo imprevisto mentre viene mantenuto the_lock. Qualsiasi altro codice che potrebbe voler giocare con quei dati sarà in attesa sul blocco. I primitivi spinlock fungono da barriere alla memoria – sono esplicitamente scritti per farlo – il che significa che gli accessi ai dati non saranno ottimizzati su di essi. Quindi il compilatore potrebbe pensare di sapere cosa sarà in shared_data, ma la chiamata a spin_lock (), poiché funge da barriera di memoria, la costringerà a dimenticare tutto ciò che sa. Non ci saranno problemi di ottimizzazione con gli accessi a quei dati.

Se shared_data fosse dichiarato volatile, il blocco sarebbe ancora necessario. Ma al compilatore verrebbe anche impedito di ottimizzare l’accesso a shared_data all’interno della sezione critica, quando sappiamo che nessun altro può lavorare con esso. Mentre il blocco è trattenuto, shared_data non è volatile. Quando si gestiscono dati condivisi, il blocco corretto rende volatili non necessari e potenzialmente dannosi.

La class di memoria volatile era originariamente pensata per i registri I / O mappati in memoria. All’interno del kernel, anche gli accessi ai registri dovrebbero essere protetti da blocchi, ma uno non vuole che il compilatore “ottimizzi” gli accessi ai registri all’interno di una sezione critica. Ma, all’interno del kernel, gli accessi alla memoria I / O vengono sempre eseguiti attraverso le funzioni accessorie; l’accesso alla memoria I / O direttamente attraverso i puntatori è disapprovato e non funziona su tutte le architetture. Questi accorgimenti sono scritti per prevenire l’ottimizzazione indesiderata, quindi, ancora una volta, non è necessario volatile.

Un’altra situazione in cui si potrebbe essere tentati di utilizzare volatile è quando il processore è occupato, in attesa del valore di una variabile. Il modo giusto per eseguire un’attesa impegnativa è:

 while (my_variable != what_i_want) cpu_relax(); 

La chiamata cpu_relax () può ridurre il consumo di energia della CPU o cedere a un processore gemellato hyperthreaded; capita anche che serva da barriera di memoria, quindi, ancora una volta, non è necessario volatile. Naturalmente, l’attesa-attesa è generalmente un atto antisociale per cominciare.

Ci sono ancora alcune rare situazioni in cui la volatilità ha un senso nel kernel:

  • Le funzioni di accesso sopra menzionate potrebbero utilizzare volatile su architetture in cui l’accesso diretto alla memoria I / O funziona. In sostanza, ogni chiamata di accesso diventa una piccola parte critica e garantisce che l’accesso avvenga come previsto dal programmatore.

  • Il codice assembly inline che cambia la memoria, ma che non ha altri effetti collaterali visibili, rischia di essere eliminato da GCC. L’aggiunta della parola chiave volatile alle istruzioni asm impedirà questa rimozione.

  • La variabile jiffies è speciale in quanto può avere un valore diverso ogni volta che viene referenziata, ma può essere letta senza alcun blocco speciale. Quindi i jiffies possono essere volatili, ma l’aggiunta di altre variabili di questo tipo è fortemente disapprovata. Jiffies è considerato un problema di “stupidi legacy” (le parole di Linus) a questo riguardo; sistemarlo sarebbe più difficile di quanto valga.

  • Puntatori a strutture di dati in una memoria coerente che potrebbero essere modificate dai dispositivi I / O possono, a volte, essere volatili legittimamente. Un buffer circolare utilizzato da una scheda di rete, in cui tale adattatore modifica i puntatori per indicare quali descrittori sono stati elaborati, è un esempio di questo tipo di situazione.

Per la maggior parte del codice, non si applica nessuna delle suddette giustificazioni per volatile. Di conseguenza, è probabile che l’uso di volatile venga visto come un bug e porterà ulteriore controllo al codice. Gli sviluppatori che sono tentati di usare volatili dovrebbero fare un passo indietro e pensare a cosa stanno veramente cercando di realizzare.

Non penso che tu abbia torto: volatile è necessario per garantire che il thread A veda il valore cambiare, se il valore è cambiato da qualcosa di diverso dal thread A. Come ho capito, volatile è fondamentalmente un modo per dire al compilatore “non memorizzare nella cache questa variabile in un registro, assicurati di leggere sempre / scriverlo dalla memoria RAM ad ogni accesso”.

La confusione è dovuta al fatto che volatile non è sufficiente per implementare una serie di cose. In particolare, i sistemi moderni utilizzano più livelli di memorizzazione nella cache, le moderne CPU multi-core eseguono alcune ottimizzazioni fantasiose in fase di esecuzione e i compilatori moderni eseguono alcune ottimizzazioni fantasiose in fase di compilazione, e tutti questi possono portare a vari effetti collaterali visualizzati in un diverso ordina dall’ordine che ti aspetteresti se guardassi il codice sorgente.

Quindi volatile va bene, purché si tenga presente che i cambiamenti “osservati” nella variabile volatile potrebbero non verificarsi nel momento esatto in cui si pensa che lo faranno. In particolare, non provare a utilizzare variabili volatili come metodo per sincronizzare o ordinare le operazioni tra thread, perché non funzionerà in modo affidabile.

Personalmente, il mio uso principale (solo?) Per la bandiera volatile è come booleano “pleaseGoAwayNow”. Se ho un thread di lavoro che va in loop continuamente, lo farò controllare il booleano volatile su ogni iterazione del ciclo, e uscire se il booleano è sempre vero. Il thread principale può quindi pulire in modo sicuro il thread di lavoro impostando il valore booleano su true, quindi chiamando pthread_join () per attendere fino a quando il thread worker non è terminato.

La tua comprensione è davvero sbagliata.

La proprietà, che le variabili volatili hanno, è “legge e scrive su questa variabile fa parte del comportamento percepibile del programma”. Ciò significa che questo programma funziona (dato l’hardware appropriato):

 int volatile* reg=IO_MAPPED_REGISTER_ADDRESS; *reg=1; // turn the fuel on *reg=2; // ignition *reg=3; // release int x=*reg; // fire missiles 

Il problema è che questa non è la proprietà che vogliamo dal thread-safe.

Ad esempio, un contatore thread-safe sarebbe solo (codice simile a Linux-kernel, non si conosce l’equivalente c ++ 0x):

 atomic_t counter; ... atomic_inc(&counter); 

Questo è atomico, senza una barriera di memoria. Dovresti aggiungerli se necessario. L’aggiunta di volatili non sarebbe probabilmente d’aiuto, perché non riguarderebbe l’accesso al codice vicino (ad esempio l’aggiunta di un elemento alla lista che il contatore sta contando). Certamente, non è necessario vedere il contatore incrementato al di fuori del programma, e le ottimizzazioni sono comunque auspicabili, ad es.

 atomic_inc(&counter); atomic_inc(&counter); 

può ancora essere ottimizzato per

 atomically { counter+=2; } 

se l’ottimizzatore è abbastanza intelligente (non cambia la semantica del codice).

volatile è utile (anche se insufficiente) per implementare il costrutto di base di un mutex spinlock, ma una volta ottenuto ciò (o qualcosa di superiore), non è necessario un altro volatile .

Il modo tipico di programmazione multithread non è quello di proteggere ogni variabile condivisa a livello di macchina, ma piuttosto di introdurre variabili di guardia che guidano il stream del programma. Invece di volatile bool my_shared_flag; avresti dovuto

 pthread_mutex_t flag_guard_mutex; // contains something volatile bool my_shared_flag; 

Non solo questo incapsula la “parte difficile”, è fondamentalmente necessario: C non include le operazioni atomiche necessarie per implementare un mutex; è solo volatile fare delle garanzie extra sulle operazioni ordinarie .

Ora hai qualcosa di simile a questo:

 pthread_mutex_lock( &flag_guard_mutex ); my_local_state = my_shared_flag; // critical section pthread_mutex_unlock( &flag_guard_mutex ); pthread_mutex_lock( &flag_guard_mutex ); // may alter my_shared_flag my_shared_flag = ! my_shared_flag; // critical section pthread_mutex_unlock( &flag_guard_mutex ); 

my_shared_flag non ha bisogno di essere volatile, nonostante sia non memorabile, perché

  1. Un altro thread ha accesso ad esso.
  2. Significa che un riferimento ad esso deve essere stato preso qualche volta (con l’operatore & ).
    • (O è stato fatto riferimento a una struttura di contenimento)
  3. pthread_mutex_lock è una funzione di libreria.
  4. Il che significa che il compilatore non può sapere se pthread_mutex_lock qualche modo acquisisce tale riferimento.
  5. Il che significa che il compilatore deve assumere che pthread_mutex_lock modifichi la bandiera condivisa !
  6. Quindi la variabile deve essere ricaricata dalla memoria. volatile , sebbene significativo in questo contesto, è estraneo.

Affinché i tuoi dati siano coerenti in un ambiente concorrente, devi applicare due condizioni:

1) Atomicità cioè se leggo o scrivo alcuni dati in memoria, allora i dati vengono letti / scritti in un unico passaggio e non possono essere interrotti o contesi a causa ad esempio di un interruttore di contesto

2) Coerenza cioè l’ordine delle operazioni di lettura / scrittura deve essere visto come uguale tra più ambienti concorrenti – sia che i thread, le macchine ecc.

volatile non si adatta a nessuno dei precedenti – o più particolarmente, lo standard c o c ++ su come dovrebbe comportarsi volatile non include nessuno dei precedenti.

In pratica è persino peggio dato che alcuni compilatori (come il compilatore Itanium di intel) tentano di implementare alcuni elementi di comportamento sicuro di accesso simultaneo (ad esempio assicurando le recinzioni di memoria), tuttavia non c’è coerenza tra le implementazioni del compilatore e inoltre lo standard non richiede questo dell’attuazione in primo luogo.

Contrassegnare una variabile come volatile significherebbe solo che stai forzando il valore a essere scaricato dalla e dalla memoria ogni volta che, in molti casi, rallenta il tuo codice in quanto hai praticamente fatto saltare le prestazioni della cache.

c # e java AFAIK risolvono questo facendo aderire volatile a 1) e 2) tuttavia lo stesso non può essere detto per i compilatori c / c ++, quindi basterà farlo come meglio credi.

Per una discussione più approfondita (anche se non imparziale) sull’argomento leggi questo

Le domande frequenti su comp.programming.threads hanno una spiegazione classica di Dave Butenhof:

Q56: Perché non ho bisogno di dichiarare variabili condivise VOLATILE?

Sono interessato, tuttavia, ai casi in cui sia la libreria del compilatore che quella dei thread soddisfano le rispettive specifiche. Un compilatore C conforms può allocare globalmente una variabile condivisa (non volatile) a un registro che viene salvato e ripristinato mentre la CPU viene passata da thread a thread. Ogni thread avrà il proprio valore privato per questa variabile condivisa, che non è ciò che vogliamo da una variabile condivisa.

In un certo senso questo è vero, se il compilatore conosce abbastanza i rispettivi ambiti della variabile e le funzioni pthread_cond_wait (o pthread_mutex_lock). In pratica, la maggior parte dei compilatori non tenterà di conservare le copie dei registri dei dati globali attraverso una chiamata a una funzione esterna, perché è troppo difficile sapere se la routine potrebbe in qualche modo avere accesso all’indirizzo dei dati.

Quindi sì, è vero che un compilatore conforms rigorosamente (ma molto aggressivo) a ANSI C potrebbe non funzionare con più thread senza volatilità. Ma qualcuno dovrebbe sistemarlo meglio. Poiché qualsiasi SYSTEM (ovvero pragmaticamente una combinazione di kernel, librerie e compilatore C) che non fornisce le garanzie di coerenza della memoria POSIX non CONFORME allo standard POSIX. Periodo. Il sistema NON PU require richiedere l’utilizzo di variabili volatili su variabili condivise per un comportamento corretto, poiché POSIX richiede solo che siano necessarie le funzioni di sincronizzazione POSIX.

Quindi se il tuo programma si interrompe perché non hai usato volatile, questo è un BUG. Potrebbe non essere un bug in C, o un bug nella libreria dei thread, o un bug nel kernel. Ma è un bug di SISTEMA e uno o più di quei componenti dovranno lavorare per risolverlo.

Non si vuole usare volatile, perché, su qualsiasi sistema in cui possa fare la differenza, sarà molto più costoso di una variabile non volatile corretta. (ANSI C richiede “punti di sequenza” per le variabili volatili a ciascuna espressione, mentre POSIX li richiede solo alle operazioni di sincronizzazione: un’applicazione filettata ad uso intensivo vedrà sostanzialmente più attività di memoria usando volatile e, dopo tutto, è l’attività di memoria che davvero rallenta.)

/ — [Dave Butenhof] ———————– [[email protected]] — \
| Digital Equipment Corporation 110 Spit Brook Rd ZKO2-3 / Q18 |
| 603.881.2218, FAX 603.881.0120 Nashua NH 03062-2698 |
—————– [Better Living Through Concurrency] —————- /

Il signor Butenhof copre molto dello stesso terreno in questo post usenet :

L’uso di “volatile” non è sufficiente per garantire una visibilità della memoria adeguata o la sincronizzazione tra i thread. L’uso di un mutex è sufficiente e, tranne che ricorrendo a varie alternative di codici macchina non portatili (o più sottili implicazioni delle regole di memoria POSIX che sono molto più difficili da applicare in generale, come spiegato nel mio post precedente), un il mutex è NECESSARIO.

Pertanto, come ha spiegato Bryan, l’uso di volatile non fa altro che impedire al compilatore di fare ottimizzazioni utili e desiderabili, senza fornire alcun aiuto nel rendere il codice “thread safe”. Ovviamente, è naturale dichiarare qualsiasi cosa tu voglia come “volatile”: è un attributo di archiviazione ANSI C legale, dopotutto. Non aspettarti che risolva alcun problema di sincronizzazione dei thread per te.

Tutto ciò è ugualmente applicabile a C ++.

In base al mio vecchio standard C, “Ciò che costituisce un accesso a un object che ha un tipo volatile-qualificato è definito dall’implementazione” . Quindi gli scrittori di compilatori C avrebbero potuto scegliere “volatile” per “accesso sicuro ai thread in un ambiente multi-processo” . Ma loro no.

Invece, le operazioni necessarie per rendere sicuro un thread di sezione critico in un ambiente di memoria condivisa multi-processo multi-core sono state aggiunte come nuove funzionalità definite dall’implementazione. E, liberato dal requisito che “volatile” avrebbe fornito accesso atomico e accesso agli ordini in un ambiente multi-processo, gli scrittori del compilatore hanno dato priorità alla riduzione del codice rispetto alla semantica “volatile” dipendente dall’implementazione storica.

Ciò significa che cose come i semafori “volatili” attorno a sezioni di codice critiche, che non funzionano su nuovo hardware con nuovi compilatori, potrebbero aver funzionato una volta con vecchi compilatori su hardware vecchio, e vecchi esempi a volte non sono sbagliati, solo vecchi.

Questo è tutto ciò che “volatile” sta facendo: “Ehi compilatore, questa variabile potrebbe cambiare A QUALSIASI MOMENTO (su qualsiasi tick dell’orologio) anche se non ci sono ISTRUZIONI LOCALI che agiscono su di esso. NON memorizzare questo valore in un registro.”

Questo è IT. Indica al compilatore che il tuo valore è, beh, volatile: questo valore può essere modificato in qualsiasi momento dalla logica esterna (un altro thread, un altro processo, il Kernel, ecc.). Esiste più o meno esclusivamente per sopprimere le ottimizzazioni del compilatore che memorizzeranno automaticamente un valore in un registro che è intrinsecamente pericoloso per la cache EVER.

Potresti imbatterti in articoli come “Dr. Dobbs” che sono volatili come una panacea per la programmazione multi-thread. Il suo approccio non è totalmente privo di merito, ma ha il difetto fondamentale di rendere gli utenti di un object responsabili della sicurezza dei thread, che tende ad avere gli stessi problemi di altre violazioni dell’incapsulamento.