La necessità di un modificatore volatile nel blocco a doppio controllo in .NET


85

Testi multipli dicono che quando si implementa il blocco con doppio controllo in .NET, il campo su cui si sta bloccando dovrebbe avere un modificatore volatile applicato. Ma perché esattamente? Considerando il seguente esempio:

public sealed class Singleton
{
   private static volatile Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         if (instance == null) 
         {
            lock (syncRoot) 
            {
               if (instance == null) 
                  instance = new Singleton();
            }
         }

         return instance;
      }
   }
}

perché "lock (syncRoot)" non realizza la necessaria consistenza della memoria? Non è vero che dopo l'istruzione "lock" sia la lettura che la scrittura sarebbero volatili e quindi si otterrebbe la necessaria coerenza?


2
Questo è già stato masticato molte volte. yoda.arachsys.com/csharp/singleton.html
Hans Passant

1
Sfortunatamente in quell'articolo Jon fa riferimento a "volatile" due volte e nessuno dei due fa riferimento direttamente agli esempi di codice che ha fornito.
Dan Esparza

Vedi questo articolo per capire il problema: igoro.com/archive/volatile-keyword-in-c-memory-model-explained Fondamentalmente è TEORICAMENTE possibile per JIT utilizzare un registro CPU per la variabile di istanza, specialmente se hai bisogno di un piccolo codice extra in là. Pertanto, eseguire un'istruzione if due volte potenzialmente potrebbe restituire lo stesso valore indipendentemente dal fatto che cambi su un altro thread. In realtà la risposta è un po 'complessa, l'istruzione lock può o non può essere responsabile di migliorare le cose qui (continua)
user2685937

(vedi commento precedente continua) - Ecco cosa penso stia realmente accadendo - Fondamentalmente qualsiasi codice che fa qualcosa di più complesso di leggere o impostare una variabile può attivare il JIT per dire, dimentica di provare a ottimizzare questo, carichiamo e salviamo in memoria perché se una funzione viene chiamata, il JIT dovrebbe potenzialmente salvare e ricaricare il registro se lo memorizzava lì ogni volta, invece di farlo semplicemente scrive e legge direttamente dalla memoria ogni volta. Come faccio a sapere che il lucchetto non è niente di speciale? Guarda il link che ho pubblicato nel commento precedente di Igor (continua commento successivo)
user2685937

(vedi sopra 2 commenti) - Ho testato il codice di Igor e quando crea un nuovo thread ho aggiunto un lucchetto attorno ad esso e l'ho persino fatto loop. Continuerebbe a non causare l'uscita del codice perché la variabile di istanza è stata tolta dal ciclo. Aggiungendo al ciclo while un semplice set di variabili locali ancora ha sollevato la variabile fuori dal ciclo - Ora qualcosa di più complicato come istruzioni if ​​o una chiamata di metodo o sì anche una chiamata di blocco impedirebbe l'ottimizzazione e quindi la farebbe funzionare. Pertanto, qualsiasi codice complesso spesso forza l'accesso diretto alle variabili anziché consentire l'ottimizzazione di JIT. (continua commento successivo)
user2685937

Risposte:


60

Volatile non è necessario. Beh, una specie di **

volatileviene utilizzato per creare una barriera di memoria * tra le letture e le scritture sulla variabile.
lock, quando viene utilizzato, provoca la creazione di barriere di memoria attorno al blocco all'interno di lock, oltre a limitare l'accesso al blocco a un thread.
Le barriere di memoria fanno in modo che ogni thread legga il valore più corrente della variabile (non un valore locale memorizzato nella cache in qualche registro) e che il compilatore non riordini le istruzioni. L'uso volatilenon è necessario ** perché hai già un lucchetto.

Joseph Albahari spiega queste cose in modo migliore di quanto potrei mai fare.

E assicurati di controllare la guida di Jon Skeet per implementare il singleton in C #


update :
* volatilefa sì che le letture della variabile siano se le VolatileReadscritture siano VolatileWrites, che su x86 e x64 su CLR, sono implementate con un file MemoryBarrier. Possono essere a grana più fine su altri sistemi.

** la mia risposta è corretta solo se utilizzi CLR su processori x86 e x64. Esso potrebbe essere vero in altri modelli di memoria, come su Mono (e altre implementazioni), Itanium64 e l'hardware futuro. Questo è ciò a cui si riferisce Jon nel suo articolo nei "trucchi" per il bloccaggio a doppio controllo.

Fare uno dei {contrassegnare la variabile come volatile, leggerlo con Thread.VolatileReado inserire una chiamata a Thread.MemoryBarrier} potrebbe essere necessario affinché il codice funzioni correttamente in una situazione di modello di memoria debole.

Da quanto ho capito, su CLR (anche su IA64), le scritture non vengono mai riordinate (le scritture hanno sempre la semantica di rilascio). Tuttavia, su IA64, le letture possono essere riordinate prima delle scritture, a meno che non siano contrassegnate come volatili. Sfortunatamente, non ho accesso all'hardware IA64 con cui giocare, quindi qualsiasi cosa dica al riguardo sarebbe una speculazione.

Ho anche trovato utili questi articoli:
http://www.codeproject.com/KB/tips/MemoryBarrier.aspx
articolo di vance morrison (tutto si collega a questo, parla del blocco a doppio controllo)
articolo di chris brumme (tutto si collega a questo )
Joe Duffy: Varianti non funzionanti della chiusura a doppio controllo

La serie di luis abreu sul multithreading offre anche una bella panoramica dei concetti
http://msmvps.com/blogs/luisabreu/archive/2009/06/29/multithreading-load-and-store-reordering.aspx
http: // msmvps. com / blogs / luisabreu / archive / 2009/07/03 / multithreading-introducing-memory-fences.aspx


Jon Skeet in realtà afferma che il modificatore volatile è necessario per creare una memoria più varia, mentre l'autore del primo collegamento afferma che il blocco (Monitor.Enter) sarebbe sufficiente. Chi ha effettivamente ragione ???
Konstantin,

@Konstantin sembra che Jon si riferisse al modello di memoria sui processori Itanium 64, quindi in quel caso potrebbe essere necessario usare volatile. Tuttavia, volatile non è necessario sui processori x86 e x64. Aggiornerò di più tra un po '.
dan

Se il blocco sta effettivamente creando una barriera di memoria e se la memoria più varia riguarda sia l'ordine delle istruzioni che l'invalidazione della cache, allora dovrebbe funzionare su tutti i processori. Ad ogni modo è così strano che una cosa così basilare causi così tanta confusione ...
Konstantin

2
Questa risposta mi sembra sbagliata. Se volatilenon era necessario su qualsiasi piattaforma, allora vorrebbe dire JIT non poteva ottimizzare i carichi di memoria object s1 = syncRoot; object s2 = syncRoot;a object s1 = syncRoot; object s2 = s1;su quella piattaforma. Mi sembra molto improbabile.
user541686

1
Anche se il CLR non riordinasse le scritture (dubito che sia vero, molte ottime ottimizzazioni possono essere fatte in questo modo), sarebbe comunque bacato fintanto che possiamo inline la chiamata del costruttore e creare l'oggetto sul posto (potremmo vedere un oggetto inizializzato a metà). Indipendentemente da qualsiasi modello di memoria utilizzato dalla CPU sottostante! Secondo Eric Lippert, il CLR su Intel introduce almeno un membroarrier dopo i costruttori che nega tale ottimizzazione, ma non è richiesto dalle specifiche e non conterei che la stessa cosa succeda su ARM, ad esempio ..
Voo

34

C'è un modo per implementarlo senza volatile campo. Lo spiego io ...

Penso che sia pericoloso riordinare l'accesso alla memoria all'interno del blocco, in modo tale da poter ottenere un'istanza non inizializzata completamente al di fuori del blocco. Per evitare ciò, faccio questo:

public sealed class Singleton
{
   private static Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         // very fast test, without implicit memory barriers or locks
         if (instance == null)
         {
            lock (syncRoot)
            {
               if (instance == null)
               {
                    var temp = new Singleton();

                    // ensures that the instance is well initialized,
                    // and only then, it assigns the static variable.
                    System.Threading.Thread.MemoryBarrier();
                    instance = temp;
               }
            }
         }

         return instance;
      }
   }
}

Capire il codice

Immagina che ci sia del codice di inizializzazione all'interno del costruttore della classe Singleton. Se queste istruzioni vengono riordinate dopo che il campo è stato impostato con l'indirizzo del nuovo oggetto, allora hai un'istanza incompleta ... immagina che la classe abbia questo codice:

private int _value;
public int Value { get { return this._value; } }

private Singleton()
{
    this._value = 1;
}

Ora immagina una chiamata al costruttore usando l'operatore new:

instance = new Singleton();

Questo può essere esteso a queste operazioni:

ptr = allocate memory for Singleton;
set ptr._value to 1;
set Singleton.instance to ptr;

E se riordino queste istruzioni in questo modo:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
set ptr._value to 1;

Fa differenza? NO se pensi a un solo thread. se pensi a più thread ... cosa succede se il thread viene interrotto subito dopo set instance to ptr:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
-- thread interruped here, this can happen inside a lock --
set ptr._value to 1; -- Singleton.instance is not completelly initialized

Questo è ciò che la barriera della memoria evita, non consentendo il riordino dell'accesso alla memoria:

ptr = allocate memory for Singleton;
set temp to ptr; // temp is a local variable (that is important)
set ptr._value to 1;
-- memory barrier... cannot reorder writes after this point, or reads before it --
-- Singleton.instance is still null --
set Singleton.instance to temp;

Buona programmazione!


1
Se il CLR consente l'accesso a un oggetto prima che venga inizializzato, si tratta di una falla di sicurezza. Immagina una classe privilegiata il cui unico costruttore pubblico imposta "SecureMode = 1" e quindi i cui metodi di istanza lo verificano. Se puoi chiamare questi metodi di istanza senza che il costruttore sia in esecuzione, puoi uscire dal modello di sicurezza e violare il sandboxing.
MichaelGG

1
@MichaelGG: nel caso che hai descritto, se quella classe supporterà più thread per accedervi, allora è un problema. Se la chiamata del costruttore è inline dal jitter, è possibile che la CPU riordini le istruzioni in modo che il riferimento memorizzato punti a un'istanza non completamente inizializzata. Questo non è un problema di sicurezza CLR perché è evitabile, è responsabilità del programmatore utilizzare: interlock, barriere di memoria, blocchi e / o campi volatili, all'interno del costruttore di tale classe.
Miguel Angelo

2
Una barriera all'interno del ctor non lo risolve. Se il CLR assegna il riferimento all'oggetto appena allocato prima che il ctor abbia completato e non inserisce un membarrier, un altro thread potrebbe eseguire un metodo di istanza su un oggetto semi-inizializzato.
MichaelGG

Questo è il "modello alternativo" suggerito da ReSharper 2016/2017 in caso di DCL in C #. OTOH, Java fa garanzia che il risultato di newè completamente inizializzato ..
user2864740

So che l'implementazione di MS .net pone una barriera di memoria alla fine del costruttore ... ma è meglio prevenire che curare.
Miguel Angelo

7

Non credo che nessuno abbia effettivamente risposto alla domanda , quindi proverò.

Il volatile e il primo if (instance == null)non sono "necessari". Il blocco renderà questo codice thread-safe.

Quindi la domanda è: perché dovresti aggiungere il primo if (instance == null)?

Il motivo è presumibilmente quello di evitare di eseguire inutilmente la sezione di codice bloccata. Mentre si esegue il codice all'interno del blocco, qualsiasi altro thread che tenta di eseguire anche quel codice viene bloccato, il che rallenterà il programma se si tenta di accedere frequentemente al singleton da molti thread. A seconda della lingua / piattaforma, potrebbero esserci anche dei sovraccarichi dalla serratura stessa che si desidera evitare.

Quindi il primo controllo nullo viene aggiunto come un modo molto rapido per vedere se è necessario il blocco. Se non è necessario creare il singleton, è possibile evitare completamente il blocco.

Ma non puoi controllare se il riferimento è nullo senza bloccarlo in qualche modo, perché a causa del caching del processore, un altro thread potrebbe cambiarlo e tu leggeresti un valore "non aggiornato" che ti porterebbe a inserire il blocco inutilmente. Ma stai cercando di evitare una serratura!

Quindi rendi il singleton volatile per assicurarti di leggere il valore più recente, senza la necessità di utilizzare un blocco.

Hai ancora bisogno del lucchetto interno perché volatile ti protegge solo durante un singolo accesso alla variabile: non puoi testarlo e impostarlo in modo sicuro senza utilizzare un lucchetto.

Ora, è davvero utile?

Beh, direi "nella maggior parte dei casi, no".

Se Singleton.Instance potesse causare inefficienza a causa dei blocchi, allora perché lo chiami così spesso che questo sarebbe un problema significativo ? Il punto centrale di un singleton è che ce n'è solo uno, quindi il tuo codice può leggere e memorizzare nella cache il riferimento singleton una volta.

L'unico caso in cui riesco a pensare a dove questa memorizzazione nella cache non sarebbe possibile sarebbe quando si dispone di un numero elevato di thread (ad esempio un server che utilizza un nuovo thread per elaborare ogni richiesta potrebbe creare milioni di thread a esecuzione molto breve, ciascuno dei che dovrebbe chiamare Singleton.Instance una volta).

Quindi sospetto che il doppio controllo del blocco sia un meccanismo che ha un posto reale in casi molto specifici critici per le prestazioni, e quindi tutti si sono arrampicati sul carro del "questo è il modo corretto di farlo" senza realmente pensare a cosa fa e se sarà effettivamente necessario nel caso in cui lo stiano usando.


6
Questo è da qualche parte tra sbagliato e mancato punto. volatilenon ha nulla a che fare con la semantica del blocco nel blocco a doppio controllo, ha a che fare con il modello di memoria e la coerenza della cache. Il suo scopo è garantire che un thread non riceva un valore che è ancora inizializzato da un altro thread, cosa che il modello di blocco del doppio controllo non impedisce intrinsecamente. In Java hai sicuramente bisogno della volatileparola chiave; in .NET è oscuro, perché è sbagliato secondo ECMA ma corretto secondo il runtime. Ad ogni modo, locksicuramente non se ne occupa.
Aaronaught

Eh? Non riesco a vedere dove la tua dichiarazione non è d'accordo con quello che ho detto, né ho detto che il volatile è in alcun modo correlato alla semantica del blocco.
Jason Williams

6
La tua risposta, come molte altre affermazioni in questo thread, afferma che lockrende il codice thread-safe. Quella parte è vera, ma il modello di blocco del doppio controllo può renderlo pericoloso . È quello che sembra che ti manchi. Questa risposta sembra serpeggiare sul significato e lo scopo di un doppio controllo del blocco senza mai affrontare i problemi di sicurezza del thread che ne sono la ragione volatile.
Aaronaught

1
Come può essere pericoloso se instanceè contrassegnato con volatile?
UserControl

5

È necessario utilizzare volatile con il modello di blocco del doppio controllo.

La maggior parte delle persone indica questo articolo come prova che non hai bisogno di volatile: https://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10

Ma non riescono a leggere fino alla fine: " Un'ultima parola di avvertimento - Sto solo indovinando il modello di memoria x86 dal comportamento osservato sui processori esistenti. Pertanto anche le tecniche di blocco basso sono fragili perché l'hardware e i compilatori possono diventare più aggressivi nel tempo . Ecco alcune strategie per ridurre al minimo l'impatto di questa fragilità sul tuo codice. Innanzitutto, quando possibile, evita tecniche di low-lock. (...) Infine, ipotizza il modello di memoria più debole possibile, utilizzando dichiarazioni volatili invece di affidarti a garanzie implicite . "

Se hai bisogno di più convincimento, leggi questo articolo sulle specifiche ECMA che verranno utilizzate per altre piattaforme: msdn.microsoft.com/en-us/magazine/jj863136.aspx

Se hai bisogno di ulteriore convincimento, leggi questo articolo più recente in cui potrebbero essere inserite ottimizzazioni che impediscono il funzionamento senza volatile: msdn.microsoft.com/en-us/magazine/jj883956.aspx

In sintesi, "potrebbe" funzionare per te senza volatile per il momento, ma non rischiare che scriva il codice corretto e utilizzi il metodo volatile o volatileread / write. Gli articoli che suggeriscono di fare diversamente a volte tralasciano alcuni dei possibili rischi delle ottimizzazioni JIT / compilatore che potrebbero avere un impatto sul tuo codice, così come noi future ottimizzazioni che potrebbero verificarsi che potrebbero rompere il tuo codice. Inoltre, come accennato nell'ultimo articolo, i presupposti precedenti di lavorare senza volatile potrebbero già non reggere su ARM.


1
Buona risposta. L'unica risposta giusta a questa domanda è il semplice "No". Secondo questo la risposta accettata è sbagliata.
Dennis Kassel

3

AFAIK (e - prendi questo con cautela, non sto facendo molte cose simultanee) no. Il blocco ti dà solo la sincronizzazione tra più contendenti (thread).

volatile d'altra parte dice alla tua macchina di rivalutare il valore ogni volta, in modo da non incappare in un valore memorizzato nella cache (e sbagliato).

Vedi http://msdn.microsoft.com/en-us/library/ms998558.aspx e prendi nota della seguente citazione:

Inoltre, la variabile viene dichiarata volatile per garantire che l'assegnazione alla variabile di istanza venga completata prima che sia possibile accedere alla variabile di istanza.

Una descrizione di volatile: http://msdn.microsoft.com/en-us/library/x13ttww7%28VS.71%29.aspx


2
Un "blocco" fornisce anche una barriera di memoria, uguale (o migliore) volatile.
Henk Holterman,

2

Penso di aver trovato quello che stavo cercando. I dettagli sono in questo articolo: http://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10 .

Per riassumere - in .NET il modificatore volatile non è effettivamente necessario in questa situazione. Tuttavia, nei modelli di memoria più deboli, le scritture effettuate nel costruttore di un oggetto avviato in modo lento possono essere ritardate dopo la scrittura sul campo, quindi altri thread potrebbero leggere istanze non nulle danneggiate nella prima istruzione if.


1
In fondo a quell'articolo leggi attentamente, in particolare l'ultima frase l'autore afferma: "Un'ultima parola di avvertimento - Sto solo ipotizzando il modello di memoria x86 dal comportamento osservato sui processori esistenti. Pertanto le tecniche di blocco basso sono anche fragili perché hardware e compilatori possono diventare più aggressivi nel tempo. Ecco alcune strategie per ridurre al minimo l'impatto di questa fragilità sul tuo codice. Innanzitutto, quando possibile, evita le tecniche di low-lock. (...) Infine, ipotizza il modello di memoria più debole possibile, utilizzando dichiarazioni volatili invece di fare affidamento su garanzie implicite. "
user2685937

1
Se hai bisogno di più convincimento, leggi questo articolo sulle specifiche ECMA che verranno utilizzate per altre piattaforme: msdn.microsoft.com/en-us/magazine/jj863136.aspx Se hai bisogno di ulteriore convincimento, leggi questo articolo più recente in cui possono essere inserite ottimizzazioni che gli impediscono di funzionare senza volatile: msdn.microsoft.com/en-us/magazine/jj883956.aspx In sintesi, "potrebbe" funzionare per te senza volatile per il momento, ma non rischiare che scriva il codice corretto e lo usi volatile o i metodi volatileread / write.
user2685937

1

Il lockè sufficiente. La specifica del linguaggio MS (3.0) menziona questo scenario esatto nel §8.12, senza alcuna menzione di volatile:

Un approccio migliore consiste nel sincronizzare l'accesso ai dati statici bloccando un oggetto statico privato. Per esempio:

class Cache
{
    private static object synchronizationObject = new object();
    public static void Add(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
    public static void Remove(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
}

Jon Skeet nel suo articolo ( yoda.arachsys.com/csharp/singleton.html ) afferma che il volatile è necessario per una corretta barriera di memoria in questo caso. Marc, puoi commentare questo?
Konstantin,

Ah, non avevo notato la serratura a doppio controllo; semplicemente: non farlo ;-p
Marc Gravell

In realtà penso che il blocco del doppio controllo sia una buona cosa dal punto di vista delle prestazioni. Inoltre, se è necessario rendere un campo volatile, mentre vi si accede all'interno del blocco, il blocco a doppio controllo non è molto peggiore di qualsiasi altro blocco ...
Konstantin

Ma è buono come l'approccio di classe separato menzionato da Jon?
Marc Gravell

-3

Questo è un bel post sull'utilizzo di volatile con blocco a doppio controllo:

http://tech.puredanger.com/2007/06/15/double-checked-locking/

In Java, se lo scopo è proteggere una variabile non è necessario bloccarla se è contrassegnata come volatile


3
Interessante, ma non necessariamente molto utile. Il modello di memoria di JVM e il modello di memoria di CLR non sono la stessa cosa.
bcat
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.