tipi - C++ 11 ha introdotto un modello di memoria standardizzato. Cosa significa? E come influenzerà la programmazione in C++?




tipi di java (4)

Darò solo l'analogia con la quale capisco i modelli di consistenza della memoria (o modelli di memoria, in breve). Si ispira al saggio articolo di Leslie Lamport "Tempo, orologi e ordine degli eventi in un sistema distribuito" . L'analogia è adatta e ha un significato fondamentale, ma potrebbe essere eccessivo per molte persone. Tuttavia, spero che fornisca un'immagine mentale (una rappresentazione pittorica) che faciliti il ​​ragionamento sui modelli di consistenza della memoria.

Vediamo le storie di tutte le locazioni di memoria in un diagramma spazio-temporale in cui l'asse orizzontale rappresenta lo spazio degli indirizzi (cioè, ogni posizione di memoria è rappresentata da un punto su quell'asse) e l'asse verticale rappresenta il tempo (vedremo che, in generale, non esiste una nozione universale di tempo). La cronologia dei valori detenuti da ciascuna posizione di memoria è, quindi, rappresentata da una colonna verticale in corrispondenza di quell'indirizzo di memoria. Ogni variazione di valore è dovuta a uno dei thread che scrive un nuovo valore in quella posizione. Con un'immagine di memoria , intendiamo l'aggregazione / combinazione di valori di tutte le posizioni di memoria osservabili in un determinato momento da un particolare thread .

Citando da "A Primer on Memory Consistency and Cache Coherence"

Il modello di memoria intuitivo (e più restrittivo) è la coerenza sequenziale (SC) in cui un'esecuzione multithread dovrebbe apparire come un interleaving delle esecuzioni sequenziali di ogni thread costituente, come se i thread fossero multiplati nel tempo su un processore single-core.

L'ordine di memoria globale può variare da una corsa all'altra del programma e potrebbe non essere conosciuto in anticipo. La caratteristica caratteristica di SC è l'insieme di sezioni orizzontali nel diagramma indirizzo-spazio-tempo che rappresenta i piani di simultaneità (cioè immagini di memoria). Su un dato piano, tutti i suoi eventi (o valori di memoria) sono simultanei. Esiste una nozione di Tempo assoluto , in cui tutti i thread concordano su quali valori di memoria sono simultanei. In SC, in ogni istante, c'è solo un'immagine di memoria condivisa da tutti i thread. Cioè, in ogni istante di tempo, tutti i processori concordano sull'immagine della memoria (cioè, il contenuto aggregato della memoria). Ciò non solo implica che tutti i thread visualizzino la stessa sequenza di valori per tutte le posizioni di memoria, ma anche che tutti i processori osservino le stesse combinazioni di valori di tutte le variabili. È come dire che tutte le operazioni di memoria (su tutte le posizioni di memoria) sono osservate nello stesso ordine totale da tutti i thread.

Nei modelli di memoria rilassati, ogni thread troncerà lo spazio-tempo dell'indirizzo a modo suo, l'unica restrizione è che le sezioni di ciascun thread non si incroceranno perché tutti i thread devono concordare sulla cronologia di ogni singola posizione di memoria (ovviamente , fette di fili diversi possono e si incroceranno l'un l'altro). Non esiste un modo universale per dividerlo (nessuna foliazione privilegiata di indirizzo-spazio-tempo). Le fette non devono essere planari (o lineari). Possono essere curvati e questo è ciò che può far sì che un thread legga valori scritti da un altro thread fuori dall'ordine in cui sono stati scritti. Le storie di diverse posizioni di memoria possono scorrere (o essere allungate) arbitrariamente l'una rispetto all'altra quando visualizzate da un particolare thread . Ogni thread avrà un diverso senso di quali eventi (o, equivalentemente, i valori della memoria) sono simultanei. L'insieme di eventi (o valori di memoria) che sono simultanei a un thread non sono simultanei a un altro. Pertanto, in un modello di memoria rilassato, tutti i thread osservano ancora la stessa cronologia (cioè sequenza di valori) per ogni posizione di memoria. Ma possono osservare diverse immagini di memoria (cioè combinazioni di valori di tutte le posizioni di memoria). Anche se due diverse posizioni di memoria sono scritte dallo stesso thread in sequenza, i due nuovi valori scritti possono essere osservati in ordine diverso da altri thread.

[Immagine da Wikipedia]

I lettori che hanno familiarità con la teoria della relatività speciale di Einstein noteranno ciò a cui alludo. Tradurre le parole di Minkowski nel regno dei modelli di memoria: lo spazio e il tempo dell'indirizzo sono ombre di indirizzo-spazio-tempo. In questo caso, ogni osservatore (cioè, thread) proietterà ombre di eventi (cioè memorie / carichi) sulla propria linea del mondo (cioè il suo asse del tempo) e il proprio piano di simultaneità (il suo asse dello spazio indirizzo) . I thread nel modello di memoria C ++ 11 corrispondono agli osservatori che si muovono l'uno rispetto all'altro in relatività speciale. La coerenza sequenziale corrisponde allo spazio-tempo galileiano (cioè, tutti gli osservatori concordano su un ordine assoluto di eventi e un senso globale di simultaneità).

La somiglianza tra modelli di memoria e relatività speciale deriva dal fatto che entrambi definiscono un insieme di eventi parzialmente ordinati, spesso chiamati set causali. Alcuni eventi (es. Memorie) possono influenzare (ma non essere influenzati da) altri eventi. Un thread C ++ 11 (o un osservatore in fisica) non è altro che una catena (cioè un insieme totalmente ordinato) di eventi (ad esempio, carichi di memoria e archivi a indirizzi potenzialmente diversi).

In relatività, un certo ordine viene ripristinato al quadro apparentemente caotico di eventi parzialmente ordinati, dal momento che l'unico ordinamento temporale su cui tutti gli osservatori concordano è l'ordine tra eventi "timelike" (cioè quegli eventi che sono in principio collegabili da qualsiasi particella che va più lentamente rispetto alla velocità della luce nel vuoto). Solo gli eventi correlati al tempo sono ordinati in modo invariante. Tempo in fisica, Craig Callender .

Nel modello di memoria C ++ 11, un meccanismo simile (il modello di coerenza acquisizione-rilascio) viene utilizzato per stabilire queste relazioni locali di causalità .

Per fornire una definizione di consistenza della memoria e una motivazione per abbandonare la SC, citerò tra "Un primer sulla coerenza della memoria e la coerenza della cache"

Per una macchina con memoria condivisa, il modello di consistenza della memoria definisce il comportamento architettonicamente visibile del suo sistema di memoria. Il criterio di correttezza per un comportamento delle partizioni core a processore singolo tra " un risultato corretto " e " molte alternative errate ". Ciò è dovuto al fatto che l'architettura del processore richiede che l'esecuzione di un thread trasformi un dato stato di input in un singolo stato di output ben definito, anche su un core out-of-order. I modelli di consistenza della memoria condivisa, tuttavia, riguardano i carichi e gli archivi di più thread e in genere consentono molte esecuzioni corrette mentre disabilitano molti (più) errori. La possibilità di più esecuzioni corrette è dovuta all'ISA che consente l'esecuzione simultanea di più thread, spesso con molte possibili interluzioni legali di istruzioni da diversi thread.

Modelli di consistenza della memoria rilassati o deboli sono motivati ​​dal fatto che la maggior parte degli ordini di memoria nei modelli forti non è necessaria. Se un thread aggiorna dieci elementi di dati e quindi un flag di sincronizzazione, i programmatori di solito non si preoccupano se gli elementi di dati vengono aggiornati in ordine l'uno rispetto all'altro ma solo che tutti gli elementi di dati vengono aggiornati prima dell'aggiornamento del flag (in genere implementato utilizzando le istruzioni FENCE ). I modelli rilassati cercano di catturare questa maggiore flessibilità degli ordini e preservare solo gli ordini che i programmatori " richiedono " per ottenere sia prestazioni più elevate che correttezza di SC. Ad esempio, in alcune architetture, i buffer di scrittura FIFO vengono utilizzati da ciascun core per conservare i risultati degli archivi impegnati (in pensione) prima di scrivere i risultati nelle cache. Questa ottimizzazione migliora le prestazioni ma viola SC. Il buffer di scrittura nasconde la latenza di manutenzione di una mancanza di archivio. Poiché i negozi sono comuni, essere in grado di evitare lo stallo sulla maggior parte di essi è un vantaggio importante. Per un processore single-core, un buffer di scrittura può essere reso architettonicamente invisibile assicurando che un carico per l'indirizzo A restituisca il valore dell'archivio più recente ad A anche se uno o più negozi in A sono nel buffer di scrittura. In genere ciò avviene bypassando il valore dell'archivio più recente in A al carico da A, dove "più recente" è determinato dall'ordine del programma, o arrestando un carico di A se un archivio in A è nel buffer di scrittura . Quando vengono utilizzati più core, ognuno avrà il proprio buffer di scrittura bypass. Senza buffer di scrittura, l'hardware è SC, ma con buffer di scrittura, non lo è, rendendo i buffer di scrittura architettonicamente visibili in un processore multicore.

Il riordino del punto vendita può verificarsi se un core ha un buffer di scrittura non FIFO che consente ai negozi di discostarsi in un ordine diverso dall'ordine in cui sono entrati. Questo potrebbe accadere se il primo archivio fallisce nella cache mentre il secondo colpisce o se il secondo negozio può fondersi con un negozio precedente (cioè, prima del primo negozio). Il riordino del carico può verificarsi anche su core con pianificazione dinamica che eseguono le istruzioni dall'ordine del programma. Questo può comportarsi come riordinare i negozi su un altro core (puoi trovare un esempio di interleaving tra due thread?). Il riordino di un carico precedente con un archivio successivo (un riordino del carico-archivio) può causare molti comportamenti scorretti, come il caricamento di un valore dopo aver rilasciato il blocco che lo protegge (se l'archivio è l'operazione di sblocco). Si noti che i riordini del carico di magazzino possono verificarsi anche a causa dell'esclusione locale nel buffer di scrittura FIFO comunemente implementato, anche con un core che esegue tutte le istruzioni nell'ordine di programma.

Poiché la coerenza della cache e la coerenza della memoria sono a volte confuse, è istruttivo avere anche questa citazione:

A differenza della coerenza, la coerenza della cache non è né visibile al software né richiesta. Coherence cerca di rendere le cache di un sistema di memoria condivisa come funzionalmente invisibili come le cache in un sistema single-core. La corretta coerenza garantisce che un programmatore non possa determinare se e dove un sistema ha cache analizzando i risultati di carichi e negozi. Questo perché la coerenza corretta garantisce che le cache non abilitino mai un comportamento funzionale nuovo o differente (i programmatori potrebbero ancora essere in grado di dedurre la probabile struttura della cache utilizzando le informazioni di temporizzazione ). Lo scopo principale dei protocolli di coerenza della cache è mantenere invariato l'invariante single-writer-multiple-readers (SWMR) per ogni posizione di memoria. Un'importante distinzione tra coerenza e consistenza è che la coerenza è specificata in base alla posizione per memoria , mentre la coerenza è specificata rispetto a tutte le posizioni di memoria.

Continuando con la nostra immagine mentale, l'invariante SWMR corrisponde al requisito fisico che ci sia al massimo una particella situata in una qualsiasi posizione, ma ci può essere un numero illimitato di osservatori di qualsiasi posizione.

C ++ 11 ha introdotto un modello di memoria standardizzato, ma cosa significa esattamente? E come influenzerà la programmazione in C ++?

Questo articolo (di Gavin Clarke che cita Herb Sutter ) dice che,

Il modello di memoria significa che il codice C ++ ora ha una libreria standardizzata da chiamare indipendentemente da chi ha creato il compilatore e su quale piattaforma è in esecuzione. Esiste un modo standard per controllare in che modo i diversi thread parlano alla memoria del processore.

"Quando parli di dividere [codice] su diversi core dello standard, stiamo parlando del modello di memoria: lo ottimizzeremo senza infrangere le seguenti supposizioni che le persone faranno nel codice", ha affermato Sutter .

Bene, posso memorizzare questo e paragrafi simili disponibili online (dato che ho avuto il mio modello di memoria sin dalla nascita: P) e posso persino postare come risposta alle domande poste da altri, ma per essere onesto, non capisco esattamente Questo.

Quindi, quello che fondamentalmente voglio sapere è che i programmatori C ++ erano soliti sviluppare applicazioni multi-thread anche prima, quindi come importa se si tratta di thread POSIX, thread di Windows o thread di C ++ 11? Quali sono i vantaggi? Voglio capire i dettagli di basso livello.

Ho anche la sensazione che il modello di memoria C ++ 11 sia in qualche modo collegato al supporto multi-threading di C ++ 11, visto che spesso vedo questi due insieme. Se lo è, come esattamente? Perché dovrebbero essere correlati?

Poiché non so come funzionano gli interni del multi-threading e quale modello di memoria significa in generale, per favore aiutami a capire questi concetti. :-)


In primo luogo, devi imparare a pensare come un avvocato linguistico.

Le specifiche C ++ non fanno riferimento a nessun particolare compilatore, sistema operativo o CPU. Fa riferimento a una macchina astratta che è una generalizzazione dei sistemi attuali. Nel mondo del Language Lawyer, il compito del programmatore è scrivere codice per la macchina astratta; il compito del compilatore è quello di attualizzare quel codice su una macchina concreta. Codificando rigidamente le specifiche, puoi essere certo che il tuo codice verrà compilato ed eseguito senza modifiche su qualsiasi sistema con un compilatore C ++ compatibile, sia oggi che tra 50 anni.

La macchina astratta nella specifica C ++ 98 / C ++ 03 è fondamentalmente a thread singolo. Quindi non è possibile scrivere codice C + multi-threaded che sia "completamente portatile" rispetto alle specifiche. Le specifiche non dicono nulla sull'atomicità dei carichi di memoria e dei negozi o sull'ordine in cui potrebbero accadere carichi e negozi, per non parlare di cose come i mutex.

Naturalmente, è possibile scrivere codice multi-threading in pratica per particolari sistemi concreti, come pthreads o Windows. Ma non esiste un modo standard per scrivere codice multi-thread per C ++ 98 / C ++ 03.

La macchina astratta in C ++ 11 è multi-threaded dal design. Ha anche un modello di memoria ben definito; cioè, dice cosa può o non può fare il compilatore quando si tratta di accedere alla memoria.

Si consideri il seguente esempio, in cui una coppia di variabili globali è accessibile contemporaneamente da due thread:

           Global
           int x, y;

Thread 1            Thread 2
x = 17;             cout << y << " ";
y = 37;             cout << x << endl;

Cosa potrebbe produrre il thread 2?

Sotto C ++ 98 / C ++ 03, questo non è nemmeno un comportamento indefinito; la domanda stessa non ha senso perché lo standard non contempla nulla chiamato "filo".

In C ++ 11, il risultato è Undefined Behaviour, poiché carichi e negozi non devono necessariamente essere atomici in generale. Quale può non sembrare un gran miglioramento ... E da solo, non lo è.

Ma con C ++ 11, puoi scrivere questo:

           Global
           atomic<int> x, y;

Thread 1                 Thread 2
x.store(17);             cout << y.load() << " ";
y.store(37);             cout << x.load() << endl;

Ora le cose diventano molto più interessanti. Prima di tutto, il comportamento qui è definito . Thread 2 potrebbe ora stampare 0 0 (se viene eseguito prima di Thread 1), 37 17 (se viene eseguito dopo Thread 1) o 0 17 (se viene eseguito dopo Thread 1 assegna a x ma prima che assegni a y).

Ciò che non può stampare è 37 0 , perché la modalità predefinita per carichi atomici / negozi in C ++ 11 consiste nell'imporre coerenza sequenziale . Questo significa solo che tutti i carichi e i negozi devono essere "come se" fossero accaduti nell'ordine in cui li hai scritti all'interno di ciascun thread, mentre le operazioni tra i thread possono essere intercalate, a differenza del sistema. Quindi il comportamento predefinito dell'atomica fornisce sia l' atomicità che l' ordine per carichi e negozi.

Ora, su una CPU moderna, garantire la coerenza sequenziale può essere costoso. In particolare, il compilatore probabilmente emetterà barriere di memoria in piena regola tra ogni accesso qui. Ma se il tuo algoritmo può tollerare carichi e negozi fuori ordine; cioè, se richiede l'atomicità ma non l'ordine; cioè, se può tollerare 37 0 come output da questo programma, allora puoi scrivere questo:

           Global
           atomic<int> x, y;

Thread 1                            Thread 2
x.store(17,memory_order_relaxed);   cout << y.load(memory_order_relaxed) << " ";
y.store(37,memory_order_relaxed);   cout << x.load(memory_order_relaxed) << endl;

Più moderna è la CPU, più è probabile che sia più veloce rispetto all'esempio precedente.

Infine, se hai solo bisogno di tenere particolari carichi e negozi in ordine, puoi scrivere:

           Global
           atomic<int> x, y;

Thread 1                            Thread 2
x.store(17,memory_order_release);   cout << y.load(memory_order_acquire) << " ";
y.store(37,memory_order_release);   cout << x.load(memory_order_acquire) << endl;

Questo ci riporta ai carichi e ai negozi ordinati - quindi 37 0 non è più un output possibile - ma lo fa con un sovraccarico minimo. (In questo banale esempio, il risultato è lo stesso della consistenza sequenziale in piena regola, in un programma più grande, non lo sarebbe.)

Ovviamente, se le sole uscite che vuoi vedere sono 0 0 o 37 17 , puoi semplicemente racchiudere un mutex attorno al codice originale. Ma se hai letto fino a qui, scommetto che sai già come funziona, e questa risposta è già più lunga di quanto intendessi :-).

Quindi, linea di fondo. I mutex sono fantastici e il C ++ 11 li standardizza. Ma a volte per motivi di prestazioni vuoi le primitive di livello inferiore (ad esempio, il classico schema di blocco a doppio controllo ). Il nuovo standard fornisce gadget di alto livello come mutex e variabili di condizione e fornisce anche gadget di basso livello come i tipi atomici e i vari tipi di barriera della memoria. Quindi ora è possibile scrivere routine simultanee sofisticate e ad alte prestazioni interamente all'interno del linguaggio specificato dallo standard, e si può essere certi che il codice verrà compilato ed eseguito invariato sia sui sistemi attuali che su quelli di domani.

Anche se per essere sinceri, a meno che tu non sia un esperto e lavori su qualche serio codice di basso livello, dovresti probabilmente limitarti a mutex e condizionare le variabili. Questo è quello che intendo fare.

Per ulteriori informazioni su questa roba, consulta questo post sul blog .


Se usi mutex per proteggere tutti i tuoi dati, non dovresti preoccuparti. I mutex hanno sempre fornito sufficienti garanzie di ordine e visibilità.

Ora, se hai usato l'atomica o gli algoritmi lock-free, devi pensare al modello di memoria. Il modello di memoria descrive precisamente quando l'atomica fornisce garanzie di ordinamento e visibilità e fornisce recinti portatili per garanzie codificate a mano.

In precedenza, l'atomica veniva eseguita utilizzando i componenti intrinseci del compilatore o una libreria di livello superiore. Le recinzioni sarebbero state fatte usando istruzioni specifiche della CPU (barriere della memoria).


Significa che lo standard ora definisce multi-threading e definisce cosa succede nel contesto di più thread. Ovviamente, le persone hanno usato implementazioni diverse, ma è come chiedere perché dovremmo avere una std::string quando potremmo utilizzare una classe di string home-rolled.

Quando parli di thread POSIX o di thread di Windows, allora è un po 'un'illusione perché in realtà stai parlando di thread x86, poiché è una funzione hardware da eseguire contemporaneamente. Il modello di memoria C ++ 0x offre garanzie, sia su x86, sia su ARM, o MIPS , o qualsiasi altra cosa tu possa inventare.





memory-model