Proprio in questi giorni mi è capitato di leggere un interessante articolo sull'integrazione di NHibernate con ASP.NET (che trovate qui). Analizzando il codice allegato all'articolo in questione mi è caduto l'occhio su una particolare implementazione del pattern Singleton in C#. Tra i commenti, l'autore citava un link ad un articolo che avevo già letto di sfuggita un po' di tempo fa al quale solo ora sono riuscito a dare il peso che merita.
Ricalco di seguito qualche concetto e qualche riga di approfondimento, giusto a pro memoria e per reference in italiano.
In sostanza un Singleton è una classe che permette la creazione di una singola istanza di se stessa (requisito di resource management [1]) e fornisce un unico, semplice accesso pubblico a detta istanza (requisito di accessibilità [1]).
Obiettivo del Singleton è spostare la responsabilità dell'esistenza di una ed una sola istanza di una determinata classe dallo sviluppatore alla classe stessa.
Un tipico requisito di un Singleton è che l'istanza non sia creata finchè non ce n'è reale necessità, che sia cioè "lazy-created".
Tutte le implementazioni del Singleton condividono alcune caratteristiche:
- Un unico costruttore, privato e senza parametri. Per non permettere ad altre classi di instanziarlo direttamente e per non permetterne l'uso come classe base in una gerarchia di ereditarietà. Tipicamente il Singleton è sealed, anche se non servirebbe, per il motivo che abbiamo appena citato, ma sembra che segnare la classe sealed aiuti il compilatore a ottimizzare meglio [2];
- Un campo statico che mantiene un riferimento alla singola istanza del Singleton;
- Un metodo o una proprietà pubblici per ottenere il riferimento all'istanza del Singleton ed eventualmente crearla, se non ancora creata;
Vediamo di seguito qualche implementazione del pattern Singleton con un occhio di riguardo alla "thread-safety" e alla "lazyness", come consigliato in [2].
L'implementazione classica... non thread safe
1: public sealed class Singleton1
2: {
3: private static Singleton1 _instance = null;
4:
5: private Singleton1() { }
6:
7: public static Singleton1 Instance
8: {
9: get
10: {
11: if (_instance == null)
12: {
13: _instance = new Singleton1();
14: }
15: return _instance;
16: }
17: }
18: }
L'implementazione proposta è la classica descritta in [3], semplicemente tradotta da C++ a C#.
La proprietà Instance alla riga 7 garantisce il requisito di accessibilità, è infatti l'unico punto di accesso pubblico all'istanza. Il codice all'interno della proprietà garantisce il requisito di resource management definito e cioè che esista una sola istanza della classe, il getter controlla infatti la preventiva esistenza dell'istanza e la crea solo ed esclusivamente se non è già stata creata, altrimenti restituisce l'istanza già esistente.
In uno scenario single-thread il codice in questione funziona ed è perfettamente legale. In scenari multi-thread iniziano i problemi... questa implementazione non è thread safe, due o più thread potrebbero contemporaneamente valutare il test alla riga 11 come vero e creare più istanze della classe, violando il requisito di resource management.
Rigirando la frittata, anche se a mio parere non ci sono forti motivazioni (ad esempio straordinari aumenti di performance) per un approccio del genere, nel tipico caso di Singleton immutabile e nel caso in cui la costruzione del Singleton sia poco onerosa, è possibile chiudere un occhio riguardo la violazione del requisito di resource management, lasciando al garbage collector l'onore e l'onere di eliminare una eventuale "altra istanza" lasciata orfana di riferimento dalla fortuita costruzione di una seconda istanza e utilizzare, quindi, questa implementazione.
Un passo avanti, garantiamo la thread-safety
1: public sealed class Singleton2
2: {
3: private static Singleton2 _instance = null;
4: private static readonly Object syncObject = new Object();
5:
6: private Singleton2() { }
7:
8: public static Singleton2 Instance
9: {
10: get
11: {
12: lock (syncObject)
13: {
14: if (_instance == null)
15: {
16: _instance = new Singleton2();
17: }
18: return _instance;
19: }
20: }
21: }
22: }
L'implementazione è effettivamente thread safe, due o più thread non possono accedere contemporaneamente all'area protetta da lock quindi solo il primo thread che entrerà nel lock potrà creare l'istanza del singleton.
Il codice soffre però un problema di performance, è infatti richiesto un lock per ogni richiesta dell'istanza, anche nel caso in cui l'istanza sia già stata creata.
Thread safety, lock, performance, volatile e MemoryBarrier()
1: public sealed class Singleton3
2: {
3: private static volatile Singleton3 _instance = null;
4: private static readonly Object syncObject = new Object();
5:
6: private Singleton3() { }
7:
8: public static Singleton3 Instance
9: {
10: get
11: {
12: if (_instance == null)
13: {
14: lock (syncObject)
15: {
16: if (_instance == null)
17: {
18: _instance = new Singleton3();
19: }
20: }
21: }
22: return _instance;
23: }
24: }
25: }
A differenza dell'esempio precedente, il lock viene eseguito solo la prima volta che viene richiesta l'istanza. Infatti l'if più esterno (alla riga 12) serve proprio a verificare che l'istanza sia già stata creata ed in tal caso l'ingresso nel lock introdurrebbe solo un inutile overhead. Questa semplice modifica rispetto all'esempio precendente ha un chiaro impatto sulle performance, l'utilizzo di lock è utile in effetti solo nella fase iniziale in cui l'istanza non è ancora stata creata, quando cioè è indispensabile la sincronizzazione, e di conseguenza è un inutile spreco di tempo per tutto il resto del ciclo di vita del singleton in cui viene semplicemente restituita l'istanza già creata.
Merita particolare attenzione in questa implementazione l'utilizzo di volatile nella dichiarazione di _instance (riga 3)... ma prima di entrare nel merito forse vale la pena di ricordare le 3 regole fondamentali a cui i modelli di memoria (anche quello del .NET Framework) devono sottostare:
- Una lettura o una scrittura da un determinato thread ad una determinata locazione di memoria non può essere spostata temporalmente più avanti di una scrittura dallo stesso thread alla stessa locazione;
- Le letture non possono essere spostate temporalmente più indietro dell'acquisizione di un lock;
- Le scritture non possono essere spostate temporalmente più avanti del rilascio di un lock;
Il modello di memoria del .NET Framework, oltre alle 3 regole citate, suddivide le tipologie di accesso alla memoria in "accessi ordinari" e "accessi volatile". Per gli accessi volatile sono definite le due regole seguenti:
- Letture e scritture non possono essere spostate temporalmente più indietro di una lettura volatile;
- Letture e scritture non possono essere spostate temporalmente più avanti di una scrittura volatile.
Conseguenza di queste regole è, di fatto, l'inibizione del riordino degli accessi alla memoria, una particolare ottimizzazione applicata dai compilatori e dalle CPU non x86 per ottimizzare l'esecuzione del codice.
Altra conseguenza è che viene forzato l'aggiornamento della variabile dichiarata volatile al valore più recente prima che questa venga applicata all'interno di un particolare contesto.
Ora... cosa sarebbe potuto succedere se _instance non fosse stata dichiarata volatile?
Dal punto di vista del rispetto dei requisiti di accessibilità e di resource management assolutamente niente. Infatti, il requisito di resource management è garantito dalla presenza del secondo check su _instance (riga 16) e dal rispetto da parte del memory model della seconda regola fondamentale. Si supponga di avere 2 thread:
- Il thread 1 passa il primo if (riga 12), l'esecuzione viene interrotta e passa al thread 2;
- Il thread 2 passa il primo if, entra nel lock, passa il secondo if e crea l'istanza;
- Il thread 1 riprende l'esecuzione ed entra nel lock. La seconda regola fondamentale dei modelli di memoria dice che: "le letture non possono essere spostate temporalmente più indietro dell'acquisizione di un lock" e di conseguenza che _instance avrà sicuramente, a questo punto, l'ultimo valore pubblicato dal thread 2 prima di rilasciare il lock. L'if alla riga 16 verificherà che _instance è diverso da null e il thread 1 proseguirà l'esecuzione restituendo l'istanza precedentemente creata da thread 2.
In realtà, un problema subdolo si nasconde nel corpo dell'if alla riga 16 e precisamente nel codice che va a creare l'istanza di Singleton3... ed è proprio in questo caso che volatile ci viene in aiuto. Si supponga di avere il codice seguente:
1: public sealed class Singleton3
2: {
3: private static volatile Singleton3 _instance = null;
4: private static readonly Object syncObject = new Object();
5: private String _message;
6:
7: private Singleton3() { _message = "Hello World!"; }
8:
9: public static Singleton3 Instance
10: {
11: get
12: {
13: if (_instance == null)
14: {
15: lock (syncObject)
16: {
17: if (_instance == null)
18: {
19: _instance = new Singleton3();
20: }
21: }
22: }
23: return _instance;
24: }
25: }
26: }
Su architetture IA64, che permettono il riordino delle scritture in memoria, potrebbe accadere che, all'interno del corpo dell'if alla riga 19, le scritture vengano riordinate in maniera tale che la scrittura di _message all'interno del costruttore possa essere spostata temporalmente più avanti della scrittura di _instance. La lettura di _instance (alla riga 13) non è protetta da lock, potrebbe quindi accadere che un secondo thread possa accedere all'istanza di Singleton3 prima che il costruttore abbia concluso l'inizializzazione.
L'utilizzo di volatile su _instance ci mette al riparo da questo problema in virtù della regola introdotta dal memory model del .NET Framework che dice: "Letture e scritture non possono essere spostate temporalmente più avanti di una scrittura volatile", garantendo, di conseguenza, la sequenzialità delle scritture nel blocco di costruzione dell'istanza, viene garantita la corretta inizializzazione di Singleton3 anche in scenari multi-thread.
L'implementazione precedente è thread-safe, è lazy-load ed è performante... ma possiamo fare ancora un pochino di più!
Quello che possiamo fare è rimuovere il modificatore volatile dalla dichiarazione di _instance e utilizzare una MemoryBarrier esplicita subito prima della pubblicazione dell'istanza di Singleton3 come nell'esempio seguente:
1: public sealed class Singleton3
2: {
3: private static Singleton3 _instance = null;
4: private static readonly Object syncObject = new Object();
5: private String _message;
6:
7: private Singleton3() { _message = "Hello World!"; }
8:
9: public static Singleton3 Instance
10: {
11: get
12: {
13: if (_instance == null)
14: {
15: lock (syncObject)
16: {
17: if (_instance == null)
18: {
19: Singleton3 newValue = new Singleton3();
20: System.Threading.Thread.MemoryBarrier();
21: _instance = newValue;
22: }
23: }
24: }
25: return _instance;
26: }
27: }
28: }
Questo approccio è molto più efficiente dell'ultilizzo del modificatore volatile perchè, in realtà, ogni scrittura o lettura volatile è una memory barrier! anche dove non ne abbiamo bisogno!
Nel nostro caso l'obiettivo è soltanto avere la sicurezza che le scritture all'interno del costruttore vengano eseguite prima della pubblicazione dell'istanza agli altri thread (e processori). MemoryBarrier() ci assicura che "nessuna scrittura possa essere spostata temporalmente più avanti della chiamata a MemoryBarrier()" e ci permette quindi di garantire la consistenza dell'istanza risparmiando la dichiazione volatile di _instance.
E se utilizziamo il .NET Framework 2.0?
Con l'introduzione del .NET Framework 2.0 il modello di memoria è stato riveduto e corretto al fine si supportare gli sviluppatori nel passaggio alle nuove architetture IA64. Rimangono buone tutte le precedenti regole e alcune di nuove sono state introdotte tra cui una in particolare:
- Le scritture non possono essere spostate temporalmente più avanti di altre scritture dallo stesso thread.
Questa regola ci permette di evitare sia la dichiarazione volatile di _instance sia la chamata a MemoryBarrier(), risolvendo a livello di memory model la corretta serializzazione delle scritture del costruttore rispetto alla pubblicazione dell'istanza.
Thread safe senza lock. Lazyness (con riserva)
Il singleton dell'esempio qui sopra è thread safe e lazy-created (con riserva).
La lazyness è supportata da una funzionalità del CLR che garantisce che il type initializer venga eseguito "prima" dell'accesso ad un qualsiasi membro statico della classe. Nel nostro caso, subito prima del primo accesso alla proprietà statica Instance, il type initializer verrà eseguito e il campo statico _instance verrà inizializzato con una istanza di Singleton4. Il type initializer verrà eseguito però anche nel caso si acceda ad un qualsiasi altro campo o proprietà statica denifito nella classe violando (da qui la riserva), di fatto, la lazyness di Instance... Attenzione! potrebbe non essere il comportamento desiderato in fase di design.
Altra importante questione riguardante la lazyness di Singleton4 è la presenza del costruttore statico (riga 5, costruttore esplicito). La presenza del costruttore esplicito garantisce che la classe non venga decorata con il flag beforefieldinit in fase di compilazione. Il flag beforefiledinit abilita il runtime ad ottimizzare l'esecuzione del type initializer scegliendo autonomamente il momento migliore per eseguirlo, garantendone comunque l'esecuzione prima dell'accesso ad un qualunque campo statico della classe. Noi non abbiamo quindi modo di determinare precisamente il momento in cui il type initializer viene eseguito, di conseguenza la lazyness è violata. Senza il flag beforefieldinit il runtime DEVE eseguire il type initializer immediatamente prima dell'accesso ad un qualunque campo statico della classe [6].
La thread safety è supportata da una specifica del CLR che garantisce il fatto che il type initializer venga eseguito una ed una sola volta, anche in ambienti multithreaded. Il runtime, infatti, acquisisce un lock PRIMA di eseguire il type initializer, rendendo l'inizializzazione di _instance sincronizzata [6].
Thread safe senza lock. Lazy-created (garantito)
1: class Singleton5
2: {
3: private Singleton5() { }
4:
5: public static Singleton5 Instance
6: {
7: get
8: {
9: return Nested._instance;
10: }
11: }
12:
13: class Nested
14: {
15: static Nested() { }
16:
17: internal static readonly Singleton5 _instance = new Singleton5();
18: }
19: }
Il singleton dell'esempio qui sopra è thread safe (vedi le stesse motivazioni del precedente esempio) e lazy-created in ogni circostanza.
La lazyness è infatti garantita dal fatto che il type initializer di _instance viene eseguito solo ed esclusivamente prima del primo accesso ai membri statici della classe Nested (che può essere fatto solo dallo stesso Singleton5), costruita solo per questo scopo e invisibile all'esterno di Singleton5. L'accesso ad una qualsiasi altro membro statico di Singleton5 non influenza l'inizializzazione di _instance.
Note
Nel caso di decida di utilizzare l'implementazione Singleton4 o Singleton5 bisogna fare particolarmente attenzione alla particolare gestione delle eccezioni dei type initializer statici:
- Qualsiasi eccezione sollevata all'interno del costruttore statico viene automaticamente wrappata da una eccezione di tipo TypeInitializationException, l'eccezione originale è comunque disponibile attraverso la proprietà InnerException di TypeInitializationException;
- Il runtime, nel caso venga sollevata una eccezione nel costruttore statico, non tenterà di invocare una seconda volta il costruttore statico, rendendo, probabilmente, la stessa eccezione fatale per l'intera applicazione [6].
Potete scaricare i sorgenti d'esempio del post qui.
Bibliografia
[1] Adrian Florea - L'individuazione via reflection delle classi singleton all'interno del Framework .NET
[2] Jon Skeet - Implementing the Singleton Pattern in C#
[3] Gamma, Helm, Johnson, Vlissides - Design Patters, Elementi per il riuso di software a oggetti
[4] Alessandro Marotta Rizzo - Implementare Singleton con Volatile
[5] Eric Gunnerson - A programmer's introduction to C# 2.0
[6] K. Scott Allen - Get a Charge From Statics with Seven Essential Programming Tips
[7] Microsoft - ECMA C# and Common Language Infrastructure Standards
[8] Vance Morrison - Understand the Impact of Low-Lock Techniques in Multithreaded Apps