Allocazione della memoria
Comprendere la gestione della memoria per una applicazione .NET è indispensabile per evitare leak e ottenere delle buone performance, sia istantanee che nel tempo.
Quando viene lanciato il programma il sistema operativo ne assegna una porzione di RAM (estendibile a richiesta) che è formata dalla memoria managed (vedi anche questo mio post) (gli oggetti (reference types) verranno creati in questa porzione di memoria) e dalla memoria unmanaged.
In questo modo la creazione di un nuovo oggetto è molto veloce in quanto non devo chiedere al sistema operativo una nuova porzione di memoria ma questa è già disponibile per me e devo solo aggiungere un puntatore.
Non ci sono limiti al numero massimo di memoria occupabile dal nostro software, è chiaro che qualora finisca quella fisica inizierà il processo di swapping che porterà a rallentamenti non proprio gradevoli…
In .NET la gestione delle memoria avviene in automatico senza un controllo diretto dell’utente, ho un Garbage Collector che periodicamente pulisce la RAM dagli oggetti non più utilizzati; in questo modo non ho l’obbligo ne di allocare ne di deallocare memoria per gli oggetti che utilizzo.
Memoria managed e unmanaged
La stessa memoria fisica assegnata ad un processo si distingue in due tipologie: la memoria managed e unmanaged.
La memoria managed è la memoria gestita e pulita dal GC, la memoria unmanaged che è la memoria che non può essere pulita dal GC e che quindi deve essere o pulita a mano (tramite il metodo Dispose() delle classi IDisposable) o che viene utilizzata dal .NET per funzionare (per esempio per la CLR, per caricare le dll, i buffer della grafica e così via).
Allocazioni nascoste
Non sempre è facile identificare le allocazioni sullo heap, esistono infatti alcuni costrutti che portano a delle allocazioni implicite che, se conosciute, possono essere gestite e evitate.
Il caso più semplice è il boxing che è la procedura di “inscatolare” una variabile sullo stack all’interno di una sullo heap: questo avviene, per esempio, quando ho una variabile per valore (come un intero) che viene assegnata ad una variabile passata per referenza, come un object
.
Per esempio se ho un metodo che riceve in ingresso un parametro di tipo object
, verrà creato un oggetto sullo heap anche passando un intero.
L’unboxing è il procedimento opposto per cui un oggetto boxato (un intero in un oggetto object
) viene castato al suo tipo originale, tornando quindi sullo stack.
int number = 50;
// Boxing: viene "inscatolato" l'intero i all'interno di un object sullo heap
object heapObject = i;
// Unboxing: viene rimosso il box e si ritorna ad un oggetto int sullo stack
int number2 = (int) number;
E’ importante tenere conto anche delle allocazioni nascoste per evitare di occupare lo heap con oggetti non necessari.
Rilascio della memoria – Il Garbage Collector
In tutte le applicazioni il rilascio della memoria è una procedura estremamente importante in quanto permette ai software di lavorare con un minore consumo di RAM e inoltre, evitando i leak, di lavorare in continuo per periodi estremamente prolungati.
Il rilascio della memoria avviene in due modi: manualmente nel caso di oggetti unmnaged (che tipicamente ereditatano da IDisposable) e automaticamente grazie al Garbage Collector.
Per il dispose manuale rimando ad un mio vecchio articolo.
Il Garbage collector è una applicazione estremamente complessa, in sostanza il suo scopo è individuare gli oggetti dell’applicazione che non sono più utilizzati da nessuno e rimuoverli dalla memoria.
Per individuare tali oggetti individua uno o più oggetti “root” dell’applicazione e costruisce poi un grafico ad albero di tutti gli oggetti raggiungibili dalle roots.
Se un oggetto non è raggiunto da alcun ramo dell’albero viene automaticamente eliminato o aggiunto alla Finalizer queue.
E’ importante sottolineare che il lavoro di scan dell’albero per trovare gli oggetti non raggiungibili è relativamente lungo per cui anche la logica delle temporizzazioni di questo ultimo è importante: un GC troppo frequente è inutile e porta a dei rallentamenti non necessari, un GC troppo rado porta ad un aumento di RAM e a dei tempi di collection alti.
Il GC possiede delle sue logiche interne su quanto operare: non è possibile prevedere se e quando effettuerà un collect anche se è possibile forzarlo (anche se sconsigliato).
Background garbage collection
Una volta quando il GC opera l’applicazione doveva essere messa in pausa in quanto non deve essere effettuata alcuna modifica nella memoria durante lo scan. Questo è un fattore importante da tenere in considerazione per applicazioni molto veloci dove anche 100ms di freeze può portare a dei problemi di performance.
Per fortuna nelle ultime versioni di .NET è stato introdotto il background GC: questo ultimo agisce su un thread dedicato in modo asincrono. Lo scan della memoria avviene il più possibile in modo asincrono anche se è comunque possibile che vi siano lo stesso piccoli freeze durante il collect.
Generations
Il GC divide gli oggetti da eliminare in 3 macro categorie, chiamate generation, in particolare generation 0, 1, 2.
La frequenza di collect è inversamente proporzionale all’aumento del numero di gen, quindi gli oggetti gen 0 verranno eliminati più frequentemente degli oggetti gen 1 e questi ultimi più frequentemente degli oggetti gen 2.
Ogni oggetto che sopravvive al collect della sua gen viene passato alla gen successiva, quindi se un oggetto gen0 sopravvive al GC viene spostato alla gen1 e analogamente alla gen2.
Gli oggetti ancora raggiungibili della gen2 rimangono sempre in gen2.
La gen 0 viene effettuata molto frequentemente e gli oggetti che la compongono sono tipicamente molto facili da eliminare in quanto hanno poche reference con il resto del codice essendo appena stati creati. Maggiore è la gen maggiore è il tempo impiegato per lo scan e l’eliminazione, per questo avviene più di rado.
Generation | Oggetti |
0 | Oggetti appena creati (es. le variabili locali) |
1 | Oggetti gen0 sopravvissuti ad un collect del GC |
2 | Oggetti gen1 sopravvissuti ad un collect del GC. Per esempio long-lived objects come il form principale |
Il concetto di generazioni si basa sull’assunto che maggiore è il tempo in cui un oggetto rimane in memoria, maggiore è la probabilità che vi rimanga. In questo modo il GC agisce frequentemente sugli oggetti piccoli che hanno più probabilità di essere rimossi e meno frequentemente sugli oggetti che sono in memoria da più tempo.
Large Object Heap
Il Large Object Heap (LOH) è una speciale parte della memoria dedicata agli oggetti di grandi dimensioni (> 85KB). Questi oggetti sono onerosi per il GC per cui vengono eliminati solo durante una collect full (quindi gen 0,1,2).
Questi oggetti inoltre non sono deframmentati automaticamente.
Finalizer queue
La finalizer queue racchiude tutti gli oggetti che hanno un metodo finalizer (una volta chiamato distruttore) definito al loro interno che sono vivi in un determinato momento.
Il metodo finalizer tipicamente si occupa di eliminare tutte le risorse unmanaged che il GC non è in grado di gestire.
Il pattern IDisposable espone un metodo Dispose()
che elimina sia le risorse managed e unmanaged e inoltre comunica al GC di rimuovere tale oggetto dalla finalizer queue in quanto ci ha già pensato il programmatore (GC.SuppressFinalize()
).
Qualora io abbia una classe che abbia un metodo Dispose()
con il GC.SuppressFinalize()
ma questo non viene chiamato, tale classe verrà inserita nella finalizer queue e disposata a piacimento dal GC.
Avere oggetti nella finalizer queue non è un indice di buona programmazione in quanto è tutto lavoro aggiuntivo per il GC che può essere evitato dal programmatore.
Weak Reference
Come descritto sopra, un oggetto standard è un oggetto che viene rimosso quando tutti i riferimenti a questo ultimo sono stati eliminati. Quindi per poter far sì che il GC effettivamente rimuova tale oggetto è necessario prima rimuovere tutti gli oggetti che lo utilizzano.
Utilizzando una weak reference posso invece comunicare al GC che il mio riferimento all’oggetto non conta come riferimento per sapere se rimuovere l’oggetto dalla memoria o meno.
Per esempio assumiamo di avere un insieme di oggetti Foo
e di avere la necessità di salvarli in un Set
per avere una struttura dati per raccoglierli. Se però questi oggetti non vengono più utilizzati da nessuno (vengono rimosse tutte le reference) io potrei volere che il GC li rimuova dalla memoria anche se sono presenti nel Set
. Utilizzando una weak reference posso dire al GC che il puntatore all’oggetto in questione è un puntatore debole, se l’oggetto non serve a nessuno il GC lo può rimuovere anche se, in teoria, io lo sto ancora usando.
Questi oggetti possono essere utilizzati per sviluppare, per esempio, delle cache che si svuotano automaticamente in caso di sovraccarico di RAM.
Per quanto siano interessanti le weak reference rendono il codice più complicato da gestire in quanto devo pensare che ogni oggetto potrebbe non esistere più nel momento che lo uso. E’ quindi necessario aggiungere numerosi sanity check, soprattutto nel momento del get dell’oggetto.
Memory leak
Posso avere due tipologie di memory leak: il primo è quando un oggetto rimane referenziato da qualcuno anche se non più utilizzato. Avendo questo riferimento tale oggetto verrà prima promosso a gen 1 e successivamente 2 e successivamente continuerà a occupare memoria per niente.
Oltre a occupare spazio aumenta il lavoro richiesto dal GC per le promozioni di gen.
Per risolvere questo leak è necessario individuare l’oggetto che mantiene il riferimento in questione e rimuoverlo.
Posso avere anche una seconda tipologia di memory leak quando non ho alcun riferimento ad un oggetto in memoria ma questo è o possiede oggetti unmanaged. Questi oggetti non possono essere gestiti dal GC e conseguentemente rimarranno in memoria fino alla chiusura del software.
Per risolvere questo leak è necessario implementare il metodo Dispose
effettuando il dispose manuale degli oggetti unmanaged.