"Volatile" garantisce qualcosa nel codice C portatile per sistemi multi-core?


12

Dopo aver guardato un sacco di altre domande e le loro risposte , ho l'impressione che non c'è accordo diffusa su ciò che la parola chiave "volatile" in C significa esattamente.

Anche lo standard stesso non sembra essere abbastanza chiaro da consentire a tutti di concordare sul significato .

Tra gli altri problemi:

  1. Sembra fornire diverse garanzie a seconda dell'hardware e in base al compilatore.
  2. Colpisce le ottimizzazioni del compilatore ma non le ottimizzazioni hardware, quindi su un processore avanzato che esegue le proprie ottimizzazioni di runtime, non è nemmeno chiaro se il compilatore può impedire qualsiasi ottimizzazione che si desidera impedire. (Alcuni compilatori generano istruzioni per impedire alcune ottimizzazioni hardware su alcuni sistemi, ma questo non sembra essere standardizzato in alcun modo.)

Per riassumere il problema, sembra (dopo aver letto molto) che "volatile" garantisce qualcosa del tipo: Il valore verrà letto / scritto non solo da / verso un registro, ma almeno nella cache L1 del core, nello stesso ordine in cui le letture / scritture appaiono nel codice. Ma questo sembra inutile, dal momento che leggere / scrivere da / verso un registro è già sufficiente all'interno dello stesso thread, mentre il coordinamento con la cache L1 non garantisce nulla di più riguardo al coordinamento con altri thread. Non riesco a immaginare quando potrebbe mai essere importante sincronizzare solo con la cache L1.

USO 1
L'unico uso ampiamente concordato di volatile sembra essere per sistemi vecchi o incorporati in cui determinate posizioni di memoria sono mappate hardware su funzioni I / O, come un po 'nella memoria che controlla (direttamente, nell'hardware) una luce o un po 'in memoria che indica se un tasto della tastiera è inattivo o meno (perché è collegato dall'hardware direttamente al tasto).

Sembra che "use 1" non si verifichi nel codice portatile i cui target includono sistemi multi-core.

USE 2
Non troppo diverso da "use 1" è la memoria che può essere letta o scritta in qualsiasi momento da un gestore di interrupt (che potrebbe controllare una luce o memorizzare informazioni da un tasto). Ma già per questo abbiamo il problema che, a seconda del sistema, il gestore di interrupt potrebbe funzionare su un core diverso con la propria cache di memoria e "volatile" non garantisce la coerenza della cache su tutti i sistemi.

Quindi "usa 2" sembra essere al di là di ciò che "volatile" può offrire.

USO 3
L'unico altro uso indiscusso che vedo è prevenire un'errata ottimizzazione degli accessi tramite variabili diverse che puntano alla stessa memoria che il compilatore non capisce è la stessa memoria. Ma questo è probabilmente solo indiscusso perché le persone non ne parlano - ne ho visto solo una menzione. E pensavo che lo standard C avesse già riconosciuto che puntatori "diversi" (come argomenti diversi a una funzione) potessero puntare allo stesso oggetto o elementi vicini, e già specificato che il compilatore doveva produrre codice che funzionasse anche in questi casi. Tuttavia, non sono riuscito a trovare rapidamente questo argomento nell'ultimo standard (500 pagine!).

Quindi "usa 3" forse non esiste affatto?

Da qui la mia domanda:

"Volatile" garantisce qualcosa nel codice C portatile per sistemi multi-core?


EDIT - aggiorna

Dopo aver sfogliato lo standard più recente , sembra che la risposta sia almeno un sì molto limitato:
1. Lo standard specifica ripetutamente un trattamento speciale per il tipo specifico "volatile sig_atomic_t". Tuttavia, lo standard afferma anche che l'uso della funzione del segnale in un programma multi-thread comporta un comportamento indefinito. Quindi questo caso d'uso sembra limitato alla comunicazione tra un programma a thread singolo e il suo gestore di segnale.
2. Lo standard specifica inoltre un chiaro significato di "volatile" in relazione a setjmp / longjmp. (Esempio di codice in cui è importante è riportato in altre domande e risposte .)

Quindi la domanda più precisa diventa:
"volatile" garantisce qualcosa nel codice C portatile per sistemi multi-core, tranne (1) che consente a un programma a thread singolo di ricevere informazioni dal suo gestore di segnale, o (2) che consente setjmp codice per vedere le variabili modificate tra setjmp e longjmp?

Questa è ancora una domanda sì / no.

Se "sì", sarebbe bello se si potesse mostrare un esempio di codice portatile privo di bug che diventa difettoso se si omette "volatile". Se "no", suppongo che un compilatore sia libero di ignorare "volatile" al di fuori di questi due casi molto specifici, per target multi-core.


3
I segnali esistono in C portatile; che dire di una variabile globale che viene aggiornata da un gestore di segnale? Questo dovrebbe essere volatilequello di informare il programma che potrebbe cambiare in modo asincrono.
Nate Eldredge,

2
@NateEldredge Global, sebbene volatile da solo, non è abbastanza buono. Deve essere anche atomico.
Eugene Sh.

@EugeneSh .: Sì, certo. Ma la domanda attuale riguarda in volatileparticolare, che credo sia necessaria.
Nate Eldredge,

" mentre il coordinamento con la cache L1 non garantisce nulla di più riguardo al coordinamento con altri thread " Dov'è "il coordinamento con la cache L1" non è sufficiente per comunicare con altri thread?
curioso

1
Forse pertinente, proposta C ++ per deprecare la volatilità , la proposta affronta molte delle preoccupazioni che sollevi qui, e forse il suo risultato sarà influente per il comitato C
MM

Risposte:


1

Per riassumere il problema, sembra (dopo aver letto molto) che "volatile" garantisce qualcosa del tipo: Il valore verrà letto / scritto non solo da / verso un registro, ma almeno nella cache L1 del core, nello stesso ordine in cui le letture / scritture appaiono nel codice .

No, assolutamente no . E ciò rende la volatile quasi inutile ai fini del codice MT sicuro.

In tal caso, la volatile sarebbe abbastanza buona per le variabili condivise da più thread poiché ordinare gli eventi nella cache L1 è tutto ciò che devi fare nella CPU tipica (ovvero multi-core o multi-CPU sulla scheda madre) in grado di cooperare in un modo che rende possibile una normale implementazione del multithreading C / C ++ o Java con i costi previsti tipici (vale a dire, non un costo enorme per la maggior parte delle operazioni di mutex atomiche o senza contenuto).

Ma volatile non fornisce alcun ordinamento garantito (o "visibilità della memoria") nella cache né in teoria né in pratica.

(Nota: quanto segue si basa sulla solida interpretazione dei documenti standard, sull'intento dello standard, sulla pratica storica e su una profonda comprensione delle aspettative degli autori del compilatore. Questo approccio si basa sulla storia, sulle pratiche effettive e sulle aspettative e la comprensione delle persone reali in il mondo reale, che è molto più forte e più affidabile dell'analisi delle parole di un documento che non è noto per essere una scrittura stellare specifica e che è stato rivisto molte volte.)

In pratica, volatile garantisce la capacità ptrace che è la capacità di utilizzare le informazioni di debug per il programma in esecuzione, a qualsiasi livello di ottimizzazione , e il fatto che le informazioni di debug abbiano senso per questi oggetti volatili:

  • puoi usare ptrace(un meccanismo simile a ptrace) per impostare punti di interruzione significativi nei punti di sequenza dopo operazioni che coinvolgono oggetti volatili: puoi davvero rompere esattamente in questi punti (nota che funziona solo se sei disposto a impostare molti punti di interruzione come qualsiasi L'istruzione C / C ++ può essere compilata in molti punti di inizio e fine dell'assembly diversi, come in un ciclo srotolato in modo massiccio);
  • mentre un thread di esecuzione di fermato, puoi leggere il valore di tutti gli oggetti volatili, poiché hanno la loro rappresentazione canonica (seguendo l'ABI per il loro rispettivo tipo); una variabile locale non volatile potrebbe avere una rappresentazione atipica, ad es. una rappresentazione spostata: una variabile utilizzata per indicizzare un array potrebbe essere moltiplicata per la dimensione dei singoli oggetti, per facilitare l'indicizzazione; oppure potrebbe essere sostituito da un puntatore a un elemento array (purché tutti gli usi della variabile vengano convertiti in modo simile) (si pensi a cambiare dx in du in un integrale);
  • è inoltre possibile modificare tali oggetti (purché i mapping di memoria lo consentano, poiché l'oggetto volatile con durata statica che è qualificata può trovarsi in un intervallo di memoria mappato in sola lettura).

Garanzia volatile in pratica un po 'più della rigorosa interpretazione di ptrace: garantisce anche che le variabili automatiche volatili abbiano un indirizzo nello stack, poiché non sono allocate in un registro, un'allocazione di registro che renderebbe più delicate le manipolazioni di ptrace (il compilatore può output delle informazioni di debug per spiegare come le variabili sono allocate ai registri, ma leggere e modificare lo stato dei registri è leggermente più coinvolto dell'accesso agli indirizzi di memoria).

Si noti che la capacità di debug del programma completo, che considera tutte le variabili volatili almeno nei punti di sequenza, è fornita dalla modalità di "ottimizzazione zero" del compilatore, una modalità che esegue ancora banali ottimizzazioni come semplificazioni aritmetiche (di solito non è garantito alcun ottimizzazione in tutte le modalità). Ma volatile è più forte della non ottimizzazione: x-xpuò essere semplificato per un numero intero non volatile xma non per un oggetto volatile.

Quindi i mezzi volatili sono garantiti per essere compilati così come sono , come la traduzione dal sorgente al binario / assembly da parte del compilatore di una chiamata di sistema non è una reinterpretazione, una modifica o ottimizzata in alcun modo da un compilatore. Si noti che le chiamate in libreria possono essere o meno chiamate di sistema. Molte funzioni di sistema ufficiali sono in realtà funzioni di libreria che offrono un sottile strato di interposizione e generalmente rimandano al kernel alla fine. (In particolare getpidnon è necessario andare al kernel e potrebbe benissimo leggere un percorso di memoria fornito dal sistema operativo contenente le informazioni.)

Le interazioni volatili sono interazioni con il mondo esterno della macchina reale , che deve seguire la "macchina astratta". Non sono interazioni interne delle parti del programma con altre parti del programma. Il compilatore può solo ragionare su ciò che sa, ovvero le parti interne del programma.

La generazione del codice per un accesso volatile dovrebbe seguire l'interazione più naturale con quella posizione di memoria: non dovrebbe sorprendere. Ciò significa che alcuni accessi volatili dovrebbero essere atomici : se il modo naturale di leggere o scrivere la rappresentazione di longa sull'architettura è atomico, si prevede che una lettura o scrittura di un volatile longsarà atomica, poiché il compilatore non dovrebbe generare codice stupido inefficiente per accedere ad oggetti volatili byte per byte, per esempio .

Dovresti essere in grado di determinarlo conoscendo l'architettura. Non devi sapere nulla del compilatore, poiché volatile significa che il compilatore dovrebbe essere trasparente .

Ma volatile non fa altro che forzare l'emissione dell'assemblaggio previsto per il minimo ottimizzato per casi particolari per eseguire un'operazione di memoria: semantica volatile significa semantica di caso generale.

Il caso generale è quello che fa il compilatore quando non ha alcuna informazione su un costrutto: f.ex. chiamare una funzione virtuale su un valore tramite invio dinamico è un caso generale, effettuare una chiamata diretta all'overrider dopo aver determinato in fase di compilazione il tipo di oggetto designato dall'espressione è un caso particolare. Il compilatore ha sempre una gestione del caso generale di tutti i costrutti e segue l'ABI.

Volatile non fa nulla di speciale per sincronizzare i thread o fornire "visibilità della memoria": volatile fornisce solo garanzie a livello astratto visto dall'interno di un thread in esecuzione o arrestato, ovvero all'interno di un core della CPU :

  • volatile non dice nulla su quali operazioni di memoria raggiungano la RAM principale (è possibile impostare specifici tipi di memorizzazione nella cache della memoria con istruzioni di assemblaggio o chiamate di sistema per ottenere tali garanzie);
  • volatile non fornisce alcuna garanzia su quando le operazioni di memoria saranno impegnate a qualsiasi livello di cache (nemmeno L1) .

Solo il secondo punto indica che volatile non è utile nella maggior parte dei problemi di comunicazione tra thread; il primo punto è essenzialmente irrilevante in qualsiasi problema di programmazione che non comporta la comunicazione con componenti hardware esterni alla CPU ma ancora sul bus di memoria.

La proprietà di volatile che fornisce un comportamento garantito dal punto di vista del core che esegue il thread significa che i segnali asincroni inviati a quel thread, che vengono eseguiti dal punto di vista dell'ordine di esecuzione di quel thread, vedono le operazioni nell'ordine del codice sorgente .

A meno che tu non preveda di inviare segnali ai tuoi thread (un approccio estremamente utile al consolidamento delle informazioni sui thread attualmente in esecuzione senza un punto di arresto precedentemente concordato), la volatile non fa per te.


6

Non sono un esperto, ma cppreference.com ha ciò che mi sembra essere una buona informazione suvolatile . Ecco l'essenza:

Ogni accesso (sia in lettura che in scrittura) effettuato attraverso un'espressione lvalue di tipo volatile qualificato è considerato un effetto collaterale osservabile ai fini dell'ottimizzazione e viene valutato rigorosamente secondo le regole della macchina astratta (ovvero, tutte le scritture sono completate in qualche tempo prima del successivo punto di sequenza). Ciò significa che all'interno di un singolo thread di esecuzione, un accesso volatile non può essere ottimizzato o riordinato rispetto ad un altro effetto collaterale visibile che è separato da un punto di sequenza dall'accesso volatile.

Dà anche alcuni usi:

Usi di volatili

1) porte di I / O mappate in memoria modello di oggetti volatili statici e porte di input mappate in memoria modello di oggetti volatili statici, come un orologio in tempo reale

2) oggetti volatili statici di tipo sig_atomic_t vengono utilizzati per la comunicazione con i gestori di segnale.

3) le variabili volatili che sono locali a una funzione che contiene un'invocazione della macro setjmp sono le uniche variabili locali garantite per conservare i loro valori dopo i ritorni di longjmp.

4) Inoltre, le variabili volatili possono essere utilizzate per disabilitare alcune forme di ottimizzazione, ad esempio per disabilitare l'eliminazione del punto morto o la piegatura costante per i microbenchmark.

E, naturalmente, menziona che volatilenon è utile per la sincronizzazione dei thread:

Si noti che le variabili volatili non sono adatte per la comunicazione tra thread; non offrono atomicità, sincronizzazione o ordinamento della memoria. Una lettura da una variabile volatile che viene modificata da un altro thread senza sincronizzazione o modifica simultanea da due thread non sincronizzati è un comportamento indefinito a causa di una corsa ai dati.


2
In particolare, (2) e (3) sono rilevanti per il codice portatile.
Nate Eldredge,

2
@TED ​​Nonostante il nome di dominio, il collegamento è alle informazioni su C, non su C ++
David Brown,

@NateEldredge È raramente possibile utilizzare longjmpnel codice C ++.
curioso

@DavidBrown C e C ++ hanno la stessa definizione di SE osservabile ed essenzialmente le stesse primitive di thread.
curioso

4

Prima di tutto, storicamente ci sono stati vari singhiozzi riguardo a diverse interpretazioni del significato di volatileaccesso e simili. Vedi questo studio: I volatili non sono stati compilati correttamente e cosa fare al riguardo .

A parte le varie questioni menzionate in quello studio, il comportamento di volatileè portatile, salvo un aspetto di essi: quando agiscono come barriere di memoria . Una barriera di memoria è un meccanismo che è lì per impedire l'esecuzione simultanea senza conseguenze del codice. L'uso volatilecome barriera di memoria non è certamente portatile.

volatileApparentemente è discutibile se il linguaggio C garantisca o meno il comportamento della memoria , anche se personalmente penso che il linguaggio sia chiaro. Innanzitutto abbiamo la definizione formale di effetti collaterali, C17 5.1.2.3:

L'accesso a un volatileoggetto, la modifica di un oggetto, la modifica di un file o la chiamata a una funzione che esegue una di queste operazioni sono tutti effetti collaterali , che sono cambiamenti nello stato dell'ambiente di esecuzione.

Lo standard definisce il termine sequenziamento come un modo per determinare l'ordine di valutazione (esecuzione). La definizione è formale e ingombrante:

La sequenza precedente è una relazione asimmetrica, transitiva, a coppie tra valutazioni eseguite da un singolo thread, che induce un ordine parziale tra tali valutazioni. Date due valutazioni A e B, se A è sequenziato prima di B, quindi l'esecuzione di A precederà l'esecuzione di B. (Al contrario, se A è sequenziato prima di B, allora B è sequenziato dopo A.) Se A non è sequenziato prima o dopo B, quindi A e B non sono seguiti . Le valutazioni A e B sono indeterminate in sequenza quando A è in sequenza prima o dopo B, ma non è specificato quale. 13) La presenza di un punto sequenza tra la valutazione delle espressioni A e B implica che ogni calcolo di valore ed effetto collaterale associato ad A è sequenziato prima di ogni calcolo di valore ed effetto collaterale associato a B. (Un riepilogo dei punti di sequenza è riportato nell'allegato C.)

Il TL; DR di quanto sopra è fondamentalmente che nel caso in cui abbiamo un'espressione Ache contiene effetti collaterali, deve essere eseguita eseguendo prima di un'altra espressione B, nel caso in cui Bsia sequenziato dopo A.

Ottimizzazioni del codice C sono rese possibili attraverso questa parte:

Nella macchina astratta, tutte le espressioni sono valutate come specificato dalla semantica. Un'implementazione effettiva non deve valutare parte di un'espressione se può dedurre che il suo valore non viene utilizzato e che non vengono prodotti effetti collaterali necessari (inclusi quelli causati dalla chiamata a una funzione o dall'accesso a un oggetto volatile).

Ciò significa che il programma può valutare (eseguire) le espressioni nell'ordine che la norma impone altrove (ordine di valutazione ecc.). Ma non è necessario valutare (eseguire) un valore se può dedurre che non viene utilizzato. Ad esempio, l'operazione 0 * xnon deve valutare xe semplicemente sostituire l'espressione con 0.

A meno che l' accesso a una variabile sia un effetto collaterale. Il che significa che nel caso in cui xè volatile, si deve valutare (esecuzione) 0 * x, anche se il risultato sarà sempre 0. L'ottimizzazione non è permesso.

Inoltre, lo standard parla di comportamento osservabile:

I requisiti minimi per un'implementazione conforme sono:

  • Gli accessi agli oggetti volatili sono valutati rigorosamente secondo le regole della macchina astratta.
    / - / Questo è il comportamento osservabile del programma.

Alla luce di quanto sopra, un'implementazione conforme (compilatore + sistema sottostante) potrebbe non eseguire l'accesso degli volatileoggetti in un ordine non seguito, nel caso in cui la semantica della fonte C scritta dica diversamente.

Ciò significa che in questo esempio

volatile int x;
volatile int y;
z = x;
z = y;

Entrambe le espressioni di assegnazione devono essere valutate e z = x; devono essere valutate prima z = y;. Un'implementazione multi-processore che esternalizza queste due operazioni a due diversi nuclei di conseguenze non è conforme!

Il dilemma è che i compilatori non possono fare molto su cose come la cache pre-fetch e il pipelining delle istruzioni ecc., In particolare non quando si esegue su un sistema operativo. E così i compilatori consegnano quel problema ai programmatori, dicendo loro che le barriere di memoria sono ora responsabilità del programmatore. Mentre lo standard C afferma chiaramente che il compilatore deve risolvere il problema.

Il compilatore non si preoccupa necessariamente di risolvere il problema, e quindi volatileper il fatto di agire come una barriera di memoria non è portatile. È diventato un problema di qualità dell'attuazione.


@curiousguy Non importa.
Lundin,

@curiousguy Non importa, purché sia ​​una sorta di tipo intero con o senza qualificatori.
Lundin,

Se si tratta di un semplice numero intero non volatile, perché le scritture ridondanti dovrebbero zessere realmente eseguite? (come z = x; z = y;) Il valore verrà cancellato nell'istruzione successiva.
curioso

@curiousguy Perché le letture alle variabili volatili devono essere eseguite indipendentemente dalla sequenza specificata.
Lundin,

Quindi viene zassegnato davvero due volte? Come fai a sapere che "le letture vengono eseguite"?
curioso
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.