Condividi tramite


Il presente articolo è stato tradotto automaticamente.

C#

Il modello di memoria C# nella teoria e nella pratica, Parte 2

Igor Ostrovsky

 

Questo è il secondo articolo di una serie di due parti che illustra il modello di memoria C#. Come spiegato nella prima parte nel numero di dicembre di MSDN Magazine (msdn.microsoft.com/magazine/jj863136), l'hardware e il compilatore sottilmente possono trasformare le operazioni di memoria di un programma in modi che non influiscono sul comportamento single-threaded, ma possono avere un impatto multi-threaded comportamento. Ad esempio, si consideri questo metodo:

void Init() {
  _data = 42;
  _initialized = true;
}

Se data e DataObjectMethodType sono normali (cioè non volatile) campi, il compilatore e il processore sono autorizzati a riordinare le operazioni affinché Init viene eseguito come se si erano scritti come questo:

void Init() {
  _initialized = true;
  _data = 42;
}

Nel precedente articolo, ho descritto l'astratto modello di memoria C#. In questo articolo spiegherò come il modello di memoria C# è effettivamente implementato su diverse architetture supportate da Microsoft .NET Framework 4.5.

Ottimizzazioni dei compilatori

Come accennato nel primo articolo, il compilatore potrebbe ottimizzare il codice in un modo che Riordina le operazioni di memoria. Nel .NET Framework 4.5, il compilatore csc.exe che compila C# per IL non fare molte ottimizzazioni, così essa non riordina le operazioni di memoria. Tuttavia, il compilatore just-in-time (JIT) che converte IL codice macchina, infatti, eseguirà alcune ottimizzazioni che riordino le operazioni di memoria, come illustrerò.

Ciclo di lettura sollevamento prendere in considerazione il modello di ciclo di polling:

class Test
{
  private bool _flag = true;
  public void Run()
  {
    // Set _flag to false on another thread
    new Thread(() => { _flag = false; }).Start();
    // Poll the _flag field until it is set to false
    while (_flag) ;
    // The loop might never terminate!
}
}

In questo caso, il compilatore JIT 4.5 .NET potrebbe riscrivere il loop come questo:

if (_flag) { while (true); }

Nel caso di single-threaded, questa trasformazione è interamente legale e, in generale, una lettura da un ciclo di sollevamento è un'eccellente ottimizzazione. Tuttavia, se il _flag è impostato su false in un altro thread, l'ottimizzazione può causare un blocco.

Si noti che se il campo _flag volatile, il compilatore JIT non sarebbe issare la lettura fuori dal ciclo. (Vedere la sezione "Ciclo di Polling" nell'articolo di dicembre per una spiegazione più dettagliata di questo modello).

Eliminazione di lettura un'altra ottimizzazione del compilatore che può causare errori nel codice multithread è illustrato in questo esempio:

class Test
{
  private int _A, _B;
  public void Foo()
  {
    int a = _A;
    int b = _B;
    ...
}
}

La classe contiene due campi non volatili, _A e b. Metodo Foo legge primo campo _A e quindi campo b. Tuttavia, poiché i campi sono non-volatili, il compilatore è libero riordinare le due letture. Così, se la correttezza dell'algoritmo dipende dall'ordine della legge, il programma contiene un bug.

È difficile immaginare che cosa guadagnerebbe il compilatore commutando l'ordine della legge. Dato il modo che foo è scritto, il compilatore probabilmente non scambia l'ordine della legge.

Tuttavia, il riordino accadere se aggiungo un'altra istruzione innocua nella parte superiore del metodo Foo:

public bool Foo()
{
  if (_B == -1) throw new Exception(); // Extra read
  int a = _A;
  int b = _B;
  return a > b;
}

La prima riga del metodo Foo, il compilatore carica il valore di b in un registro. Poi, il secondo carico di b utilizza semplicemente il valore che è già nel registro anziché emettere un carico reale di istruzioni.

Effettivamente, il compilatore riscrive il metodo Foo come segue:

public bool Foo()
{
  int b = _B;
  if (b == -1) throw new Exception(); // Extra read
  int a = _A;
  return a > b;
}

Sebbene in questo esempio di codice dà un'approssimazione di come il compilatore ottimizza il codice, è anche istruttivo guardare il disassemblaggio del codice:

if (_B == -1) throw new Exception();
  push        eax
  mov         edx,dword ptr [ecx+8]
  // Load field _B into EDX register
  cmp         edx,0FFFFFFFFh
  je          00000016
int a = _A;
  mov         eax,dword ptr [ecx+4]
  // Load field _A into EAX register
return a > b;
  cmp         eax,edx
  // Compare registers EAX and EDX
...

Anche se non sai assieme, che cosa sta accadendo qui è abbastanza facile da capire. Come parte della valutazione della condizione b = = -1, i carichi del compilatore il b del campo nel registro EDX. Più tardi, quando b campo è leggere di nuovo, il compilatore riutilizza semplicemente il valore che già ha in EDX invece di rilasciare una vera e propria memoria leggere. Di conseguenza, le letture di _A e b ottenere riordinato.

In questo caso, la soluzione corretta è di contrassegnare il campo _A come volatile. Se questo è fatto, il compilatore non dovrebbe riordinare le letture di _A e b, perché il carico di _A ha carico-acquisire semantica. Tuttavia, vorrei sottolineare che il Framework .NET, attraverso la versione 4, non gestire correttamente questa situazione e, infatti, segna il campo _A come volatile non impedirà il riordinamento delle leggi. Questo problema è stato risolto nel .NET Framework versione 4.5.

Lettura introduzione come ho appena spiegato, il compilatore fusibili a volte letture multiple in uno. Il compilatore può anche dividere una singola lettura in molteplici letture. Nel .NET Framework 4.5, leggere introduzione è molto meno comune della lettura eliminazione e si verifica solo in circostanze molto rare, specifiche. Tuttavia, a volte può capitare.

Per comprendere la lettura introduzione, consideriamo il seguente esempio:

public class ReadIntro {
  private Object _obj = new Object();
  void PrintObj() {
    Object obj = _obj;
    if (obj != null) {
      Console.WriteLine(obj.ToString());
    // May throw a NullReferenceException
    }
  }
  void Uninitialize() {
    _obj = null;
  }
}

Se si esamina il metodo PrintObj, sembra che il valore di obj non sarà mai null nell'obj.Espressione di ToString. Tuttavia, questa riga di codice potrebbe infatti generare un oggetto NullReferenceException. Il CLR JIT potrebbe compilare il metodo PrintObj come se si erano scritti come questo:

void PrintObj() {
  if (_obj != null) {
    Console.WriteLine(_obj.ToString());
  }
}

Perché la lettura del campo _obj è stata divisa in due letture del campo, il metodo ToString ora può essere chiamato su un bersaglio null.

Si noti che non sarà in grado di riprodurre il NullReferenceException utilizza questo esempio di codice nel .NET Framework 4.5 su x86-64. Leggere l'introduzione è molto difficile da riprodurre nel .NET Framework 4.5, ma tuttavia verificarsi in determinate circostanze speciali.

Implementazione del modello di C# memoria su x86-x64

Come il x 86 e x 64 hanno lo stesso comportamento per quanto riguarda il modello di memoria, ti consideri il entrambe le varianti di processore insieme.

A differenza di alcune architetture, processore x86-64 fornisce garanzie di ordinazione abbastanza forte sulle operazioni di memoria. Infatti, il compilatore JIT non è necessario utilizzare eventuali istruzioni speciali su x86-x64 per raggiungere la semantica di volatili; le operazioni di memoria ordinaria già forniscono quelle semantica. Anche così, ci sono casi ancora specifici quando il processore x64 x86-riordinare le operazioni di memoria.

Riordino della memoria x86-x64 anche se il processore x86-x64 fornisce garanzie di ordinazione abbastanza forte, un particolare tipo di hardware riordino ancora non succede.

Processore x86-64 non verrà riordinate due scrive, né verrà riordinate due letture. Tuttavia, l'uno (e solo) possibile riordino effetto è che quando un processore scrive un valore, che valore non sarà reso immediatamente disponibile ad altri processori. Figura 1 Mostra un esempio che illustri questo comportamento.

Figura 1 StoreBufferExample

class StoreBufferExample
{
  // On x86 .NET Framework 4.5, it makes no difference
  // whether these fields are volatile or not
  volatile int A = 0;
  volatile int B = 0;
  volatile bool A_Won = false;
  volatile bool B_Won = false;
  public void ThreadA()
  {
    A = true;
    if (!B) A_Won = true;
  }
  public void ThreadB()
  {
    B = true;
    if (!A) B_Won = true;
  }
}

Si consideri il caso quando vengono chiamati i metodi ThreadA e ThreadB da diversi thread su una nuova istanza di StoreBufferExample, come mostrato Figura 2. Se ci pensate i possibili risultati del programma in Figura 2, tre casi sembrano essere possibili:

  1. Thread 1 completato prima dell'avvio di 2 Thread. Il risultato è A_Won = true, B_Won = false.
  2. Thread 2 completato prima dell'avvio di 1 Thread. Il risultato è A_Won = false, B_Won = true.
  3. I thread interleave. Il risultato è A_Won = false, B_Won = false.

Calling ThreadA and ThreadB Methods from Different Threads
Figura 2 chiamando i metodi ThreadB e ThreadA da thread diversi

Ma, sorprendentemente, non c'è un quarto caso: È possibile che la A_Won e la B_Won campi sarà veri dopo questo codice ha finito! A causa del buffer di negozio, negozi possono ottenere "ritardati" e quindi finiscono riordinati con un successivo carico. Anche se questo risultato non è coerenza con qualsiasi interfoliazione di esecuzioni Thread 1 e 2 Thread, può ancora succedere.

Questo esempio è interessante perché abbiamo un processore (il x86-x64) con ordinamento relativamente forte, e tutti i campi sono volatili — e osserviamo ancora un riordino delle operazioni di memoria. Anche se la scrittura ad A è volatile e la lettura da A_Won inoltre è volatile, le recinzioni sono entrambi unidirezionale e infatti permettono questo riordino. Così, il metodo ThreadA può eseguire efficacemente come se si erano scritti come questo:

public void ThreadA()
{
  bool tmp = B;
  A = true;
  if (!tmp) A_Won = 1;
}

Una correzione possibile è quello di inserire una barriera di memoria sia ThreadA e ThreadB. Il metodo ThreadA updated sarebbe simile a questa:

public void ThreadA()
{
  A = true;
  Thread.MemoryBarrier();
  if (!B) aWon = 1;
}

Il CLR JIT inserirà un "blocco o" istruzioni al posto della barriera di memoria. A bloccato x 86 istruzione ha l'effetto collaterale di lo svuotamento del buffer del negozio:

mov         byte ptr [ecx+4],1
lock or     dword ptr [esp],0
cmp         byte ptr [ecx+5],0
jne         00000013
mov         byte ptr [ecx+6],1
ret

Come un'interessante nota laterale, il linguaggio di programmazione Java adotta un approccio diverso. Il modello di memoria Java ha una definizione leggermente più forte del "volatile" che non permettono store-carico riordino, quindi un compilatore Java sulla x86 in genere emetterà un'istruzione bloccata dopo una scrittura volatile.

processore di osservazioni il 86 x 64 x 86 x ha un modello di memoria abbastanza forte, e l'unica fonte di riordino a livello hardware è il buffer del negozio. Il negozio di buffer può causare una scrittura ottenere riordinati con una successiva lettura (riordino archivio-carico).

Inoltre, alcune ottimizzazioni del compilatore possono provocare nel riordino delle operazioni di memoria. In particolare, se diverse letture accedono la stessa locazione di memoria, il compilatore potrebbe scegliere di eseguire la lettura solo una volta e mantenere il valore in un registro per le successive letture.

Un pezzo interessante curiosità è che la semantica di C# volatile fedelmente l'hardware riordinamento delle garanzie da 64 hardware x86. Di conseguenza, letture e scritture dei campi volatili non richiedono istruzioni speciali sulla x86: Ordinarie letture e scritture (ad esempio, utilizzando l'istruzione MOV) sono sufficienti. Naturalmente, il codice non dovrebbe dipendono da questi dettagli di implementazione poiché essi variano tra architetture hardware e possibilmente le versioni di .NET.

Implementazione del modello di C# memoria su architettura Itanium

L'architettura hardware Itanium è un modello di memoria più debole rispetto a quella di x86-x64. Itanium è stato sostenuto dal Framework .NET fino alla versione 4.

Anche se Itanium non è più supportato nel .NET Framework 4.5, comprendere l'Itanium modello di memoria è utile quando si leggere i vecchi articoli sul modello di memoria .NET e devono mantenere il codice incorporato raccomandazioni da quegli articoli.

Itanium riordino Itanium ha un diverso instruction set rispetto il x86-64, e concetti di modello di memoria visualizzata nel set di istruzioni. Itanium distingue tra un carico normale (LD) e carico-acquisire (LD.ACQ) e un normale negozio (ST) e archivio-release (ST.REL).

Negozi e carichi ordinari possono essere liberamente riordinate dall'hardware, finchè non cambia il comportamento di single-threaded. Per esempio, guardate questo codice:

class ReorderingExample
{
  int _a = 0, _b = 0;
  void PrintAB()
  {
    int a = _a;
    int b = _b;
    Console.WriteLine("A:{0} B:{1}", a, b);
  }
  ...
}

Considerare due letture di _a e b nel metodo PrintAB. Perché la legge accedere a un campo ordinario, non volatile, il compilatore utilizzerà ordinaria LD (non LD.ACQ) per attuare la legge. Di conseguenza, le due letture potrebbero efficacemente riordinato nell'hardware, così che PrintAB si comporta come se si erano scritti come questo:

void PrintAB()
{
  int b = _b;
  int a = _a;
  Console.WriteLine("A:{0} B:{1}", a, b);
}

In pratica, se il riordino accade o non dipende da una varietà di fattori imprevedibili — che cosa è nella cache del processore, come occupato la pipeline del processore è e così via. Tuttavia, il processore non verrà riordinate due letture se stai relativi via dipendenza dei dati. Dipendenza di dati tra due letture si verifica quando il valore restituito da una memoria lettura determina la posizione di lettura da una lettura successiva.

Questo esempio illustra la dipendenza dei dati:

class Counter { public int _value; }
class Test
{
  private Counter _counter = new Counter();
  void Do()
  {
    Counter c = _counter; // Read 1
    int value = c._value; // Read 2
  }
}

Nel metodo, Itanium sarà mai riordinare lettura 1 e 2 di lettura, anche se la lettura 1 è un carico normale e non acquisire carico. Potrebbe sembrare ovvio che queste due letture non possono essere riordinate: La prima lettura determina quale locazione di memoria della seconda lettura dovrebbe accedere! Tuttavia, alcuni processori — diverso da Itanium — infatti può riordinare le letture. Il processore potrebbe indovinare sul valore che leggi 1 restituirà ed eseguire lettura 2 speculativo, anche prima del completamento di lettura 1. Ma, ancora una volta, Itanium non farà che.

Potrai tornare alla dipendenza dei dati in Itanium discussione un po ', e sua rilevanza per il modello di memoria C# diventerà più chiara.

Inoltre, itanium non verranno riordinate due letture ordinarie se stai relativi via dipendenza di controllo. Controllo di dipendenza si verifica quando il valore restituito da una lettura determina se verrà eseguita un'istruzione successiva.

Così, in questo esempio, la recita di DataObjectMethodType e data è collegata via dipendenza di controllo:

void Print() {
  if (_initialized)            // Read 1
    Console.WriteLine(_data);  // Read 2
  else
    Console.WriteLine("Not initialized");
}

Anche se DataObjectMethodType e data sono normali letture (non volatile), il processore Itanium non li Riordina. Si noti che il compilatore JIT è ancora libero riordinare le due letture e in alcuni casi verrà.

Inoltre, vale la pena di sottolineare che, come il processore x86-x64, Itanium utilizza anche un buffer di negozio, così il StoreBufferExample mostrato Figura 1 esporrà lo stesso tipo di reorderings su Itanium come ha fatto su x86-x64. Un interessante pezzo di curiosità è che se si utilizza LD.ACQ per tutte le letture e ST.REL per tutte le scritture su Itanium, fondamentalmente si ottiene il modello di memoria 64 x 86 x, dove il buffer del negozio è l'unica fonte di riordino.

Comportamento del compilatore su Itanium compilatore JIT di Common Language Runtime il ha un comportamento sorprendente su Itanium: tutte le Scritture sono emessi come ST.REL e non ST. Di conseguenza, una scrittura volatile e una scrittura non volatile in genere emettono le stesse istruzioni su Itanium. Tuttavia, verrà emesso un normale leggere come LD; si legge soli da campi volatili vengono emessi come LD.ACQ.

Questo comportamento potrebbe essere una sorpresa perché il compilatore non è certamente necessaria per emettere ST.REL per scrive non volatile. Per quanto riguarda la specifica ECMA European Computer Manufacturers Association () C#, il compilatore potrebbe emettere istruzioni ST ordinarie. Emissione di ST.REL è solo qualcosa in più che il compilatore sceglie di fare, al fine di garantire che un modello particolare comune (ma in teoria errata) funzionerà come previsto.

Può essere difficile immaginare che cosa potrebbe essere quel modello importante dove ST.REL deve essere utilizzato per la scrittura, ma LD è sufficiente per legge. Nell'esempio PrintAB presentato in precedenza in questa sezione, vincolando scrive solo la servirebbe, perché la legge potrebbero ancora essere riordinati.

C'è uno scenario molto importante in cui usando ST.REL con LD ordinaria è sufficiente: Quando i carichi stessi vengono ordinati utilizzando la dipendenza dei dati. Questo modello esce nell'inizializzazione lazy, che è un modello estremamente importante. Figura 3 Mostra un esempio di inizializzazione lazy.

Figura 3 Lazy Initialization

// Warning: Might not work on future architectures and .NET versions;
// do not use
class LazyExample
{
  private BoxedInt _boxedInt;
  int GetInt()
  {
    BoxedInt b = _boxedInt; // Read 1
    if (b == null)
    {
      lock(this)
      {
        if (_boxedInt == null)
        {
          b = new BoxedInt();
          b._value = 42;  // Write 1
          _boxedInt = b; // Write 2
        }
      }
    }
    int value = b._value; // Read 2
    return value;
  }
}

In ordine per questo bit di codice per restituire sempre 42 — anche se GetInt viene chiamato da più thread contemporaneamente — lettura 1 non deve essere riordinati con 2 leggere e scrivere 1 non devono essere riordinati con scrivere 2. Le letture saranno riordinato dal processore Itanium non perché essi stai correlati tramite la dipendenza dei dati. E, scrive la non saranno riordinata perché il JIT CLR li emette come ST.REL.

Si noti che se il campo _boxedInt volatile, il codice sarebbe corretto secondo le specifiche ECMA c#. Che è il miglior tipo di corretto e probabilmente l'unico vero tipo di corretto. Tuttavia, anche se _boxed non è volatile, la versione corrente del compilatore garantirà che il codice funziona ancora su Itanium nella pratica.

Naturalmente, ciclo leggere sollevamento, leggere eliminazione e lettura introduzione può essere eseguita da CLR JIT su Itanium, così come sono su x86-64.

Itanium osservazioni , Itanium è una parte interessante della storia è che era la prima architettura con un modello di memoria debole che correva il .NET Framework.

Di conseguenza, in un certo numero di articoli sul modello di memoria C# e la parola chiave volatile e C#, gli autori avevano generalmente Itanium nella mente. Dopo tutto, fino a quando il .NET Framework 4.5, Itanium era l'architettura solo diverso da quello del x86-64 che correva il .NET Framework.

Di conseguenza, l'autore potrebbe dire qualcosa di simile, "nel modello a memoria 2.0 .NET, tutte le Scritture sono volatili — anche quelli non volatile campi." Che cosa significa l'autore è che su Itanium, CLR emetterà tutti scrive come ST.REL. Questo comportamento non è garantito da ECMA c# spec e, di conseguenza, potrebbe non tenere in futuro versioni del Framework .NET e su architetture di future (e, infatti, non tenere il .NET Framework 4.5 sul braccio).

Allo stesso modo, alcune persone sostengono che l'inizializzazione lazy è corretto in ambito .NET, anche se il campo azienda è non-­volatile, mentre gli altri direi che il campo deve essere volatile.

E naturalmente, gli sviluppatori ha scritto codice contro questi presupposti (a volte contraddittori). Così, comprendere l'Itanium parte della storia può essere utile quando si cerca di dare un senso di simultaneo codice scritto da qualcun altro, leggere gli articoli precedenti o anche solo parlando con altri sviluppatori.

Implementazione del modello di C# memoria sul braccio

L'architettura ARM è la più recente aggiunta all'elenco delle architetture supportate da .NET Framework. Come Itanium, braccio ha un modello di memoria più debole rispetto a x86-x64.

BRACCIO riordino proprio come Itanium, braccio è consentito liberamente Riordina legge ordinarie e scrive. Tuttavia, la soluzione che braccio fornisce per domare il movimento della legge e scrive è un po' diversa da quello di Itanium. BRACCIO espone una singola istruzione — DMB — che agisce come una barriera di memoria piena. Nessuna operazione di memoria può passare sopra DMB in entrambe le direzioni.

Oltre ai vincoli imposti dall'istruzione DMB, braccio anche rispetta la dipendenza dei dati, ma non rispetta controllo dipendenza. Vedere la sezione "Riordino Itanium" più indietro in questo articolo per una spiegazione dei dati e il controllo delle dipendenze.

Comportamento del compilatore sul braccio istruzioni la DMB sono utilizzato per implementare la semantica di volatile in c#. Sul braccio, il JIT CLR implementa una lettura da un campo volatile utilizzando una lettura ordinaria (ad esempio LDR) seguita dall'istruzione DMB. Perché l'istruzione DMB impedirà la lettura da sempre riordinati con le successive operazioni di volatile, questa soluzione implementa correttamente la semantica di acquisizione.

Una scrittura su un campo volatile viene implementata utilizzando l'istruzione DMB seguita da una scrittura ordinaria (ad esempio STR). Perché l'istruzione DMB impedisce la scrittura volatile sempre riordinati con le operazioni precedenti, questa soluzione implementa correttamente la semantica di rilascio.

Appena come con il processore Itanium, sarebbe bello andare di là della specifica ECMA c# e mantenere la lazy initialization modello lavorando, perché un sacco di codice esistente dipende da esso. Tuttavia, fare tutte le scritture efficacemente volatile non è una buona soluzione sul braccio perché l'istruzione DBM è abbastanza costoso.

Nel .NET Framework 4.5, JIT CLR utilizza un trucco leggermente diverso per ottenere lavoro lazy initialization. Di seguito vengono trattati come barriere "release":

  1. Scrive ai campi di tipo di riferimento nell'heap di garbage collector (GC)
  2. Scrive a campi statici di tipo di riferimento

Di conseguenza, qualsiasi scrittura che potrebbe pubblicare un oggetto viene considerato come una barriera di rilascio.

Questa è la parte rilevante del LazyExample (richiamo che nessuno dei campi sono volatili):

b = new BoxedInt();
b._value = 42;  // Write 1
// DMB will be emitted here
_boxedInt = b; // Write 2

Perché il CLR JIT genera le istruzioni DMB prima della pubblicazione dell'oggetto nel campo di _boxedInt, 1 scrivere e scrivere 2 non sarà riordinata. E poiché braccio rispetta la dipendenza dai dati, si legge nel modello di inizializzazione lazy non sarà riordinata sia, e il codice funzionerà correttamente sul braccio.

Così, il CLR JIT fa uno sforzo supplementare (di là di ciò che è obbligatorio nelle specifiche ECMA c#) per mantenere la variante più comune della errata inizializzazione lazy lavorando sul braccio.

Come commento finale sul braccio, si noti che — come x86-x64 e Itanium — ciclo leggere sollevamento, leggere eliminazione ed introduzione di lettura sono tutte le ottimizzazioni legittime quanto il CLR JIT è interessato.

Esempio: Lazy Initialization

Può essere istruttivo guardare alcune diverse varianti del modello lazy initialization e pensare a come ti si comportano su diverse architetture.

Corretta attuazione attuazione della lazy initialization in Figura 4 è corretta secondo il modello di memoria C# come definito dalle specifiche ECMA c#, e così è garantito per funzionare su tutte le architetture supportate dalle attuali e future versioni di .NET Framework.

Figura 4 implementazione corretta della Lazy Initialization

class BoxedInt
{
  public int _value;
  public BoxedInt() { }
  public BoxedInt(int value) { _value = value; }
}
class LazyExample
{
  private volatile BoxedInt _boxedInt;
  int GetInt()
  {
    BoxedInt b = _boxedInt;
    if (b == null)
    {
      b = new BoxedInt(42);
      _boxedInt = b;
    }
    return b._value;
  }
}

Si noti che anche se l'esempio di codice è corretto, in pratica è ancora preferibile utilizzare Lazy <T> o il tipo di LazyInitializer.

Non corretta attuazione n. 1 Figura 5 viene illustrata un'implementazione che non è corretta secondo il modello di memoria C#. Nonostante questo, l'implementazione probabilmente funzionerà sul x86-x64, Itanium e braccio in ambito .NET. Questa versione del codice non è corretta. _BoxedInt non è volatile, un compilatore c# è consentito riordinare lettura 1 lettura 2 o 1 scrivere con scrivere 2. O riordino potenzialmente comporterebbe 0 restituito da GetInt.

Figura 5 un'errata implementazione della Lazy Initialization

// Warning: Bad code
class LazyExample
{
  private BoxedInt _boxedInt; // Note: This field is not volatile
  int GetInt()
  {
    BoxedInt b = _boxedInt; // Read 1
    if (b == null)
    {
      b = new BoxedInt(42); // Write 1 (inside constructor)
      _boxedInt = b;        // Write 2
    }
    return b._value;        // Read 2
  }
}

Tuttavia, questo codice si comporterà correttamente (cioè sempre restituire 42) su tutte le architetture nelle versioni .NET Framework 4 e 4.5:

  • x86-x64:
    • Scrive e legge non saranno riordinate. Non non c'è nessun modello di negozio-carico nel codice, e anche motivo il compilatore non avrebbe nella cache i valori nei registri.
  • Itanium:
    • Scrive non saranno riordinate perché sono ST.REL.
    • Letture non saranno riordinate a causa della dipendenza dei dati.
  • BRACCIO:
    • Scrive non saranno riordinate perché DMB è emessa prima "_boxedInt = b."
    • Letture non saranno riordinate a causa della dipendenza dei dati.

Naturalmente, si dovrebbero utilizzare queste informazioni solo per cercare di capire il comportamento del codice esistente. Non usare questo modello durante la scrittura di nuovo codice.

Non corretta attuazione n. 2 Errata attuazione in Figura 6 può fallire sul braccio sia Itanium.

Figura 6 seconda errata attuazione della Lazy Initialization

// Warning: Bad code
class LazyExample
{
  private int _value;
  private bool _initialized;
  int GetInt()
  {
    if (!_initialized) // Read 1
    {
      _value = 42;
      _initialized = true;
    }
    return _value;     // Read 2
  }
}

Questa versione della lazy initialization utilizza due campi separati per tenere traccia dei dati (_value) e se il campo viene inizializzato (DataObjectMethodType). Di conseguenza, i due legge — lettura 1 e 2 di lettura — non più sono correlati tramite la dipendenza dei dati. Inoltre, sul braccio, lo scrive potrebbero anche ottenere riordinato, per gli stessi motivi come la prossima implementazione non corretta (n. 3).

Di conseguenza, questa versione potrebbe non riuscire e restituire 0 sul braccio e Itanium nella pratica. Naturalmente, GetInt è consentito ritornare 0 su x86-64 (e anche in seguito le ottimizzazioni JIT), ma tale comportamento non sembra accadere nel .NET Framework 4.5.

Non corretta attuazione n. 3 , Infine, è possibile ottenere l'esempio a fallire anche su x86-64. Basta aggiungere uno dall'aspetto innocuo leggere, come mostrato Figura 7.

Figura 7 terzo errata attuazione della Lazy Initialization

// WARNING: Bad code
class LazyExample
{
  private int _value;
  private bool _initialized;
  int GetInt()
  {
    if (_value < 0) throw new 
      Exception(); // Note: extra reads to get _value
                          // pre-loaded into a register
    if (!_initialized)      // Read 1
    {
      _value = 42;
      _initialized = true;
      return _value;
    }
    return _value;          // Read 2
  }
}

Extra letto che controlla se _value < 0 ora può causare il compilatore memorizzare nella cache il valore nel registro. Di conseguenza, lettura 2 sarà ottenere servite da un registro, e così si ottiene efficacemente riordinato con lettura 1. Di conseguenza, questa versione di GetInt può in pratica restituire 0 anche su x86-64.

Conclusioni

Durante la scrittura di nuovo codice multi-threaded, è generalmente una buona idea evitare del tutto la complessità del modello di memoria C# utilizzando primitive ad alto livello della concorrenza come serrature, collezioni contemporanee, le attività e cicli paralleli. Quando si scrive codice CPU-intensive, a volte ha senso utilizzare campi volatili, fintanto che si basano solo sulle garanzie specifiche ECMA c# e non sui dettagli di implementazione specifici di architettura.

Igor Ostrovsky è un senior software development engineer presso Microsoft. Ha lavorato su Parallel LINQ, il Task Parallel Library e altre librerie parallele e primitive in ambito .NET. Ostrovsky Blog sulla programmazione di argomenti a igoro.com.

Grazie ai seguenti esperti tecnici per la revisione di questo articolo: Joe Duffy, Eric Eilebrecht, Joe Hoag, Emad Omara, Grant Richins, Jaroslav Sevcik e Stephen Toub