Claudio Brotto

Un VIRT di troppo

L'ambiente di esecuzione virtuale del Common Language Runtime effettua le chiamate di metodo attraverso la MethodTable. Questo sottintende che non solo i metodi virtuali, ma anche quelli non virtuali (statici compresi) hanno una loro identità precisa, rappresentata (anche) da un'entry nella tabella dei metodi.

Al runtime spetta la scelta di quale MethodTable utilizzare. Se, per un metodo non virtuale, questa può essere effettuata in maniera "statica" in fase di compilazione, i metodi virtuali richiedono, per la loro stessa natura, un meccanismo di scelta rimandato alla fase di esecuzione.

L'Intermediate Language consente di selezionare il comportamento richiesto tramite le due istruzioni call e callvirt.

A farla breve, si potrebbe quindi dire che i compilatori generano l'istruzione call per chiamate a metodi non virtuali (e statici) e l'istruzione callvirt per chiamate virtuali.

Non si impiega molto a verificare la validità di questa affermazione: buttiamo giù due righe di codice !

class Esempio {
  
public static void 
MetodoStatico() {}
  
public void 
MetodoNonVirtuale() {}
  
public virtual void 
MetodoVirtuale() {}
}

class 
Chiamante {
  
static Esempio
DammiUnEsempio() { ... }
  
static void 
Main() {
      Esempio esempio = DammiUnEsempio();
      Esempio.MetodoStatico();
      esempio.MetodoNonVirtuale();
      esempio.MetodoVirtuale();
  }
}

Ildasm alla mano, andiamo ad analizzare il codice IL del chiamante (Chiamante::Main()):

IL_0006:  call       void Esempio::MetodoStatico()
IL_000b:  ldloc.0
IL_000c:  callvirt   instance void Esempio::MetodoNonVirtuale()
IL_0011:  ldloc.0
IL_0012:  callvirt   instance void Esempio::MetodoVirtuale()

Mmmhhh ... Non ci siamo !

Abbiamo (IL_000c) una chiamata non virtuale effettuata tramite l'istruzione callvirt. Il che smentisce quanto assunto in precedenza. Dove sta l'errore ?

Un piccolo (!!) prologo è d'obbligo.

Virtual Dispatch.

Quando un metodo non è virtuale (questo vale, quindi, anche per i metodi statici) il compilatore ha la garanzia (per definizione) che la sua implementazione è fornita dal tipo del riferimento sul quale il metodo stesso è invocato.

Se scrivo:

 

Esempio esempio = DammiUnEsempio();

esempio.MetodoNonVirtuale();

 

il compilatore è in grado di risolvere direttamente l'indirizzo del metodo invocato.

Questo vale per un compilatore C++ che genera codice x86 da sorgenti C++, tanto quanto per il compilatore JIT del CLR che genera codice x86 da sorgenti IL.

Un'istruzione IL come:

 

IL_000c:  call       void Esempio::MetodoNonVirtuale()

 

verrà compilata just in time dando origine a codice macchina simile al seguente (a meno dei valori reali degli offset, ovviamente):

 

mov    ecx, esi

call   dword ptr ds:[02BF0D88h]

 

Innanzitutto il riferimento all’oggetto viene copiato nel registro ecx. La modalità di passaggio dei parametri dipende dalla convenzione di chiamata che il Jitter utilizza: il Jitter del CLR, almeno nelle versioni distribuite sino ad ora, utilizza la convenzione fastcall, che prevede, se possibile, il passaggio dei primi due parametri in ecx ed edx.

Successivamente, l'indirizzo del metodo (o del thunk di precompilazione, in ogni caso l'indirizzo target dell'istruzione x86 call) viene inserito direttamente all'interno dello stream di codice generato. Il compilatore lo conosce a priori. Punto.

 

Se un metodo è virtuale, le cose cambiano.

Dichiarare un metodo virtuale significa, più o meno, affermare: "Questo metodo può essere ridefinito (sovrascritto) da un tipo derivato".

E, quindi, significa che un’istruzione di chiamata come:

 

Esempio esempio = DammiUnEsempioOUnSuoDerivato();

esempio.MetodoVirtuale();

 

non è risolvibile in fase di compilazione in maniera diretta, come nel caso precedente.

Questo perchè non basta il tipo del riferimento (Esempio) per determinare quale sia l'implementazione corretta da invocare. La variabile esempio, per quanto acceduta tramite un riferimento alla classe base, potrebbe essere di tipo EsempioDerivato (class EsempioDerivato : Esempio { ... }), e se la classe EsempioDerivato ridefinisce il metodo MetodoVirtuale, allora sarà quello a dover essere utilizzato.

 

L'implementazione più comune del meccanismo di dispatch virtuale prevede l'utilizzo di  una tabella di indirizzi di metodi specifica per ciascun tipo (classicamente chiamata v-table) che sia accessibile a partire dal riferimento ad un'istanza.

In tal modo, benchè non sia noto a priori l'indirizzo del metodo da chiamare (poichè non è noto il tipo reale dell'oggetto), il compilatore conosce l'offset del metodo in tale tabella. Il "trucco", ovviamente, è assegnare ad un metodo virtuale lo stesso offset in tutte le v-table di tutti i tipi che lo definiscono: in esecuzione, il riferimento all'istanza chiamata consentirà di risalire alla tabella corretta, e l'offset (staticamente inserito dal compilatore) consentirà di invocare il metodo appropriato.

Come abbiamo visto, il sistema dei tipi del CLR non sfugge a questo disegno, anzi lo estende mantenendo informazioni a livello dei tipi che vanno ben al di là di un elenco di puntatori a funzione.

 

Ecco spiegato come mai la chiamata ad un metodo virtuale si trasforma nel seguente codice x86:

 

mov    ecx, esi

mov    eax, dword ptr [ecx]

call   dword ptr [eax + 38h]

 

Nel registro ecx è memorizzato l'indirizzo in memoria dell'istanza in questione (di tipo Esempio o derivato).

La seconda istruzione mov copia nel registro eax il contenuto di tale indirizzo di memoria. In pratica dereferenzia il puntatore all’oggetto.

Infine il metodo viene invocato indirizzando in modo indiretto la tabella dei metodi tramite un offset predeterminato in compilazione.

Il risultato è che c'è un livello di indirezione ulteriore, nella chiamata ai metodi virtuali, che risulta necessario proprio per la definizione stessa di "metodo virtuale".

  

Effetti Collaterali.

 

Ritorniamo al caso di una chiamata ad un metodo di istanza non virtuale.

Non abbiamo detto che il compilatore è in grado di determinare a priori l'indirizzo in memoria del metodo ed emettere la corrispondente istruzione call ?

Sicuramente sì, tanto è vero che l'assembler generato è:

 

mov    ecx, esi

call   dword ptr ds:[02BF0D88h]

 

Ma, ciò nonostante, chi dà origine a questa istruzione è l'opcode IL callvirt associato ad un token dei metadati che indica un metodo di istanza non virtuale.

Come mai ?

 

La spiegazione va ricercata in un side-effect del dispatch virtuale: la dereferenziazione del riferimento all'oggetto, c he risulta necessaria per accedere alla v-table.

E' un aspetto importante, poichè consente di verificare la validità del puntatore al nostro oggetto di tipo Esempio.

Se il puntatore è invalido (per esempio nullo), la chiamata non avviene poichè prima che questa sia possibile il sistema genera un'eccezione di Access Violation e il programma va in crash.

Nel caso di una chiamata ad un metodo statico, non c'è nessun riferimento da derefereniziare (niente this, per intenderci). E il compilatore C# è felicissimo di utilizzare l'istruzione IL call, che il Jitter saprà interpretare in modo appropriato.

Ma nel caso di una chiamata ad un metodo di istanza non virtuale, la funzione chiamata deve ricevere come (primo) parametro il riferimento all’oggetto sul quale è invocata.

Semplicemente che in questo caso la validità del riferimento (cioè il fatto che sia correttamente dereferenziabile) non si può verificare tramite il meccanismo di indirezione del dispatch virtuale: il metodo, in fondo, virtuale non lo è affatto, e il meccanismo di dispatch virtuale non può (non deve) intervenire.

 

Il Jitter, allora, incontrando un'istruzione callvirt su un metodo non virtuale, lo invoca in modo diretto ma fa precedere l'invocazione da un test sulla validità del riferimento all'oggetto. Lo stesso meccanismo che era implicito per i metodi virtuali diventa ora esplicito:

 

mov    ecx, esi

cmp    dword ptr [ecx], ecx

call   dword ptr ds:[02BF0D88h]

 

L’istruzione x86 cmp, per farla breve, effettua il confronto per sottrazione di due valori ed imposta in modo conseguente il registro di flag (EFLAGS); nella maggior parte degli utilizzi la si trova in congiunzione con qualche istruzione di salto condizionato.

Nel nostro caso, in realtà, il suo comportamento è del tutto inutile (o, per meglio dire, inutilizzato): non c’è alcun interesse a verificare la differenza fra l’indirizzo di memoria in ecx (puntatore all’oggetto) con il valore dword in esso contenuto (l’oggetto stesso).

A parte, appunto, quello di fallire se il riferimento in ecx è invalido.

Qualsiasi istruzione che avesse effettuato una dereferenziazione sarebbe stata adatta allo scopo.

La scelta di cmp credo sia dovuta principalmente ad un paio di fattori.

Primo: cmp non fa altro che settare flag. Non modifica registri, non modifica locazioni di memoria, tutti “effetti collaterali” sicuramente indesiderati in un caso come questo.

Secondo: prestazioni. Su un Pentium la versione di cmp che confronta un registro con un indirizzo di memoria porta via solamente due cicli di clock.

 

Ecco, alla fine, la spiegazione: in definitiva, l'utilizzo di callvirt su metodi non virtuali non modifica la modalità di chiamata, che è e resta non virtuale, ma serve esclusivamente ad indicare al Jitter di aggiungere un controllo ulteriore prima della chiamata stessa.

 

La prova del 9.

 

In altre parole, cosa succederebbe se il Jitter ignorasse la necessità di questo controllo ed emettesse solamente l’istruzione di chiamata ?

Quali problemi porta con sé un codice come il seguente (che è, nè più nè meno, quello generato dall'istruzione IL call) ?

 

mov    ecx, esi

; Questo non viene eseguito:

; cmp    dword ptr [ecx], ecx

call   dword ptr ds:[02BF0D88h]

 

Come detto, se il riferimento all’oggetto è valido l’istruzione cmp è del tutto superflua, e il codice scritto sopra è perfettamente legale e corretto.

Ma poniamo il caso che, per un errore o semplicemente per costruzione, il metodo DammiUnEsempio() sia:

 

static Esempio DammiUnEsempio() {

   return null;

}

 

Il chiamante si ritrova con un riferimento nullo, che viene salvato nel registro ecx come primo parametro per il MetodoNonVirtuale. Senza istruzione cmp, questa situazione non provoca errori e il controllo viene passato al metodo stesso.

 

Eccoci così con un metodo di istanza pronto ad essere eseguito su ... nessuna istanza !

Il metodo inizierà la propria esecuzione con la garanzia (implicita nel suo essere “metodo di istanza”) di avere a disposizione un puntatore this valido in ecx. Alla prima occasione in cui tale puntatore andrà dereferenziato (per esempio al primo accesso ad un campo di istanza) il programma genererà la famigerata eccezione di violazione di accesso alla memoria (il codice di stato dell’eccezione in SEH me lo ricordo a memoria, 0xC0000005, chissà come mai ... ).

 

E giusto che venga generata una NullReferenceException/Access Violation ?

Sicuramente sì, perchè tentiamo di dereferenziare un riferimento invalido.

E’ giusto che questa sia generata dal MetodoNonVirtuale() ?

Sicuramente no ! Non è compito del metodo di istanza verificare la validità dell’istanza, è compito del suo chiamante !

In questo caso, eliminando l’istruzione cmp, abbiamo sollevato il chiamante da una sua precisa responsabilità, e l’effetto che se ne ottiene è che abbiamo un’eccezione originata nel posto sbagliato.

Addirittura, la cosa si fa curiosa se, ad esempio, il metodo di istanza è:

 

void MetodoNonVirtuale() {

  Console.WriteLine("Ciao");

}

 

Se il metodo non “tocca” il riferimento this, non viene generata alcuna eccezione ed il codice è (o meglio, sembra) esente da errori. Siamo riusciti a chiamare un metodo di istanza su un’istanza nulla, e l’esecuzione procede senza intoppi.

 

In definitiva: l’istruzione cmp, che funge da “dereferenziatore” è necessaria per garantire che un metodo non virtuale riceva un riferimento all’istanza valido, e che l’esecuzione vada in exception nel punto di chiamata se questa condizione, al contrario, non si verifica.

 

Considerazioni.

 

Come sempre, andando ad indagare il funzionamento del CLR, ci sono diversi attori in gioco, e diversi livelli dai quali si possono osservare i fenomeni: linguaggio macchina, Linguaggio Intermedio, linguaggio ad alto livello (C# ecc.).

Il tutto diventa interessante quando si deve scegliere in quale ambito implementare una funzionalità.

E' una caratteristica del linguaggio ? Del motore di esecuzione ? Chi deve garantire cosa ?

Nella situazione discussa sinora, la funzionalità da realizzare è: "garantire che un metodo di istanza sia invocato su un'istanza non nulla, e fallire in caso contrario".

IL, in questa occasione, mostra il suo lato di linguaggio a basso livello e lascia ai linguaggi "applicativi" l'onere del controllo: se le specifiche di un linguaggio lo consentono, il compilatore relativo è libero di violare questa invarianza ed effettuare chiamate su riferimenti nulli (bontà sua ...) emettendo un opcode call.

Le specifiche di C#, però, indicano con chiarezza che una chiamata su un riferimento nullo deve generare una NullReferenceException. L'utilizzo di callvirt su metodi non virtuali può sembrare una forzatura, ma il compilatore C# deve ... materializzare le sue specifiche, sfruttando le funzionalità ed i meccanismi che il runtime mette a disposizione.

 

 

 

 

Posted: ago 04 2005, 03:46 by devlizard | with no comments
Filed under: