È necessario bloccare un .NET Int32 durante la lettura in un codice con multithreading?

Stavo leggendo il seguente articolo: http://msdn.microsoft.com/en-us/magazine/cc817398.aspx “Risoluzione di 11 probabili problemi nel tuo codice multithreaded” di Joe Duffy

E mi ha sollevato una domanda: “Abbiamo bisogno di bloccare un .NET Int32 durante la lettura in un codice multithreaded?”

Capisco che se fosse un Int64 in un SO a 32 bit potrebbe strappare, come è spiegato nell’articolo. Ma per Int32 ho immaginato la seguente situazione:

class Test { private int example = 0; private Object thisLock = new Object(); public void Add(int another) { lock(thisLock) { example += another; } } public int Read() { return example; } } 

Non vedo un motivo per includere un blocco nel metodo di lettura. Fai?

Aggiornamento Basato sulle risposte (di Jon Skeet e ctacke) Capisco che il codice sopra sia ancora vulnerabile alla memorizzazione nella cache di più processori (ogni processore ha una propria cache, non sincronizzata con altri). Tutte e tre le modifiche seguenti risolvono il problema:

  1. Aggiungendo a “esempio int” la proprietà “volatile”
  2. Inserimento di una discussione.MemoryBarrier (); prima della lettura effettiva di “esempio int”
  3. Leggi “esempio int” all’interno di un “lock (thisLock)”

E penso anche che “volatile” sia la soluzione più elegante.

Il blocco compie due cose:

  • Funziona come un mutex, quindi puoi assicurarti che solo un thread modifichi un insieme di valori alla volta.
  • Fornisce barriere di memoria (acquisisce / rilascia semantica) che assicura che le scritture di memoria fatte da un thread siano visibili in un altro.

La maggior parte delle persone capisce il primo punto, ma non il secondo. Supponi di aver usato il codice nella domanda da due thread diversi, con una discussione che chiama Add ripetutamente e un’altra chiamata che chiama Read . L’atomicità di per sé garantirebbe che si finisse per leggere un multiplo di 8 – e se ci fossero due thread che chiamano Add tuo blocco ti assicurerebbero di non “perdere” alcuna aggiunta. Tuttavia, è ansible che il tuo thread Read abbia sempre letto solo 0, anche dopo che Add stato chiamato più volte. Senza alcuna barriera di memoria, il JIT poteva semplicemente memorizzare il valore in un registro e presumere che non fosse cambiato tra le letture. Il punto di una barriera di memoria è quello di assicurarsi che qualcosa sia realmente scritto nella memoria principale, o realmente letto dalla memoria principale.

I modelli di memoria possono diventare piuttosto pelosi, ma se segui la semplice regola di togliere un lucchetto ogni volta che vuoi accedere ai dati condivisi (per leggere o scrivere) starai bene. Vedi la parte volatilità / atomicità del mio tutorial di threading per maggiori dettagli.

Tutto dipende dal contesto. Quando si ha a che fare con tipi interi o riferimenti si potrebbe voler utilizzare membri della class System.Threading.Interlocked .

Un utilizzo tipico come:

 if( x == null ) x = new X(); 

Può essere sostituito con una chiamata a Interlocked.CompareExchange () :

 Interlocked.CompareExchange( ref x, new X(), null); 

Interlocked.CompareExchange () garantisce che il confronto e lo scambio avvengano come un’operazione atomica.

Altri membri della class Interlocked, come Add () , Decrement () , Exchange () , Increment () e Read () eseguono tutte le rispettive operazioni atomicamente. Leggi la documentazione su MSDN.

Dipende esattamente da come utilizzerai il numero a 32 bit.

Se volessi eseguire un’operazione come:

 i++; 

Che implicitamente si rompe in

  1. leggendo il valore di i
  2. aggiungendo uno
  3. memorizzando i

Se un altro thread modifica i dopo 1, ma prima di 3, allora hai un problema in cui avevo 7 anni, ne aggiungi uno e ora è 492.

Ma se stai semplicemente leggendo i, o eseguendo una singola operazione, ad esempio:

 i = 8; 

quindi non è necessario bloccare i.

Ora, la tua domanda dice “… devi bloccare un .NET Int32 mentre lo leggi …” ma il tuo esempio implica leggere e scrivere su un Int32.

Quindi, dipende da cosa stai facendo.

Avere solo 1 blocco del filo non porta a termine nulla. Lo scopo del blocco è bloccare altri thread, ma non funziona se nessun altro controlla il blocco!

Ora, non è necessario preoccuparsi della corruzione della memoria con un int a 32 bit, perché la scrittura è atomica, ma ciò non significa necessariamente che si può andare in modalità lock-free.

Nel tuo esempio, è ansible ottenere una semantica discutibile:

 example = 10 Thread A: Add(10) read example (10) Thread B: Read() read example (10) Thread A: write example (10 + 10) 

il che significa che ThreadB ha iniziato a leggere il valore dell’esempio dopo che il thread A ha iniziato l’aggiornamento, ma ha letto il valore pre-aggiornato. Suppongo che sia un problema o meno dipendere da cosa dovrebbe fare questo codice.

Poiché questo è un codice di esempio, potrebbe essere difficile vedere il problema lì. Ma, immagina la funzione contatore canonica:

  class Counter { static int nextValue = 0; static IEnumerable GetValues(int count) { var r = Enumerable.Range(nextValue, count); nextValue += count; return r; } } 

Quindi, il seguente scenario:

  nextValue = 9; Thread A: GetValues(10) r = Enumerable.Range(9, 10) Thread B: GetValues(5) r = Enumerable.Range(9, 5) nextValue += 5 (now equals 14) Thread A: nextValue += 10 (now equals 24) 

Il nextValue viene incrementato correttamente, ma gli intervalli restituiti si sovrappongono. I valori di 19 – 24 non sono mai stati restituiti. Puoi risolvere questo problema bloccando l’assegnazione var r e nextValue per impedire l’esecuzione di qualsiasi altro thread allo stesso tempo.

Il blocco è necessario se ne hai bisogno per essere atomico. Leggere e scrivere (come un’operazione accoppiata, come quando si esegue un i ++), non è garantito che un numero a 32 bit sia atomico a causa della memorizzazione nella cache. Inoltre, una persona che legge o scrive non va necessariamente a un registro (volatilità). Rendere volatile non ti dà alcuna garanzia di atomicità se hai il desiderio di modificare il numero intero (ad esempio un’operazione di lettura, incremento, scrittura). Per i numeri interi un mutex o un monitor può essere troppo pesante (dipende dal tuo caso d’uso) ed è a questo che serve la class Interlocked . Garantisce l’atomicità di questi tipi di operazioni.

in generale, i blocchi sono richiesti solo quando il valore verrà modificato

EDIT: l’eccellente sintesi di Mark Brackett è più azzeccata:

“I blocchi sono necessari quando si desidera che un’operazione altrimenti non atomica sia atomica”

in questo caso, leggere un numero intero a 32 bit su una macchina a 32 bit è presumibilmente già un’operazione atomica … ma forse no! Forse potrebbe essere necessaria la parola chiave volatile .