Comprensione di std :: atomic :: compare_exchange_weak () in C ++ 11


88
bool compare_exchange_weak (T& expected, T val, ..);

compare_exchange_weak()è una delle primitive di scambio di confronto fornite in C ++ 11. È debole nel senso che restituisce falso anche se il valore dell'oggetto è uguale a expected. Ciò è dovuto a errori spuri su alcune piattaforme in cui una sequenza di istruzioni (invece di una come su x86) viene utilizzata per implementarla. Su tali piattaforme, il cambio di contesto, il ricaricamento dello stesso indirizzo (o linea di cache) da parte di un altro thread, ecc.Possono fallire la primitiva. È spuriousperché non è il valore dell'oggetto (non uguale a expected) che fallisce l'operazione. Invece, è una specie di problemi di tempismo.

Ma ciò che mi lascia perplesso è ciò che viene detto nello standard C ++ 11 (ISO / IEC 14882),

29.6.5 .. Una conseguenza del fallimento spurio è che quasi tutti gli usi del confronto-e-scambio debole saranno in un ciclo.

Perché deve essere in un ciclo in quasi tutti gli usi ? Significa che eseguiremo un loop quando fallisce a causa di errori spuri? Se è così, perché ci preoccupiamo di usare compare_exchange_weak()e scrivere il ciclo noi stessi? Possiamo solo usare quello compare_exchange_strong()che penso dovrebbe sbarazzarci di falsi fallimenti per noi. Quali sono i casi d'uso comuni di compare_exchange_weak()?

Un'altra domanda correlata. Nel suo libro "C ++ Concurrency In Action", Anthony afferma:

//Because compare_exchange_weak() can fail spuriously, it must typically
//be used in a loop:

bool expected=false;
extern atomic<bool> b; // set somewhere else
while(!b.compare_exchange_weak(expected,true) && !expected);

//In this case, you keep looping as long as expected is still false,
//indicating that the compare_exchange_weak() call failed spuriously.

Perché !expectedc'è nella condizione di loop? Serve per impedire che tutti i thread possano morire di fame e non fare progressi per un po 'di tempo?

Modifica: (un'ultima domanda)

Su piattaforme in cui non esiste una singola istruzione CAS hardware, sia la versione debole che quella forte sono implementate utilizzando LL / SC (come ARM, PowerPC, ecc.). Quindi c'è qualche differenza tra i seguenti due loop? Perché, se ce ne sono? (Per me, dovrebbero avere prestazioni simili.)

// use LL/SC (or CAS on x86) and ignore/loop on spurious failures
while (!compare_exchange_weak(..))
{ .. }

// use LL/SC (or CAS on x86) and ignore/loop on spurious failures
while (!compare_exchange_strong(..)) 
{ .. }

Vengo fuori con quest'ultima domanda, voi tutti menzionate che forse c'è una differenza di prestazioni all'interno di un loop. È anche menzionato dallo standard C ++ 11 (ISO / IEC 14882):

Quando un confronto e scambio è in un ciclo, la versione debole produrrà prestazioni migliori su alcune piattaforme.

Ma come analizzato sopra, due versioni in un ciclo dovrebbero dare le stesse prestazioni / simili. Qual è la cosa che mi manca?


4
Con la prima domanda, in molti casi è necessario eseguire comunque il loop (sia che si utilizzi la versione forte o debole), e la versione debole potrebbe avere prestazioni migliori di quella forte.
TC

2
Sia il CAS debole che quello forte sono implementati "usando LL / SC", nello stesso modo in cui sia il bubble sort che il quicksort sono implementati "usando lo swap"; cioè, nel senso che questa è l'operazione primitiva usata per portare a termine il compito. Quello che avvolgono intorno LL / SC è molto diversa. Il CAS debole è solo LL / SC. Strong CAS è LL / SC con un sacco di altre cose.
Sneftel


@TuXiaomi con la risposta in quel link, non riesco a capire perché "la versione debole produrrà prestazioni migliori su alcune piattaforme", come dichiarato nello Standard.
Deqing

@Deqing Su altri, compare_exchange_weak può fallire in modo spurio, a causa di interruzioni o azioni di altri processori o thread. Su queste piattaforme, compare_exchange_strong è effettivamente un ciclo su compare_exchange_weak: se fallisce in modo spurio, ripete il ciclo. Aiuta? Forse mi sbaglio
Tu Xiaomi

Risposte:


75

Perché scambiare in un ciclo?

Di solito, vuoi che il tuo lavoro sia fatto prima di andare avanti, quindi, lo metti compare_exchange_weakin un ciclo in modo che cerchi di scambiare fino a quando non riesce (cioè, ritorna true).

Nota che compare_exchange_strongviene spesso utilizzato anche in un ciclo. Non fallisce a causa di errori spuri, ma fallisce a causa di scritture simultanee.

Perché usare al weakposto di strong?

Abbastanza facile: i guasti spuri non si verificano spesso, quindi non sono un grande problema di prestazioni. Al contrario, tollerare un tale errore consente un'implementazione molto più efficiente della weakversione (rispetto a strong) su alcune piattaforme: strongdeve sempre verificare la presenza di errori spuri e mascherarli. Questo è costoso.

Quindi, weakviene utilizzato perché è molto più veloce che strongsu alcune piattaforme

Quando dovresti usare weake quando strong?

Gli stati di riferimento suggeriscono quando weake quando utilizzare strong:

Quando un confronto e scambio è in un ciclo, la versione debole produrrà prestazioni migliori su alcune piattaforme. Quando un confronto e scambio debole richiederebbe un ciclo e uno forte no, quello forte è preferibile.

Quindi la risposta sembra essere abbastanza semplice da ricordare: se dovessi introdurre un ciclo solo a causa di errori spuri, non farlo; utilizzare strong. Se hai comunque un loop, usa weak.

Perché è !expectednell'esempio

Dipende dalla situazione e dalla semantica desiderata, ma di solito non è necessaria per la correttezza. Omettendolo si otterrebbe una semantica molto simile. Solo nel caso in cui un altro thread potrebbe reimpostare il valore false, la semantica potrebbe diventare leggermente diversa (ma non riesco a trovare un esempio significativo in cui lo vorresti). Vedi il commento di Tony D. per una spiegazione dettagliata.

È semplicemente un passaggio rapido quando un altro thread scrive true: quindi interrompiamo invece di provare a scrivere di truenuovo.

Sulla tua ultima domanda

Ma come analizzato sopra, due versioni in un ciclo dovrebbero dare le stesse prestazioni / simili. Qual è la cosa che mi manca?

Da Wikipedia :

Le implementazioni reali di LL / SC non sempre hanno successo se non ci sono aggiornamenti simultanei alla posizione di memoria in questione. Qualsiasi evento eccezionale tra le due operazioni, come un cambio di contesto, un altro collegamento di caricamento o anche (su molte piattaforme) un'altra operazione di caricamento o archiviazione, causerà un errore fittizio dell'archivio condizionale. Le implementazioni più vecchie falliranno se sono presenti aggiornamenti trasmessi sul bus di memoria.

Quindi, LL / SC fallirà in modo spurio al cambio di contesto, ad esempio. Ora, la versione forte avrebbe portato il suo "piccolo loop" per rilevare quel guasto spurio e mascherarlo riprovando. Si noti che questo ciclo personale è anche più complicato di un normale ciclo CAS, poiché deve distinguere tra errori spuri (e mascherarli) e errori dovuti all'accesso simultaneo (che si traduce in un ritorno con valore false). La versione debole non ha un tale ciclo.

Dato che fornisci un ciclo esplicito in entrambi gli esempi, semplicemente non è necessario avere il ciclo piccolo per la versione forte. Di conseguenza, nell'esempio con la strongversione, la verifica del fallimento viene eseguita due volte; una volta per compare_exchange_strong(che è più complicato poiché deve distinguere errori spuri e accessi simultanei) e una volta per il tuo ciclo. Questo costoso controllo non è necessario e il motivo per cui weaksarà più veloce qui.

Nota anche che il tuo argomento (LL / SC) è solo una delle possibilità per implementarlo. Ci sono più piattaforme che hanno set di istruzioni anche diversi. Inoltre (e cosa più importante), nota che std::atomicdeve supportare tutte le operazioni per tutti i possibili tipi di dati , quindi anche se dichiari uno struct da dieci milioni di byte, puoi usarlo compare_exchangesu questo. Anche quando su una CPU che ha CAS, non è possibile CAS di dieci milioni di byte, quindi il compilatore genererà altre istruzioni (probabilmente acquisizione del blocco, seguito da un confronto e scambio non atomico, seguito da un rilascio del blocco). Ora, pensa a quante cose possono accadere durante lo scambio di dieci milioni di byte. Quindi, mentre un errore spurio può essere molto raro per gli scambi di 8 byte, potrebbe essere più comune in questo caso.

Quindi, in poche parole, C ++ ti offre due semantiche, una "migliore sforzo" una ( weak) e una "Lo farò di sicuro, non importa quante cose brutte potrebbero accadere tra di loro" una ( strong). Il modo in cui questi vengono implementati su vari tipi di dati e piattaforme è un argomento completamente diverso. Non legare il tuo modello mentale all'implementazione sulla tua piattaforma specifica; la libreria standard è progettata per funzionare con più architetture di quante potresti essere a conoscenza. L'unica conclusione generale che possiamo trarre è che garantire il successo è solitamente più difficile (e quindi potrebbe richiedere un lavoro aggiuntivo) rispetto al solo tentativo e lasciare spazio a un possibile fallimento.


"Usa forte solo se non puoi tollerare errori spuri." - esiste davvero un algoritmo che distingue tra errori dovuti a scritture simultanee e errori spuri? Tutti quelli a cui riesco a pensare ci permettono di perdere gli aggiornamenti a volte o no, nel qual caso abbiamo comunque bisogno di un ciclo.
Voo

4
@Voo: risposta aggiornata. Ora sono inclusi i suggerimenti dal riferimento. Potrebbe esserci un algoritmo che fa una distinzione. Ad esempio, si consideri una semantica "si deve aggiornarla": l'aggiornamento di qualcosa deve essere eseguito esattamente una volta, quindi una volta che falliamo a causa di una scrittura simultanea, sappiamo che è stato qualcun altro e possiamo abortire. Se falliamo a causa di un errore falso, nessuno lo ha aggiornato, quindi dobbiamo riprovare.
gexicide l'

8
" Perché è! Previsto nell'esempio? Non è necessario per la correttezza. Ometterlo produrrebbe la stessa semantica." - non è così ... se diciamo che il primo scambio fallisce perché trova bgià true, allora - con expectedora true- senza di && !expectedesso fa un ciclo e prova un altro (stupido) scambio di truee trueche potrebbe "riuscire" banalmente rompendo dal whileciclo, ma potrebbe esibire un comportamento significativamente diverso se bnel frattempo fosse cambiato di nuovo in false, nel qual caso il ciclo continuerebbe e alla fine potrebbe reimpostarsi di b true nuovo prima di interrompersi.
Tony Delroy

@ TonyD: Giusto, dovrei chiarirlo.
gexicide l'

Scusate ragazzi, ho aggiunto un'altra ultima domanda;)
Eric Z

18

Sto cercando di rispondere da solo, dopo aver esaminato varie risorse online (ad esempio, questa e questa ), lo standard C ++ 11, nonché le risposte fornite qui.

Le domande correlate vengono unite (ad esempio, " perché! Atteso? " Viene unito a "perché mettere compare_exchange_weak () in un ciclo? ") E le risposte vengono fornite di conseguenza.


Perché compare_exchange_weak () deve essere in un ciclo in quasi tutti gli usi?

Pattern tipico A

È necessario ottenere un aggiornamento atomico basato sul valore nella variabile atomica. Un errore indica che la variabile non è stata aggiornata con il valore desiderato e si desidera riprovare. Nota che non ci interessa davvero se fallisce a causa di scritture simultanee o errori spuri. Ma ci interessa che siamo noi a fare questo cambiamento.

expected = current.load();
do desired = function(expected);
while (!current.compare_exchange_weak(expected, desired));

Un esempio del mondo reale è che diversi thread aggiungono contemporaneamente un elemento a un elenco collegato singolarmente. Ogni thread carica prima il puntatore della testina, alloca un nuovo nodo e aggiunge la testina a questo nuovo nodo. Infine, prova a scambiare il nuovo nodo con la testa.

Un altro esempio è implementare mutex usando std::atomic<bool>. Al massimo un thread può entrare nella sezione critica alla volta, a seconda di quale thread è stato impostato currentper primo trueed uscire dal ciclo.

Pattern tipico B

Questo è in realtà lo schema menzionato nel libro di Anthony. Contrariamente al modello A, vuoi che la variabile atomica venga aggiornata una volta, ma non ti interessa chi lo fa. Finché non è aggiornato, riprova. Viene generalmente utilizzato con le variabili booleane. Ad esempio, è necessario implementare un trigger per far andare avanti una macchina a stati. Quale filo tira il grilletto è indipendentemente.

expected = false;
// !expected: if expected is set to true by another thread, it's done!
// Otherwise, it fails spuriously and we should try again.
while (!current.compare_exchange_weak(expected, true) && !expected);

Nota che generalmente non possiamo usare questo modello per implementare un mutex. In caso contrario, più thread potrebbero trovarsi all'interno della sezione critica allo stesso tempo.

Detto questo, dovrebbe essere raro da usare compare_exchange_weak()al di fuori di un ciclo. Al contrario, ci sono casi in cui è in uso la versione forte. Per esempio,

bool criticalSection_tryEnter(lock)
{
  bool flag = false;
  return lock.compare_exchange_strong(flag, true);
}

compare_exchange_weak non è corretto qui perché quando ritorna a causa di un guasto spurio, è probabile che nessuno occupi ancora la sezione critica.

Filo di fame?

Un punto degno di nota è che cosa succede se continuano a verificarsi errori spuri, facendo così morire di fame il filo? Teoricamente potrebbe accadere sulle piattaforme quando compare_exchange_XXX()è implementato come una sequenza di istruzioni (ad esempio, LL / SC). L'accesso frequente alla stessa linea di cache tra LL e SC produrrà continui errori spuri. Un esempio più realistico è dovuto a una pianificazione stupida in cui tutti i thread simultanei vengono intercalati nel modo seguente.

Time
 |  thread 1 (LL)
 |  thread 2 (LL)
 |  thread 1 (compare, SC), fails spuriously due to thread 2's LL
 |  thread 1 (LL)
 |  thread 2 (compare, SC), fails spuriously due to thread 1's LL
 |  thread 2 (LL)
 v  ..

Può succedere?

Non succederà per sempre, fortunatamente, grazie a ciò che richiede C ++ 11:

Le implementazioni dovrebbero garantire che le operazioni di confronto e scambio deboli non restituiscano costantemente false a meno che l'oggetto atomico non abbia un valore diverso da quello previsto o non vi siano modifiche simultanee all'oggetto atomico.

Perché ci preoccupiamo di usare compare_exchange_weak () e scrivere il ciclo noi stessi? Possiamo semplicemente usare compare_exchange_strong ().

Dipende.

Caso 1: quando entrambi devono essere utilizzati all'interno di un loop. C ++ 11 dice:

Quando un confronto e scambio è in un ciclo, la versione debole produrrà prestazioni migliori su alcune piattaforme.

Su x86 (almeno attualmente. Forse un giorno ricorrerà a uno schema simile come LL / SC per le prestazioni quando vengono introdotti più core), la versione debole e forte sono essenzialmente le stesse perché entrambe si riducono alla singola istruzione cmpxchg. Su alcune altre piattaforme in cui compare_exchange_XXX()non è implementato atomicamente (qui significa che non esiste una singola primitiva hardware), la versione debole all'interno del ciclo potrebbe vincere la battaglia perché quella forte dovrà gestire i guasti spuri e riprovare di conseguenza.

Ma,

raramente, si può preferire compare_exchange_strong()sopra compare_exchange_weak(), anche in un ciclo. Ad esempio, quando ci sono molte cose da fare tra la variabile atomica viene caricata e un nuovo valore calcolato viene scambiato (vedi function()sopra). Se la variabile atomica stessa non cambia frequentemente, non è necessario ripetere il costoso calcolo per ogni errore spurio. Invece, possiamo sperare che compare_exchange_strong()"assorbano" tali errori e ripetiamo il calcolo solo quando fallisce a causa di un cambiamento di valore reale.

Caso 2: quando compare_exchange_weak() deve essere utilizzato solo all'interno di un loop. C ++ 11 dice anche:

Quando un confronto e scambio debole richiederebbe un ciclo e uno forte no, quello forte è preferibile.

Questo è in genere il caso in cui si esegue il ciclo solo per eliminare errori spuri dalla versione debole. Riprovare fino a quando lo scambio non ha esito positivo o negativo a causa della scrittura simultanea.

expected = false;
// !expected: if it fails spuriously, we should try again.
while (!current.compare_exchange_weak(expected, true) && !expected);

Nella migliore delle ipotesi, sta reinventando le ruote e ha le stesse prestazioni di compare_exchange_strong(). Peggio? Questo approccio non riesce a sfruttare appieno le macchine che forniscono un confronto e uno scambio non spuri nell'hardware .

Infine, se si esegue un ciclo per altre cose (ad esempio, vedere "Modello tipico A" sopra), allora ci sono buone possibilità che compare_exchange_strong()venga inserito in un ciclo, il che ci riporta al caso precedente.


17

Perché deve essere in un ciclo in quasi tutti gli usi ?

Perché se non esegui il loop e fallisce in modo spurio il tuo programma non ha fatto nulla di utile - non hai aggiornato l'oggetto atomico e non sai qual è il suo valore corrente (Correzione: vedi il commento di Cameron sotto). Se la chiamata non fa nulla di utile, che senso ha farlo?

Significa che eseguiremo un loop quando fallisce a causa di errori spuri?

Sì.

Se è così, perché ci preoccupiamo di usare compare_exchange_weak()e scrivere il ciclo noi stessi? Possiamo semplicemente usare compare_exchange_strong () che penso dovrebbe sbarazzarci di errori spuri per noi. Quali sono i casi d'uso comuni di compare_exchange_weak ()?

Su alcune architetture compare_exchange_weakè più efficiente e gli errori spuri dovrebbero essere abbastanza rari, quindi potrebbe essere possibile scrivere algoritmi più efficienti utilizzando la forma debole e un ciclo.

In generale è probabilmente meglio usare la versione forte invece se il tuo algoritmo non ha bisogno di loop, poiché non devi preoccuparti di errori spuri. Se deve comunque eseguire il ciclo anche per la versione forte (e molti algoritmi devono comunque eseguire il ciclo), l'utilizzo della forma debole potrebbe essere più efficiente su alcune piattaforme.

Perché !expectedc'è nella condizione di loop?

Il valore potrebbe essere stato impostato trueda un altro thread, quindi non si desidera continuare a eseguire il loop cercando di impostarlo.

Modificare:

Ma come analizzato sopra, due versioni in un ciclo dovrebbero dare le stesse prestazioni / simili. Qual è la cosa che mi manca?

Sicuramente è ovvio che su piattaforme in cui è possibile un errore spurio, l'implementazione di compare_exchange_strongdeve essere più complicata, verificare la presenza di errori spuri e riprovare.

La forma debole ritorna solo in caso di errore spurio, non riprova.


2
+1 Effettivamente accurato su tutti i punti (di cui la Q ha un disperato bisogno).
Tony Delroy

Circa you don't know what its current value isnel primo punto, quando si verifica un guasto spurio, il valore corrente non dovrebbe essere uguale al valore atteso in quell'istante? Altrimenti, sarebbe un vero fallimento.
Eric Z

IMO, sia la versione debole che quella forte sono implementate utilizzando LL / SC su piattaforme in cui non esiste una singola primitiva hardware CAS. Quindi per me perché c'è qualche differenza di prestazioni tra while(!compare_exchange_weak(..))e while(!compare_exchange_strong(..))?
Eric Z

Scusate ragazzi, ho aggiunto un'altra ultima domanda.
Eric Z

1
@ Jonathan: Solo un nitpick, ma si fa conoscere il valore corrente se non riesce falsamente (naturalmente, se questo è ancora il valore corrente per il tempo di leggere la variabile è un altro problema del tutto, ma questo è a prescindere dalla debole / forte). L'ho usato, ad esempio, per tentare di impostare una variabile assumendo che il suo valore sia nullo, e se fallisce (in modo spurio o no) continua a provare ma solo a seconda del valore effettivo.
Cameron

13

Va bene, quindi ho bisogno di una funzione che esegua lo spostamento a sinistra atomico. Il mio processore non ha un'operazione nativa per questo e la libreria standard non ha una funzione per questo, quindi sembra che sto scrivendo la mia. Ecco qui:

void atomicLeftShift(std::atomic<int>* var, int shiftBy)
{
    do {
        int oldVal = std::atomic_load(var);
        int newVal = oldVal << shiftBy;
    } while(!std::compare_exchange_weak(oldVal, newVal));
}

Ora, ci sono due ragioni per cui il ciclo potrebbe essere eseguito più di una volta.

  1. Qualcun altro ha cambiato la variabile mentre stavo facendo il mio turno di sinistra. I risultati del mio calcolo non dovrebbero essere applicati alla variabile atomica, perché cancellerebbe effettivamente la scrittura di qualcun altro.
  2. La mia CPU ruttò e il CAS debole fallì falsamente.

Onestamente non mi interessa quale. Il cambio di marcia a sinistra è abbastanza veloce da poterlo fare di nuovo, anche se il fallimento è stato spurio.

Ciò che è meno veloce, tuttavia, è il codice extra che un CAS forte deve avvolgere attorno a CAS debole per essere forte. Quel codice non fa molto quando il CAS debole ha successo ... ma quando fallisce, il CAS forte deve fare un po 'di lavoro investigativo per determinare se si trattava del caso 1 o del caso 2. Quel lavoro di investigazione assume la forma di un secondo ciclo, efficacemente all'interno del mio ciclo. Due loop annidati. Immagina che il tuo insegnante di algoritmi ti stia fissando in questo momento.

E come ho già detto, non mi interessa il risultato di quel lavoro di investigazione! Ad ogni modo rifarò il CAS. Quindi usare un CAS forte non mi guadagna proprio nulla e mi fa perdere una piccola ma misurabile quantità di efficienza.

In altre parole, il CAS debole viene utilizzato per implementare operazioni di aggiornamento atomico. Strong CAS viene utilizzato quando ci si preoccupa del risultato di CAS.


0

Penso che la maggior parte delle risposte di cui sopra affronti "errori spuri" come una sorta di problema, compromesso tra prestazioni e correttezza.

Può essere visto come la versione debole è più veloce la maggior parte delle volte, ma in caso di guasto spurio, diventa più lenta. E la versione forte è una versione che non ha possibilità di falsi guasti, ma è quasi sempre più lenta.

Per me, la differenza principale è come queste due versioni gestiscono il problema ABA:

la versione debole avrà successo solo se nessuno ha toccato la linea della cache tra il caricamento e l'archivio, quindi rileverà al 100% il problema ABA.

la versione forte fallirà solo se il confronto fallisce, quindi non rileverà il problema ABA senza misure aggiuntive.

Quindi, in teoria, se si utilizza una versione debole su un'architettura ordinata debole, non è necessario il meccanismo di rilevamento ABA e l'implementazione sarà molto più semplice, offrendo prestazioni migliori.

Ma, su x86 (architettura ordinata forte), la versione debole e la versione forte sono le stesse ed entrambe soffrono di problemi ABA.

Quindi, se scrivi un algoritmo completamente multipiattaforma, devi comunque affrontare il problema ABA, quindi non ci sono vantaggi in termini di prestazioni dall'utilizzo della versione debole, ma c'è una penalizzazione delle prestazioni per la gestione di errori spuri.

In conclusione, per motivi di portabilità e prestazioni, la versione forte è sempre un'opzione migliore o uguale.

La versione debole può essere un'opzione migliore solo se ti consente di saltare completamente le contromisure ABA o se il tuo algoritmo non si preoccupa dell'ABA.

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.