Mentre stavo lavorando a un tutorial video scaricabile online per lo sviluppo di grafica 3D e motore di gioco, lavorando con OpenGL moderno. Abbiamo usato volatile
all'interno di una delle nostre classi. Il sito web del tutorial può essere trovato qui e il video che lavora con la volatile
parola chiave si trova nella Shader Engine
serie video 98. Questi lavori non sono miei ma sono accreditati Marek A. Krzeminski, MASc
e questo è un estratto dalla pagina di download del video.
E se sei iscritto al suo sito web e hai accesso ai suoi video all'interno di questo video, fa riferimento a questo articolo riguardante l'uso di Volatile
con la multithreading
programmazione.
volatile: il migliore amico del programmatore multithread
Di Andrei Alexandrescu, 1 febbraio 2001
La parola chiave volatile è stata ideata per impedire le ottimizzazioni del compilatore che potrebbero rendere il codice non corretto in presenza di determinati eventi asincroni.
Non voglio rovinare il tuo umore, ma questa colonna affronta il temuto argomento della programmazione multithread. Se - come dice la precedente puntata di Generic - la programmazione sicura rispetto alle eccezioni è difficile, è un gioco da ragazzi rispetto alla programmazione multithread.
I programmi che utilizzano più thread sono notoriamente difficili da scrivere, dimostrarsi corretti, eseguire il debug, mantenere e domare in generale. Programmi multithread errati potrebbero funzionare per anni senza problemi, solo per funzionare in modo imprevisto perché sono state soddisfatte alcune condizioni di temporizzazione critiche.
Inutile dire che un programmatore che scrive codice multithread ha bisogno di tutto l'aiuto che può ottenere. Questa colonna si concentra sulle condizioni di gara - una fonte comune di problemi nei programmi multithread - e fornisce approfondimenti e strumenti su come evitarli e, sorprendentemente, il compilatore lavora sodo per aiutarti in questo.
Solo una piccola parola chiave
Sebbene sia gli standard C che C ++ siano notevolmente silenziosi quando si tratta di thread, fanno una piccola concessione al multithreading, sotto forma della parola chiave volatile.
Proprio come la sua controparte più nota const, volatile è un modificatore di tipo. È concepito per essere utilizzato insieme a variabili a cui si accede e modificate in thread diversi. Fondamentalmente, senza volatile, la scrittura di programmi multithread diventa impossibile o il compilatore spreca vaste opportunità di ottimizzazione. È necessaria una spiegazione.
Considera il codice seguente:
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
Lo scopo di Gadget :: Wait sopra è controllare la variabile membro flag_ ogni secondo e tornare quando quella variabile è stata impostata su true da un altro thread. Almeno questo è ciò che intendeva il suo programmatore, ma, ahimè, Wait non è corretto.
Supponiamo che il compilatore capisca che Sleep (1000) è una chiamata in una libreria esterna che non può modificare la variabile membro flag_. Quindi il compilatore conclude che può memorizzare nella cache flag_ in un registro e utilizzare quel registro invece di accedere alla memoria su scheda più lenta. Questa è un'ottimizzazione eccellente per il codice a thread singolo, ma in questo caso danneggia la correttezza: dopo aver chiamato Wait for some Gadget object, sebbene un altro thread chiami Wakeup, Wait verrà eseguito in loop per sempre. Questo perché la modifica di flag_ non si rifletterà nel registro che memorizza flag_ nella cache. L'ottimizzazione è troppo ... ottimista.
La memorizzazione nella cache delle variabili nei registri è un'ottimizzazione molto preziosa che si applica la maggior parte delle volte, quindi sarebbe un peccato sprecarla. C e C ++ ti danno la possibilità di disabilitare esplicitamente tale memorizzazione nella cache. Se si utilizza il modificatore volatile su una variabile, il compilatore non memorizzerà nella cache quella variabile nei registri: ogni accesso raggiungerà l'effettiva posizione di memoria di quella variabile. Quindi tutto ciò che devi fare per far funzionare la combo Wait / Wakeup di Gadget è qualificare flag_ in modo appropriato:
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
La maggior parte delle spiegazioni della logica e dell'uso di volatile si ferma qui e ti consiglia di qualificare volatile i tipi primitivi che usi in più thread. Tuttavia, c'è molto di più che puoi fare con volatile, perché fa parte del meraviglioso sistema di tipi di C ++.
Utilizzo di volatile con tipi definiti dall'utente
È possibile qualificare volatile non solo i tipi primitivi, ma anche i tipi definiti dall'utente. In tal caso, volatile modifica il tipo in modo simile a const. (Puoi anche applicare const e volatile allo stesso tipo contemporaneamente.)
A differenza di const, volatile discrimina tra tipi primitivi e tipi definiti dall'utente. Vale a dire, a differenza delle classi, i tipi primitivi supportano ancora tutte le loro operazioni (addizione, moltiplicazione, assegnazione, ecc.) Quando qualificati volatile. Ad esempio, è possibile assegnare un int non volatile a un int volatile, ma non è possibile assegnare un oggetto non volatile a un oggetto volatile.
Illustriamo in un esempio come funziona volatile sui tipi definiti dall'utente.
class Gadget {
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
Se pensi che volatile non sia così utile con gli oggetti, preparati a qualche sorpresa.
volatileGadget.Foo();
regularGadget.Foo();
volatileGadget.Bar();
La conversione da un tipo non qualificato alla sua controparte volatile è banale. Tuttavia, proprio come con const, non puoi tornare da volatile a non qualificato. Devi usare un cast:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
Una classe qualificata volatile dà accesso solo a un sottoinsieme della sua interfaccia, un sottoinsieme che è sotto il controllo dell'implementatore della classe. Gli utenti possono ottenere l'accesso completo all'interfaccia di quel tipo solo utilizzando const_cast. Inoltre, proprio come constness, la volatilità si propaga dalla classe ai suoi membri (ad esempio, volatileGadget.name_ e volatileGadget.state_ sono variabili volatili).
volatile, sezioni critiche e condizioni di gara
Il dispositivo di sincronizzazione più semplice e più utilizzato nei programmi multithread è il mutex. Un mutex espone le primitive Acquire e Release. Dopo aver chiamato Acquire in un thread, qualsiasi altro thread che chiama Acquire verrà bloccato. Successivamente, quando quel thread chiama Release, verrà rilasciato esattamente un thread bloccato in una chiamata Acquire. In altre parole, per un dato mutex, solo un thread può ottenere il tempo del processore tra una chiamata ad Acquire e una chiamata a Release. Il codice in esecuzione tra una chiamata ad Acquire e una chiamata a Release è chiamato sezione critica. (La terminologia di Windows è un po 'confusa perché chiama il mutex stesso una sezione critica, mentre "mutex" è in realtà un mutex tra processi. Sarebbe stato carino se fossero chiamati thread mutex e process mutex.)
I mutex vengono utilizzati per proteggere i dati dalle race condition. Per definizione, una condizione di competizione si verifica quando l'effetto di più thread sui dati dipende dalla modalità di pianificazione dei thread. Le condizioni di competizione vengono visualizzate quando due o più thread competono per l'utilizzo degli stessi dati. Poiché i thread possono interrompersi a vicenda in momenti arbitrari, i dati possono essere danneggiati o interpretati male. Di conseguenza, le modifiche e talvolta gli accessi ai dati devono essere accuratamente protetti con sezioni critiche. Nella programmazione orientata agli oggetti, questo di solito significa che si memorizza un mutex in una classe come variabile membro e lo si utilizza ogni volta che si accede allo stato di quella classe.
I programmatori multithread esperti potrebbero aver sbadigliato leggendo i due paragrafi precedenti, ma il loro scopo è fornire un allenamento intellettuale, perché ora ci collegheremo con la connessione volatile. Lo facciamo tracciando un parallelo tra il mondo dei tipi C ++ e il mondo della semantica del threading.
- Al di fuori di una sezione critica, qualsiasi thread potrebbe interrompere qualsiasi altro in qualsiasi momento; non c'è controllo, quindi di conseguenza le variabili accessibili da più thread sono volatili. Ciò è in linea con l'intento originale di volatile, ovvero quello di impedire al compilatore di memorizzare involontariamente nella cache i valori utilizzati da più thread contemporaneamente.
- All'interno di una sezione critica definita da un mutex, solo un thread ha accesso. Di conseguenza, all'interno di una sezione critica, il codice in esecuzione ha una semantica a thread singolo. La variabile controllata non è più volatile: è possibile rimuovere il qualificatore volatile.
In breve, i dati condivisi tra i thread sono concettualmente volatili al di fuori di una sezione critica e non volatili all'interno di una sezione critica.
Si entra in una sezione critica bloccando un mutex. Si rimuove il qualificatore volatile da un tipo applicando const_cast. Se riusciamo a mettere insieme queste due operazioni, creiamo una connessione tra il sistema di tipi di C ++ e la semantica di threading dell'applicazione. Possiamo fare in modo che il compilatore controlli le condizioni di gara per noi.
LockingPtr
Abbiamo bisogno di uno strumento che raccolga un'acquisizione mutex e un const_cast. Sviluppiamo un modello di classe LockingPtr inizializzato con un oggetto volatile obj e un mutex mtx. Durante la sua vita, un LockingPtr mantiene mtx acquisito. Inoltre, LockingPtr offre l'accesso all'obj spogliato dei volatili. L'accesso è offerto in modo intelligente dal puntatore, tramite operatore-> e operatore *. Il const_cast viene eseguito all'interno di LockingPtr. Il cast è semanticamente valido perché LockingPtr mantiene il mutex acquisito per tutta la sua durata.
Per prima cosa, definiamo lo scheletro di una classe Mutex con cui LockingPtr funzionerà:
class Mutex {
public:
void Acquire();
void Release();
...
};
Per utilizzare LockingPtr, implementa Mutex utilizzando le strutture di dati native e le funzioni primitive del tuo sistema operativo.
LockingPtr è modellato con il tipo della variabile controllata. Ad esempio, se si desidera controllare un widget, utilizzare un LockingPtr che si inizializza con una variabile di tipo Widget volatile.
La definizione di LockingPtr è molto semplice. LockingPtr implementa un puntatore intelligente non sofisticato. Si concentra esclusivamente sulla raccolta di un const_cast e di una sezione critica.
template <typename T>
class LockingPtr {
public:
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {
mtx.Lock();
}
~LockingPtr() {
pMtx_->Unlock();
}
T& operator*() {
return *pObj_;
}
T* operator->() {
return pObj_;
}
private:
T* pObj_;
Mutex* pMtx_;
LockingPtr(const LockingPtr&);
LockingPtr& operator=(const LockingPtr&);
};
Nonostante la sua semplicità, LockingPtr è un aiuto molto utile per scrivere codice multithread corretto. È necessario definire gli oggetti condivisi tra i thread come volatili e non utilizzare mai const_cast con essi: utilizzare sempre gli oggetti automatici LockingPtr. Illustriamolo con un esempio.
Supponi di avere due thread che condividono un oggetto vettoriale:
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_;
};
All'interno di una funzione thread, si utilizza semplicemente un LockingPtr per ottenere l'accesso controllato alla variabile membro buffer_:
void SyncBuf::Thread1() {
LockingPtr<BufT> lpBuf(buffer_, mtx_);
BufT::iterator i = lpBuf->begin();
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}
Il codice è molto facile da scrivere e da comprendere: ogni volta che è necessario utilizzare buffer_, è necessario creare un LockingPtr che punta ad esso. Una volta che lo fai, hai accesso all'intera interfaccia di Vector.
La parte bella è che se commetti un errore, il compilatore lo indicherà:
void SyncBuf::Thread2() {
BufT::iterator i = buffer_.begin();
for ( ; i != lpBuf->end(); ++i ) {
... use *i ...
}
}
Non è possibile accedere a nessuna funzione di buffer_ finché non si applica un const_cast o non si utilizza LockingPtr. La differenza è che LockingPtr offre un modo ordinato di applicare const_cast a variabili volatili.
LockingPtr è straordinariamente espressivo. Se è necessario chiamare solo una funzione, è possibile creare un oggetto LockingPtr temporaneo senza nome e utilizzarlo direttamente:
unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}
Torna ai tipi primitivi
Abbiamo visto come la volatilità protegge gli oggetti da accessi incontrollati e come LockingPtr fornisce un modo semplice ed efficace per scrivere codice thread-safe. Torniamo ora ai tipi primitivi, che vengono trattati in modo diverso da volatile.
Consideriamo un esempio in cui più thread condividono una variabile di tipo int.
class Counter {
public:
...
void Increment() { ++ctr_; }
void Decrement() { —ctr_; }
private:
int ctr_;
};
Se Incremento e Decremento devono essere chiamati da thread diversi, il frammento sopra è bacato. Innanzitutto, ctr_ deve essere volatile. In secondo luogo, anche un'operazione apparentemente atomica come ++ ctr_ è in realtà un'operazione in tre fasi. La memoria stessa non ha capacità aritmetiche. Quando si incrementa una variabile, il processore:
- Legge quella variabile in un registro
- Incrementa il valore nel registro
- Scrive il risultato in memoria
Questa operazione in tre fasi è chiamata RMW (Read-Modify-Write). Durante la parte Modifica di un'operazione RMW, la maggior parte dei processori libera il bus di memoria per consentire ad altri processori di accedere alla memoria.
Se in quel momento un altro processore esegue un'operazione RMW sulla stessa variabile, abbiamo una race condition: la seconda scrittura sovrascrive l'effetto della prima.
Per evitare ciò, puoi fare affidamento, ancora una volta, su LockingPtr:
class Counter {
public:
...
void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
Ora il codice è corretto, ma la sua qualità è inferiore rispetto al codice di SyncBuf. Perché? Perché con Counter, il compilatore non ti avviserà se accedi per errore a ctr_ direttamente (senza bloccarlo). Il compilatore compila ++ ctr_ se ctr_ è volatile, sebbene il codice generato sia semplicemente errato. Il compilatore non è più un tuo alleato e solo la tua attenzione può aiutarti a evitare le condizioni di gara.
Cosa dovresti fare allora? Incapsula semplicemente i dati primitivi che usi in strutture di livello superiore e usa volatile con quelle strutture. Paradossalmente, è peggio usare volatile direttamente con i built-in, nonostante inizialmente questo fosse l'intento d'uso di volatile!
Funzioni membro volatili
Finora, abbiamo avuto classi che aggregano membri di dati volatili; ora pensiamo a progettare classi che a loro volta faranno parte di oggetti più grandi e saranno condivise tra thread. Qui è dove le funzioni dei membri volatili possono essere di grande aiuto.
Quando si progetta la classe, si qualificano in modo volatile solo le funzioni membro che sono thread-safe. È necessario presumere che il codice dall'esterno chiamerà le funzioni volatili da qualsiasi codice in qualsiasi momento. Non dimenticare: volatile equivale a codice multithread libero e nessuna sezione critica; non volatile è uguale a scenario a thread singolo o all'interno di una sezione critica.
Ad esempio, si definisce un widget di classe che implementa un'operazione in due varianti: una thread-safe e una veloce, non protetta.
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
Notare l'uso del sovraccarico. Ora l'utente di Widget può invocare Operation utilizzando una sintassi uniforme per oggetti volatili e ottenere thread safety, o per oggetti normali e ottenere velocità. L'utente deve prestare attenzione nel definire gli oggetti Widget condivisi come volatili.
Quando si implementa una funzione membro volatile, la prima operazione è in genere quella di bloccarla con un LockingPtr. Quindi il lavoro viene svolto utilizzando il fratello non volatile:
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation();
}
Sommario
Quando si scrivono programmi multithread, è possibile utilizzare volatile a proprio vantaggio. È necessario attenersi alle seguenti regole:
- Definisci tutti gli oggetti condivisi come volatili.
- Non utilizzare volatile direttamente con i tipi primitivi.
- Quando si definiscono classi condivise, utilizzare funzioni membro volatili per esprimere la sicurezza dei thread.
Se lo fai, e se usi il semplice componente generico LockingPtr, puoi scrivere codice thread-safe e preoccuparti molto meno delle condizioni di gara, perché il compilatore si preoccuperà per te e indicherà diligentemente i punti in cui hai sbagliato.
Un paio di progetti a cui sono stato coinvolto hanno utilizzato volatile e LockingPtr con ottimi risultati. Il codice è chiaro e comprensibile. Ricordo un paio di deadlock, ma preferisco i deadlock alle condizioni di gara perché sono molto più facili da eseguire il debug. Non c'erano praticamente problemi legati alle condizioni di gara. Ma poi non lo sai mai.
Ringraziamenti
Molte grazie a James Kanze e Sorin Jianu che hanno contribuito con idee penetranti.
Andrei Alexandrescu è Development Manager presso RealNetworks Inc. (www.realnetworks.com), con sede a Seattle, WA, e autore dell'acclamato libro Modern C ++ Design. Può essere contattato su www.moderncppdesign.com. Andrei è anche uno degli istruttori del The C ++ Seminar (www.gotw.ca/cpp_seminar).
Questo articolo potrebbe essere un po 'datato, ma fornisce una buona visione di un uso eccellente dell'uso del modificatore volatile nell'uso della programmazione multithread per aiutare a mantenere gli eventi asincroni mentre il compilatore controlla le condizioni di gara per noi. Questo potrebbe non rispondere direttamente alla domanda originale dell'OP sulla creazione di una barriera di memoria, ma ho scelto di postarla come risposta per altri come un eccellente riferimento verso un buon uso del volatile quando si lavora con applicazioni multithread.