[APPTXT] CLR, reference types e locazioni in memoria
Ok, discorso anche questo vecchio come il mondo.
Il tutto, usando come riferimento le domande che mi sono state fatte, parte spesso dalla descrizione del type system utilizzato dal CLR e dalla distinzione, fondamentale, fra reference e value type.
E' un argomento tanto fondamentale quanto, a volte, difficile da illustrare in maniera semplice. Soprattutto a chi ha poca dimestichezza con le "strutture di memoria" che vengono utilizzate durante l'esecuzione di un'applicazione (e non parlo solo di applicazioni .NET).
In questo post vorrei, appunto, cercare di descrivere "che cosa succede in memoria" nel momento in cui andiamo ad allocare oggetti di tipo reference.
Approccio "a bomba sul codice" ed ecco qua:
internal class ReferenceType
{
public Int32 Number;
public void WriteTheNumber() { System.Console.WriteLine(Number.ToString()); }
}
class Program
{
static void Main()
{
ReferenceType anObject;
anObject = new ReferenceType();
anObject.Number = 42;
anObject.WriteTheNumber();
}
}
Credo che il codice sia auto-esplicativo: abbiamo definito un tipo reference (ReferenceType) che contiene un campo pubblico intero Number; il codice chiamante (in questo caso il metodo statico Main) istanzia un oggetto di tipo ReferenceType e ne memorizza il riferimento nella variabile locale anObject. Infine accede ad un campo e ad un metodo di istanza.
Due righine di codice, ma alla fine abbiamo causato modifiche ad almeno 3 aree di memoria ben definite e distinte del processo in esecuzione.
Lo stack
Scrivendo
ReferenceType anObject;
abbiamo come minimo occupato sizeof(IntPtr) bytes in cima allo stack del thread chiamante.
Brevissimamente: ogni thread Win32 ha in dote dal sistema operativo un'area di memoria "tutta sua", lo stack del thread, grande (per default, si può cambiare) 1 Mb.
Quando definiamo una variabile locale (come nell'esempio) questa risiede, fisicamente, nello stack del thread chiamante.
Inoltre, una chiamata a funzione comporta la creazione, in cima allo stack, di un frame che consente di identificare la funzione in esecuzione e di ritornare al punto di chiamata al termine della stessa.
Quando la funzione ritorna, il frame viene "virtualmente" rimosso dalla cima dello stack. Virtualmente perchè in realtà non viene azzerata la memoria che questo ha occupato, ma più semplicemente viene "spostato indietro" il valore che indica la locazione di memoria della cima dello stack, il primo posto libero da utilizzare.
Corollario: ogni variabile locale, quindi ogni variabile dichiarata all'interno di un metodo, "muore" al termine di questo, ovverosia non è più raggiungibile una volta che il frame del metodo viene rimosso dallo stack.
Ritornando al nostro esempio e a quanto detto all'inizio, la prima istruzione del metodo Main implica la allocazione di sizeof(IntPtr) bytes nello stack.
Una precisazione, prima di concludere.
Con sizeof(IntPtr) intendo la dimensione, in bytes, di un puntatore, cioè di un indirizzo di memoria. Questo valore dipende dall'architettura per la quale l'applicazione viene compilata (in .NET, lo ricordo, stiamo parlando della compilazione Just In Time, che genera codice macchina per l'architettura di esecuzione).
Le piattaforme x86 (32 bit) hanno indirizzi di memoria da 4 bytes, le piattaforme a 64 bit hanno indirizzi di memoria da 8 bytes, e via dicendo.
Managed Heap
L'istruzione successiva
anObject = new ReferenceType();
è un filino più complessa, dietro le quinte.
Detta in breve, succedono tre cose.
Primo. Da "qualche parte" viene allocata un'area di memoria sufficiente a contenere l'oggetto anObject, di tipo ReferenceType.
Secondo. Viene richiamato il costruttore di ReferenceType, che "inizializza" l'oggetto.
Terzo. L'indirizzo di memoria dell'oggetto appena istanziato viene memorizzato nella variabile locale anObject, residente sullo stack.
Tre affermazioni, nessuna completa :(
Con ordine ...
Il "qualche parte" è un'area di memoria, ben distinta dallo stack dei thread (e pre inciso comune a tutti), chiamata, solitamente, managed heap o heap gestito.
Gestito = gestito dal Common Language Runtime, sia in fase di allocazione che in fase di rilascio della memoria (il famigerato Garbage Collector agisce qui).
Il CLR, quindi, si occupa personalmente (sorry ... forse non è il caso di dare del tu al CLR ?!) di realizzare il layout dell'oggetto in memoria, riservando lo spazio per tutte le sue ... cose.
Quali cose ?
Beh, in prima battuta, tutti i campi che prendono parte alla definizione del relativo tipo. In questo caso, semplicemente 4 byte per rappresentare il campo Number (Int32). Questi 4 byte risiedono, fisicamente, all'interno del managed heap.
E questi 4 bytes sono valorizzati nel momento in cui il costruttore del tipo ReferenceType viene invocato.
In IL, questo corrisponde all'istruzione newobj, che riceve come parametro un "identificatore" del metodo .ctor (nickname del costruttore di istanza), riserva spazio all'oggetto e infine invoca il .ctor, responsabile dell'inizializzazione dello stato dell'oggetto.
Nel nostro esempio il costruttore ... non c'è ! In realtà il compilatore C# (idem fa VB.NET) ne inserisce uno di default, senza parametri. Per default, inoltre, i campi vengono inizializzati a valori ... beh, i numerici a zero, i riferimenti a null, i boolean a false, ecc...
Detto questo, alla fine del punto secondo ci ritroviamo con un po' di memoria, all'interno del managed heap, riservata e inizializzata per l'oggetto anObject. Questa area di memoria, beh, *è* l'oggetto. Almeno in parte, come vedremo a breve.
Il codice chiamante (e veniamo al punto 3) ottiene quindi un riferimento a quell'area di memoria (un puntatore, se volete).
E tramite quel riferimento è in grado di utilizzare l'oggetto.
Accedendo ai suoi campi (fatti salvi i vari modificatori di accessibilità) e, magari, invocando i suoi metodi.
Metodi .... mmmhhh ... non manca qualcosa, quindi ?
Dove stanno i metodi ? Le proprietà ? Insomma, da che OOP è OOP ci hanno insegnato che un oggetto contiene "both attributes and behaviour". Attributes, qui, ci sono. Behaviour, mica tanto. Insomma ... una classe (un tipo) non è solo una sequenza di campi !
Visto dove risiedono i campi in memoria, tempo di passare al terzo, conclusivo paragrafo. E di chiudere il cerchio.
CLR Heaps
Il concetto di tipo è assolutamente basilare nell'architettura del CLR.
Un tipo è completamente descritto dai suoi metadati.
A bocce ferme, possiamo prendere un assembly .NET e ispezionarlo (vedi anche APPTXT#1). Tanto noi, quanto i vari tool (XSD.exe, SOAPSUDS.exe, REGSVCS.exe, TLBEXP.exe ... e la lista potrebbe continuare a lungo).
A bocce in movimento (leggi: a runtime) le stesse informazioni sono ovviamente disponibili. Ma è chiaro che non è pensabile che il CLR effettui un I/O su disco ogni volta che deve avere informazioni su un tipo.
E infatti ...
Il CLR "possiede" diverse aree di memoria all'interno del processo host. Aventi scopi differenti, magari, ma comunque tutte deputate a garantire il funzionamento di una (o più)delle sue funzionalità.
Una di queste aree di memoria è nota con il nome di Loader Heap (da *non confondere* con managed heap).
Il loader heap è, per così dire, zona privata al common language runtime: il codice utente non vi alloca mai direttamente, il garbage collector non spazzola il loader heap.
Il CLR, tuttavia, utilizza questa area di memoria per caricare e mantenere strutture dati (piuttosto complesse, articolate e non del tutto documentate) che rappresentano un tipo. Se vogliamo, una copia dei metadati *estremamente* ottimizzata per le prestazioni in fase di esecuzione.
A questa area di memoria ci si riferisce generalmente con il nome di MethodTable.
Nome che non è del tutto corretto, ma ne indica comunque parzialmente i contenuti. Un tipo in memoria è rappresentato *anche* da un array i cui elementi rimandano, per via più o meno diretta, al codice dei metodi esposti dal tipo stesso.
A fare le pulci a quanto ho scritto ci sono alcune affermazioni un po' frettolose, ma quello che mi interessa chiarire è più o meno detto: così come i singoli oggetti, anche i tipi (classi, strutture, interfacce ...) hanno una propria identità all'interno del processo che esegue un'applicazione .NET.
Come si lega il tutto
Ricapitoliamo un attimo.
- Sullo stack abbiamo allocato 4 byte per la variabile locale anObject.
- Nel managed heap abbiamo allocato almeno 4 byte per lo stato dell'oggetto (il campo Number).
- Nel loader heap qualcuno ha allocato n bytes che rappresentano il tipo ReferenceType.
Come si ... legano queste aree di memoria ? Altrimenti detto, e considerato che a partire dalla variabile locale possiamo invocare un metodo il cui riferimento è mantenuto altrove, come è possibile risalire da anObject all'indirizzo del metodo WriteTheNumber, cosa che ovviamente sarà richiesta in esecuzione ?
Abbiamo già raccontato, in realtà, la metà della storia: la variabile locale contiene l'indirizzo di memoria dereferenziando il quale è possibile "arrivare" all'oggetto contenuto nel managed heap.
Rimane, tuttavia, da spiegare, il legame fra la memoria che descrive il tipo (nel loader heap) e quella che contiene i dati dell'oggetto (nel managed heap).
Presto fatto (e così correggiamo anche un imprecisione !).
Immediatamente al di sopra dell'area dati che contiene lo stato di anObject (nel managed heap) è presente un riferimento (grande, pure lui, sizeof(IntPtr)) che contiene l'indirizzo di memoria della MethodTable (nel loader heap).
E, qui sta la correzione, il riferimento anObject memorizzato sullo stack punta, in realtà, al puntatore alla MethodTable memorizzato nel managed heap.
Spero che il disegnetto qui sotto illustri meglio la situazione.

Nota
Ripeto quanto detto all'inizio: questo post si focalizza sulle aree di memoria coinvolte nell'allocazione di un oggetto di tipo reference.
Di roba ne è rimasta fuori parecchia. Solito problema, argomenti ce ne sono per scrivere un libro, probabilmente. Una cosa su tutte: come funziona il tutto con i value types ?
Beh ... diciamo che ne parliamo in un prossimo, spero, post della serie.
Per ora spero solo che, magari, alcune questioni risultino un po più chiare.
E, inutile dirlo, ogni imprecisione e/o errore in questo post è dovuto al sonno e non è imputabile in alcun modo al Common Language Runtime, a heap e stack o ai puntatori in generale :)
Risorse e link
Come sempre, qualche link per descrizioni più dettagliate.
Libri: consiglio (e non solo su questo argomento, ma in modo assolutamente generale e in ordine assolutamente sparso):
Sul web ... mioddio, c'è pieno di documentazione, faccio quasi prima a linkare una querystring di ricerca su google !
Anzi ... questo lo lascio come esercizio ;)