Scrittura di applicazioni gestite High-Performance : Introduzione

 

Gregor Noriskin
Microsoft CLR Performance Team

Giugno 2003

Si applica a:
   Microsoft® .NET Framework

Riepilogo: Informazioni su Common Language Runtime di .NET Framework dal punto di vista delle prestazioni. Informazioni su come identificare le procedure consigliate per le prestazioni del codice gestito e su come misurare le prestazioni dell'applicazione gestita. (19 pagine stampate)

Scaricare CLR Profiler. (330 KB)

Contenuto

Gioco come metafora per lo sviluppo di software
The .NET Common Language Runtime
Dati gestiti e Garbage Collector
Profili di allocazione
API profilatura e Profiler CLR
Hosting del server GC
Finalizzazione
Modello Dispose
Nota sui riferimenti deboli
Codice gestito e CLR JIT
Tipi valore
Gestione delle eccezioni
Threading e sincronizzazione
Reflection
Associazione tardiva
Security
Interoperabilità COM e Platform Invoke
Contatori delle prestazioni
Altri strumenti
Conclusione
Risorse

Gioco come metafora per lo sviluppo di software

Juggling è una grande metafora per descrivere il processo di sviluppo del software. La deggling richiede in genere almeno tre elementi, anche se non è previsto alcun limite massimo al numero di elementi che è possibile provare a destreggiarsi. Quando si inizia a imparare come destreggiarsi si scopre che si watch ogni palla singolarmente mentre si cattura e gettano loro. Mentre si procede si inizia a concentrarsi sul flusso delle palle, invece di ogni singola palla. Quando hai imparato a giocare, puoi ancora una volta concentrarsi su una singola palla, bilanciando quella palla sul naso, continuando a destreggiarsi gli altri. Sai in modo intuitivo dove saranno le palle e puoi mettere la mano nel posto giusto per catturarle e buttarle. Come funziona lo sviluppo di software?

Ruoli diversi nel processo di sviluppo software destreggiano diverse "trinità"; Project and Program Manager destreggia le funzionalità, le risorse e il tempo e gli sviluppatori software destreggiano la correttezza, le prestazioni e la sicurezza. Si può sempre provare a destreggiarsi più oggetti, ma come qualsiasi studente di gioco può attestare, l'aggiunta di una singola palla lo rende esponenzialmente più difficile mantenere le palle in aria. Tecnicamente, se stai destreggiando meno di tre palle non stai affatto destreggiando. Se, in qualità di sviluppatore di software, non si considera la correttezza, le prestazioni e la sicurezza del codice che si sta scrivendo, il caso può essere fatto che non si sta eseguendo il proprio lavoro. Quando si inizia inizialmente a considerare correttezza, prestazioni e sicurezza, ci si troverà a dover concentrarsi su un aspetto alla volta. Man mano che diventano parte della pratica quotidiana, si noterà che non è necessario concentrarsi su un aspetto specifico, essi saranno semplicemente parte del modo in cui si lavora. Dopo averli masterati, sarà possibile fare compromessi in modo intuitivo e concentrare le proprie attività in modo appropriato. E come per il gioco, la pratica è la chiave.

La scrittura di codice ad alte prestazioni ha una trinità propria; Impostazione di obiettivi, misurazione e comprensione della piattaforma di destinazione. Se non si sa quanto velocemente deve essere il codice, come si saprà al termine? Se non si misura e si profila il codice, come si sa quando si soddisfano gli obiettivi o perché non si soddisfano gli obiettivi? Se non si comprende la piattaforma di destinazione, come si saprà cosa ottimizzare nel caso in cui non si soddisfano gli obiettivi. Questi principi si applicano allo sviluppo di codice ad alte prestazioni in generale, indipendentemente dalla piattaforma di destinazione. Nessun articolo sulla scrittura di codice ad alte prestazioni sarebbe completo senza menzionare questa trinità. Anche se tutti e tre sono ugualmente significativi, questo articolo è incentrato sugli ultimi due aspetti che si applicano alla scrittura di applicazioni ad alte prestazioni destinate a Microsoft® .NET Framework.

I principi fondamentali della scrittura di codice ad alte prestazioni in qualsiasi piattaforma sono:

  1. Impostare gli obiettivi di prestazioni
  2. Misurare, misurare e quindi misurare altre
  3. Comprendere le piattaforme hardware e software destinate all'applicazione

The .NET Common Language Runtime

Il core di .NET Framework è Common Language Runtime (CLR). CLR fornisce tutti i servizi di runtime per il codice; Compilazione JUST-In-Time, Gestione della memoria, Sicurezza e diversi altri servizi. CLR è stato progettato per garantire prestazioni elevate. Detto questo, ci sono modi in cui è possibile sfruttare le prestazioni e i modi in cui è possibile ostacolarlo.

L'obiettivo di questo articolo è offrire una panoramica di Common Language Runtime dal punto di vista delle prestazioni, identificare le procedure consigliate per le prestazioni del codice gestito e illustrare come misurare le prestazioni dell'applicazione gestita. Questo articolo non è una discussione completa sulle caratteristiche delle prestazioni di .NET Framework. Ai fini di questo articolo definire le prestazioni per includere velocità effettiva, scalabilità, tempo di avvio e utilizzo della memoria.

Dati gestiti e Garbage Collector

Uno dei principali problemi degli sviluppatori sull'uso del codice gestito nelle applicazioni critiche per le prestazioni è il costo della gestione della memoria di CLR, eseguita dal Garbage Collector (GC). Il costo della gestione della memoria è una funzione del costo di allocazione della memoria associato a un'istanza di un tipo, il costo della gestione della memoria per tutta la durata dell'istanza e il costo di liberare tale memoria quando non è più necessario.

Un'allocazione gestita è in genere molto economica; nella maggior parte dei casi richiede meno tempo rispetto a C/C++ malloc o new. Ciò è dovuto al fatto che CLR non ha bisogno di analizzare un elenco gratuito per trovare il successivo blocco di memoria contiguo disponibile abbastanza grande da contenere il nuovo oggetto; mantiene un puntatore alla posizione libera successiva in memoria. È possibile considerare le allocazioni di heap gestite come "stack like". Un'allocazione può causare una raccolta se il GC deve liberare memoria per allocare il nuovo oggetto, nel qual caso l'allocazione è più costosa di un malloc oggetto o new. Anche gli oggetti aggiunti possono influire sul costo di allocazione. Gli oggetti aggiunti sono oggetti che il GC non deve spostare durante una raccolta, in genere perché l'indirizzo dell'oggetto è stato passato a un'API nativa.

A differenza di o mallocnew, è previsto un costo associato alla gestione della memoria per tutta la durata di un oggetto. CLR GC è generazionale, il che significa che l'intero heap non viene sempre raccolto. Tuttavia, il GC deve comunque sapere se gli oggetti attivi nel resto degli oggetti radice dell'heap nella parte dell'heap che viene raccolto. La memoria che contiene oggetti che contengono riferimenti a oggetti nelle generazioni più recenti è costosa da gestire per tutta la durata degli oggetti.

GC è un mark generazionale e garbage collector sweep. L'heap gestito contiene tre generazioni; La generazione 0 contiene tutti i nuovi oggetti, la generazione 1 contiene oggetti di durata leggermente più lunga e la generazione 2 contiene oggetti di lunga durata. Il GC raccoglierà la sezione più piccola dell'heap possibile per liberare memoria sufficiente per continuare l'applicazione. La raccolta di una generazione include la raccolta di tutte le generazioni più giovani, in questo caso una raccolta di generazione 1 raccoglie anche generation 0. La generazione 0 viene ridimensionata dinamicamente in base alle dimensioni della cache del processore e alla frequenza di allocazione dell'applicazione e in genere richiede meno di 10 millisecondi per raccogliere. La generazione 1 viene ridimensionata dinamicamente in base al tasso di allocazione dell'applicazione e in genere richiede tra 10 e 30 millisecondi per raccogliere. Le dimensioni della generazione 2 dipendono dal profilo di allocazione dell'applicazione, in quanto il tempo necessario per la raccolta. Si tratta di queste raccolte di seconda generazione che influiranno maggiormente sul costo delle prestazioni della gestione della memoria delle applicazioni.

SUGGERIMENTO Il GC è auto-ottimizzazione e si adatterà in base ai requisiti di memoria delle applicazioni. Nella maggior parte dei casi, richiamare un GC a livello di codice impedirà tale ottimizzazione. "Aiutare" il GC chiamando GC. La raccolta non migliorerà probabilmente le prestazioni delle applicazioni.

GC può rilocare gli oggetti attivi durante una raccolta. Se tali oggetti sono elevati, il costo della rilocazione è elevato in modo che tali oggetti vengano allocati in un'area speciale dell'heap denominata Heap oggetti grandi. L'heap oggetti di grandi dimensioni viene raccolto, ma non viene compattato, ad esempio gli oggetti di grandi dimensioni non vengono rilocati. Gli oggetti di grandi dimensioni sono oggetti di dimensioni superiori a 80 KB. Si noti che questa modifica può essere modificata nelle versioni future di CLR. Quando l'heap oggetti Large deve essere raccolto, forza una raccolta completa e l'heap Large Object viene raccolto durante le raccolte di generazione 2. L'allocazione e il tasso di morte degli oggetti nell'heap di oggetti di grandi dimensioni possono avere un effetto significativo sul costo delle prestazioni della gestione della memoria delle applicazioni.

Profili di allocazione

Il profilo di allocazione complessivo di un'applicazione gestita definirà il modo in cui il Garbage Collector deve lavorare per gestire la memoria associata all'applicazione. Più difficile è il lavoro GC per gestire la memoria, maggiore è il numero di cicli della CPU necessari per GC e meno tempo la CPU impiega per l'esecuzione del codice dell'applicazione. Il profilo di allocazione è una funzione del numero di oggetti allocati, delle dimensioni di tali oggetti e della relativa durata. Il modo più ovvio per alleviare la pressione GC è semplicemente allocare meno oggetti. Le applicazioni progettate per l'estendibilità, la modularità e il riutilizzo usando le tecniche di progettazione orientata agli oggetti risulteranno quasi sempre un aumento del numero di allocazioni. C'è una penalità delle prestazioni per l'astrazione e l'"eleganza".

Un profilo di allocazione compatibile con GC avrà alcuni oggetti allocati all'inizio dell'applicazione e quindi sopravvive per tutta la durata dell'applicazione e quindi tutti gli altri oggetti sono di breve durata. Gli oggetti di lunga durata conterranno pochi o nessun riferimento a oggetti di breve durata. Poiché il profilo di allocazione si discosta da questo, il GC dovrà lavorare più duramente per gestire la memoria delle applicazioni.

Un profilo di allocazione GC-unfriendly avrà molti oggetti che sopravvivono alla generazione 2 e quindi moriranno o avranno molti oggetti di breve durata allocati nell'heap oggetti di grandi dimensioni. Gli oggetti che sopravvivono abbastanza a lungo per entrare nella generazione 2 e poi muoiono sono i più costosi da gestire. Come accennato prima di oggetti nelle generazioni precedenti che contengono riferimenti a oggetti nelle generazioni più giovani durante un GC, aumentano anche il costo della raccolta.

Un tipico profilo di allocazione reale sarà in un punto qualsiasi tra i due profili di allocazione indicati in precedenza. Una metrica importante del profilo di allocazione è la percentuale del tempo totale di CPU impiegato in GC. È possibile ottenere questo numero dalla memoria CLR .NET: % tempo nel contatore delle prestazioni GC. Se il valore medio di questo contatore è superiore al 30% è probabilmente consigliabile esaminare più da vicino il profilo di allocazione. Ciò non significa necessariamente che il profilo di allocazione sia "errato"; esistono alcune applicazioni a elevato utilizzo di memoria in cui questo livello di GC è necessario e appropriato. Questo contatore dovrebbe essere la prima cosa che si osserva se si verificano problemi di prestazioni; dovrebbe essere visualizzato immediatamente se il profilo di allocazione fa parte del problema.

SUGGERIMENTO Se il contatore delle prestazioni CLR di .NET CLR Memory: % Time in GC indica che l'applicazione sta spendendo una media superiore al 30% del tempo in GC, è consigliabile esaminare più da vicino il profilo di allocazione.

SUGGERIMENTO Un'applicazione compatibile con GC avrà raccolte di generazione 0 significativamente superiori rispetto alle raccolte di seconda generazione. Questo rapporto può essere stabilito confrontando i contatori delle prestazioni net CLR Memory: # Gen 0 Collections e NET CLR Memory: # Gen 2 Collections.

API profilatura e Profiler CLR

CLR include un'API di profilatura potente che consente a terze parti di scrivere profiler personalizzati per le applicazioni gestite. CLR Profiler è uno strumento di esempio di profilatura dell'allocazione non supportato, scritto dal team del prodotto CLR, che usa questa API di profilatura. CLR Profiler consente agli sviluppatori di visualizzare il profilo di allocazione delle applicazioni di gestione.

Figura 1 Finestra principale del profiler CLR

CLR Profiler include una serie di visualizzazioni molto utili del profilo di allocazione, tra cui un istogramma di tipi allocati, allocazione e grafici delle chiamate, una linea temporale che mostra i GCS di varie generazioni e lo stato risultante dell'heap gestito dopo tali raccolte e un albero delle chiamate che mostra le allocazioni per metodo e i carichi di assembly.

Figura 2 Grafico allocazione profiler CLR

SUGGERIMENTO Per informazioni dettagliate su come usare CLR Profiler, vedere il file leggimi incluso nel file ZIP.

Si noti che CLR Profiler presenta un sovraccarico a prestazioni elevate e modifica significativamente le caratteristiche delle prestazioni dell'applicazione. I bug di stress emergenti scompariranno probabilmente quando si esegue l'applicazione con CLR Profiler.

Hosting del server GC

Per CLR sono disponibili due Garbage Collector diversi: un GC workstation e un server GC. Le applicazioni console e Windows Forms ospitano workstation GC e ASP.NET ospitano il Server GC. Il server GC è ottimizzato per la velocità effettiva e la scalabilità multiprocessore. Il server GC sospende tutti i thread che eseguono codice gestito per l'intera durata di una raccolta, incluse le fasi mark e sweep, e GC avviene in parallelo su tutti i thread della CPU disponibili per il processo su thread con affinità CPU con priorità elevata dedicati. Se i thread eseguono codice nativo durante un processo GC, tali thread vengono sospesi solo quando la chiamata nativa viene restituita. Se si sta creando un'applicazione server che verrà eseguita in computer multiprocessore, è consigliabile usare server GC. Se l'applicazione non è ospitata da ASP.NET, sarà necessario scrivere un'applicazione nativa che ospita in modo esplicito CLR.

SUGGERIMENTO Se si creano applicazioni server scalabili, ospitare il Server GC. Vedere Implementare un host Common Language Runtime personalizzato per l'app gestita.

Workstation GC è ottimizzato per una bassa latenza, in genere necessaria per le applicazioni client. Non si vuole una pausa evidente in un'applicazione client durante un GC, poiché in genere le prestazioni del client non vengono misurate dalla velocità effettiva non elaborata, ma piuttosto dalle prestazioni percepite. La workstation GC esegue GC simultaneo, il che significa che esegue la fase di contrassegno mentre il codice gestito è ancora in esecuzione. Il GC sospende solo i thread che eseguono codice gestito quando deve eseguire la fase sweep. In Workstation GC, GC viene eseguito solo su un thread e quindi solo su una CPU.

Finalizzazione

CLR fornisce un meccanismo in cui la pulizia viene eseguita automaticamente prima che la memoria associata a un'istanza di un tipo venga liberata. Questo meccanismo è denominato Finalizzazione. In genere la finalizzazione viene usata per rilasciare risorse native, in questo caso connessioni alle banche dati o handle del sistema operativo usati da un oggetto .

La finalizzazione è una funzionalità costosa e aumenta la pressione che viene messa sul GC. Il GC tiene traccia degli oggetti che richiedono la finalizzazione in una coda finalizzabile. Se durante una raccolta GC trova un oggetto che non è più attivo, ma richiede la finalizzazione, la voce dell'oggetto nella coda Finalizable viene spostata nella coda FReachable. La finalizzazione avviene in un thread separato denominato Thread finalizzatore. Poiché l'intero stato dell'oggetto può essere necessario durante l'esecuzione del finalizzatore, l'oggetto e tutti gli oggetti a cui punta vengono promossi alla generazione successiva. La memoria associata all'oggetto o al grafico degli oggetti viene liberata solo durante il GC seguente.

Le risorse che devono essere rilasciate devono essere racchiuse nel più piccolo possibile in un oggetto Finalizable; Ad esempio, se la classe richiede riferimenti a risorse gestite e non gestite, è necessario eseguire il wrapping delle risorse non gestite in una nuova classe Finalizable e impostare tale classe come membro della classe. La classe padre non deve essere Finalizable. Ciò significa che verrà promossa solo la classe che contiene le risorse non gestite , presupponendo che non si contenga un riferimento alla classe padre nella classe contenente le risorse non gestite. Un altro aspetto da tenere presente è che esiste un solo thread di finalizzazione. Se un finalizzatore blocca questo thread, i finalizzatori successivi non verranno chiamati, le risorse non verranno liberate e l'applicazione perderà.

SUGGERIMENTO I finalizzatori devono essere mantenuti il più semplici possibile e non devono mai bloccare.

SUGGERIMENTO Rendere finalizzabile solo la classe wrapper per gli oggetti non gestiti che richiedono la pulizia.

La finalizzazione può essere considerata come un'alternativa al conteggio dei riferimenti. Un oggetto che implementa il conteggio dei riferimenti tiene traccia del numero di altri oggetti a cui fa riferimento (che può causare alcuni problemi noti), in modo che possa rilasciare le risorse quando il numero di riferimenti è zero. CLR non implementa il conteggio dei riferimenti, pertanto deve fornire un meccanismo per rilasciare automaticamente le risorse quando non vengono mantenuti altri riferimenti all'oggetto. La finalizzazione è questo meccanismo. La finalizzazione è in genere necessaria solo nel caso in cui la durata di un oggetto che richiede la pulizia non sia nota in modo esplicito.

Modello Dispose

Nel caso in cui la durata dell'oggetto sia nota in modo esplicito, le risorse non gestite associate a un oggetto devono essere rilasciate con entusiasmo. Questa operazione è denominata "Eliminazione" dell'oggetto . Il criterio Dispose viene implementato tramite l'interfaccia IDisposable (anche se l'implementazione è semplice). Se si vuole rendere disponibile la finalizzazione eager per la classe, ad esempio rendere eliminabili le istanze della classe, è necessario che l'oggetto implementi l'interfaccia IDisposable e fornisca un'implementazione per il metodo Dispose . Nel metodo Dispose chiamerai lo stesso codice di pulizia presente nel finalizzatore e informerai il GC che non deve più finalizzare l'oggetto chiamando il GC. SuppressFinalization , metodo. È consigliabile che sia il metodo Dispose che il finalizzatore chiamino una funzione di finalizzazione comune in modo che sia necessario mantenere una sola versione del codice di pulizia. Inoltre, se la semantica dell'oggetto è tale che un metodo Close sarà più logico di un metodo Dispose , deve essere implementato anche un metodo Close ; in questo caso, una connessione al database o un socket è logicamente "chiusa". Close può semplicemente chiamare il metodo Dispose.

È sempre consigliabile fornire un metodo Dispose per le classi con un finalizzatore; non si può mai essere sicuri di come la classe verrà usata, ad esempio, se la sua durata sarà esplicitamente nota o meno. Se una classe in uso implementa il modello Dispose e si conosce in modo esplicito quando si esegue l'operazione con l'oggetto , chiamare in modo più definitivo Dispose.

SUGGERIMENTO Specificare un metodo Dispose per tutte le classi finalizzabili.

SUGGERIMENTO Eliminare la finalizzazione nel metodo Dispose .

SUGGERIMENTO Chiamare una funzione di pulizia comune.

SUGGERIMENTO Se un oggetto in uso implementa IDisposable e si sa che l'oggetto non è più necessario, chiamare Dispose.

C# offre un modo molto pratico per eliminare automaticamente gli oggetti. La using parola chiave consente di identificare un blocco di codice dopo il quale Dispose verrà chiamato su un numero di oggetti eliminabili.

Parola chiave using di C#

using(DisposableType T)
{
   //Do some work with T
}
//T.Dispose() is called automatically

Nota sui riferimenti deboli

Qualsiasi riferimento a un oggetto che si trova nello stack, in un registro, in un altro oggetto o in una delle altre radici GC manterrà attivo un oggetto durante un GC. Questo è in genere un aspetto molto positivo, considerando che in genere significa che l'applicazione non viene eseguita con tale oggetto. Esistono tuttavia casi in cui si vuole avere un riferimento a un oggetto, ma non si vuole influire sulla durata. In questi casi CLR fornisce un meccanismo denominato Riferimenti deboli a tale scopo. Qualsiasi riferimento sicuro, ad esempio un riferimento che radici un oggetto, può essere trasformato in un riferimento debole. Un esempio di quando è possibile usare riferimenti deboli è quando si desidera creare un oggetto cursore esterno in grado di attraversare una struttura di dati, ma non deve influire sulla durata dell'oggetto. Un altro esempio è se si desidera creare una cache scaricata quando si verifica un utilizzo elevato di memoria; ad esempio, quando si verifica un GC.

Creazione di un riferimento debole in C#

MyRefType mrt = new MyRefType();
//...

//Create weak reference
WeakReference wr = new WeakReference(mrt); 
mrt = null; //object is no longer rooted
//...

//Has object been collected?
if(wr.IsAlive)
{
   //Get a strong reference to the object
   mrt = wr.Target;
   //object is rooted and can be used again
}
else
{
   //recreate the object
   mrt = new MyRefType();
}

Codice gestito e CLR JIT

Gli assembly gestiti, che sono l'unità di distribuzione per il codice gestito, contengono un linguaggio indipendente dal processore denominato Microsoft Intermediate Language (MSIL o IL). CLR JUST-In-Time (JIT) compila le istruzioni IL in istruzioni X86 native ottimizzate. JIT è un compilatore di ottimizzazione, ma poiché la compilazione avviene in fase di esecuzione e solo la prima volta che viene chiamato un metodo, il numero di ottimizzazioni che deve essere bilanciato rispetto al tempo necessario per eseguire la compilazione. In genere, questo non è fondamentale per le applicazioni server poiché il tempo di avvio e la velocità di risposta non è in genere un problema, ma è fondamentale per le applicazioni client. Si noti che l'ora di avvio può essere migliorata eseguendo la compilazione in fase di installazione usando NGEN.exe.

Molte delle ottimizzazioni eseguite dal JIT non dispongono di modelli a livello di codice associati, ad esempio, non è possibile codice in modo esplicito per loro, ma esiste un numero che esegue. La sezione successiva illustra alcune di queste ottimizzazioni.

SUGGERIMENTO Migliorare il tempo di avvio delle applicazioni client compilando l'applicazione in fase di installazione usando l'utilità NGEN.exe.

Metodo Inlining

C'è un costo associato alle chiamate al metodo; gli argomenti devono essere inseriti nello stack o archiviati nei registri, il prolog del metodo e l'epiloga devono essere eseguiti e così via. Il costo di queste chiamate può essere evitato per determinati metodi semplicemente spostando il corpo del metodo chiamato nel corpo del chiamante. Questo è chiamato Metodo In-line. JIT usa un numero di euristici per decidere se un metodo deve essere allineato. Di seguito è riportato un elenco di quelli più significativi (si noti che questo non è esaustivo):

  • I metodi che sono maggiori di 32 byte di IL non verranno inlinedi.
  • Le funzioni virtuali non sono inlined.
  • I metodi con controllo flusso complesso non saranno allineati. Il controllo flusso complesso è qualsiasi controllo del flusso diverso da if/then/else; in questo caso switch o while.
  • I metodi che contengono blocchi di gestione delle eccezioni non sono inlinedi, anche se i metodi che generano eccezioni sono ancora candidati per l'inlining.
  • Se uno degli argomenti formali del metodo è struct, il metodo non verrà inlinedeto.

Prenderei attentamente in considerazione la codifica in modo esplicito per queste euristiche perché potrebbero cambiare nelle versioni future del JIT. Non compromettere la correttezza del metodo per tentare di garantire che venga inlinedata. È interessante notare che le inline parole chiave e __inline in C++ non garantiscono che il compilatore inlinerà un metodo (anche se __forceinline lo fa).

I metodi get e set di proprietà sono in genere buoni candidati per l'inlining, poiché tutto ciò che fanno è in genere inizializzare i membri dei dati privati.

**HINT **Non compromettere la correttezza di un metodo in un tentativo di garantire l'inlining.

Eliminazione controllo intervallo

Uno dei numerosi vantaggi del codice gestito è il controllo automatico dell'intervallo; ogni volta che si accede a una matrice usando la semantica array[index], JIT genera un controllo per assicurarsi che l'indice si trovi nei limiti della matrice. Nel contesto dei cicli con un numero elevato di iterazioni e un numero ridotto di istruzioni eseguite per iterazione questi controlli di intervallo possono essere costosi. Esistono casi in cui JIT rileverà che questi controlli di intervallo non sono necessari e elimineranno il controllo dal corpo del ciclo, controllandolo una sola volta prima dell'inizio dell'esecuzione del ciclo. In C# esiste un modello a livello di codice per assicurarsi che questi controlli di intervallo vengano eliminati: test esplicito per la lunghezza della matrice nell'istruzione "for". Si noti che le deviazioni sottili da questo modello comportano l'eliminazione del controllo e, in questo caso, l'aggiunta di un valore all'indice.

Eliminazione controllo intervallo in C#

//Range check will be eliminated
for(int i = 0; i < myArray.Length; i++) 
{
   Console.WriteLine(myArray[i].ToString());
}

//Range check will NOT be eliminated
for(int i = 0; i < myArray.Length + y; i++) 
{ 
   Console.WriteLine(myArray[i+x].ToString());
}

L'ottimizzazione è particolarmente evidente durante la ricerca di matrici di grandi dimensioni, ad esempio, poiché il controllo intervallo interno e esterno del ciclo viene eliminato.

Ottimizzazioni che richiedono il rilevamento dell'utilizzo delle variabili

Un numero di ottimizzazioni del compilatore JIT richiede che il JIT tiene traccia dell'utilizzo degli argomenti formali e delle variabili locali; ad esempio, quando vengono usati per la prima volta e l'ultima volta che vengono usati nel corpo del metodo. Nella versione 1.0 e 1.1 di CLR esiste una limitazione di 64 sul numero totale di variabili per cui il JIT tiene traccia dell'utilizzo. Un esempio di ottimizzazione che richiede il rilevamento dell'utilizzo è Enregistration. L'iscrizione è quando le variabili vengono archiviate nei registri del processore anziché nel frame dello stack, ad esempio in RAM. L'accesso alle variabili enregistered è notevolmente più veloce rispetto a se si trovano nel frame dello stack, anche se la variabile nel frame si trova nella cache del processore. Verranno considerate solo 64 variabili per Enregistration; tutte le altre variabili verranno push nello stack. Esistono altre ottimizzazioni diverse da Enregistrazione che dipendono dal rilevamento dell'utilizzo. Il numero di argomenti formali e locali per un metodo deve essere mantenuto al di sotto di 64 per garantire il numero massimo di ottimizzazioni JIT. Tenere presente che questo numero potrebbe cambiare per le versioni future di CLR.

SUGGERIMENTO Mantenere i metodi brevi. Esistono diversi motivi per questo, tra cui l'inlining del metodo, la registrazione e la durata JIT.

Altre ottimizzazioni JIT

Il compilatore JIT esegue numerose altre ottimizzazioni: propagazione costante e copia, esecuzione di cicli invarianti e diversi altri. Non esistono modelli di programmazione espliciti che è necessario usare per ottenere queste ottimizzazioni; sono liberi.

Perché non vengono visualizzate queste ottimizzazioni in Visual Studio?

Quando si usa Start dal menu Debug o si preme F5 per avviare un'applicazione in Visual Studio, se è stata compilata una versione release o debug, tutte le ottimizzazioni JIT verranno disabilitate. Quando un'applicazione gestita viene avviata da un debugger, anche se non è una compilazione debug dell'applicazione, il JIT genererà istruzioni x86 non ottimizzate. Se si vuole avere il codice ottimizzato per l'emissione JIT, avviare l'applicazione da Esplora risorse o usare CTRL+F5 da Visual Studio. Se si vuole visualizzare il disassembly ottimizzato e contrastarlo con il codice non ottimizzato, è possibile usare cordbg.exe.

SUGGERIMENTO Usare cordbg.exe per visualizzare il disassembly del codice ottimizzato e non ottimizzato generato dal JIT. Dopo aver avviato l'applicazione con cordbg.exe, è possibile impostare la modalità JIT digitando quanto segue:

(cordbg) mode JitOptimizations 1
JIT's will produce optimized code

(cordbg) mode JitOptimizations 0

JIT produrrà codice debug (non ottimizzato).

Tipi valore

CLR espone due diversi set di tipi, tipi di riferimento e tipi di valore. I tipi di riferimento vengono sempre allocati nell'heap gestito e vengono passati per riferimento (come implica il nome). I tipi di valore vengono allocati nello stack o inline come parte di un oggetto nell'heap e vengono passati per valore per impostazione predefinita, anche se è anche possibile passarli per riferimento. I tipi di valore sono molto economici per allocare e, presupponendo che siano mantenuti piccoli e semplici, sono economici per passare come argomenti. Un esempio valido di un uso appropriato dei tipi di valore è un tipo di valore Point che contiene una coordinatax e y.

Tipo valore punto

struct Point
{
   public int x;
   public int y;
   
   //
}

I tipi di valore possono essere considerati anche come oggetti; Ad esempio, i metodi oggetto possono essere chiamati su di essi, possono essere chiamato all'oggetto o passati in cui è previsto un oggetto. Quando questo avviene, tuttavia, il tipo di valore viene convertito in un tipo di riferimento tramite un processo denominato Boxing. Quando un tipo di valore è Boxed, un nuovo oggetto viene allocato nell'heap gestito e il valore viene copiato nel nuovo oggetto. Si tratta di un'operazione costosa e può ridurre o negare completamente le prestazioni ottenute usando i tipi di valore. Quando il tipo Boxed è implicitamente o in modo esplicito esegue il cast in un tipo di valore, è Unboxed.

Tipo di valore Box/Unbox

C#:

int BoxUnboxValueType()
{
   int i = 10;
   object o = (object)i; //i is Boxed
   return (int)o + 3; //i is Unboxed
}

MSIL:

.method private hidebysig instance int32
        BoxUnboxValueType() cil managed
{
  // Code size       20 (0x14)
  .maxstack  2
  .locals init (int32 V_0,
           object V_1)
  IL_0000:  ldc.i4.s   10
  IL_0002:  stloc.0
  IL_0003:  ldloc.0
  IL_0004:  box        [mscorlib]System.Int32
  IL_0009:  stloc.1
  IL_000a:  ldloc.1
  IL_000b:  unbox      [mscorlib]System.Int32
  IL_0010:  ldind.i4
  IL_0011:  ldc.i4.3
  IL_0012:  add
  IL_0013:  ret
} // end of method Class1::BoxUnboxValueType

Se si implementano tipi di valore personalizzati (struct in C#), è consigliabile eseguire l'override del metodo ToString . Se non si esegue l'override di questo metodo, le chiamate a ToString nel tipo di valore causeranno la creazione del tipo boxed. Questo vale anche per gli altri metodi ereditati da System.Object, in questo caso Equals, anche se ToString è probabilmente il metodo più spesso chiamato. Se si vuole sapere se e quando il tipo di valore è in corso Boxed, è possibile cercare l'istruzione nell'oggetto MSIL usando l'utilità box ildasm.exe (come nel frammento precedente).

Override del metodo ToString() in C# per impedire il boxing

struct Point
{
   public int x;
   public int y;

   //This will prevent type being boxed when ToString is called
   public override string ToString()
   {
      return x.ToString() + "," + y.ToString();
   }
}

Tenere presente che quando si creano raccolte, ad esempio un arrayList di float, ogni elemento verrà boxed quando viene aggiunto all'insieme. È consigliabile usare una matrice o creare una classe di raccolta personalizzata per il tipo di valore.

Boxing implicito quando si usano classi di raccolta in C#

ArrayList al = new ArrayList();
al.Add(42.0F); //Implicitly Boxed becuase Add() takes object
float f = (float)al[0]; //Unboxed

Gestione delle eccezioni

È pratica comune usare le condizioni di errore come normale controllo del flusso. In questo caso, quando si tenta di aggiungere un utente a livello di codice a un'istanza di Active Directory, è sufficiente tentare di aggiungere l'utente e, se viene restituito un E_ADS_OBJECT_EXISTS HRESULT, si sa che esistono già nella directory. In alternativa, è possibile cercare la directory per l'utente e quindi aggiungere l'utente solo se la ricerca ha esito negativo.

Questo uso di errori per il controllo del flusso normale è un modello anti-pattern delle prestazioni nel contesto di CLR. La gestione degli errori in CLR viene eseguita con la gestione delle eccezioni strutturata. Le eccezioni gestite sono molto economiche finché non vengono generate. In CLR, quando viene generata un'eccezione, è necessaria una procedura dettagliata dello stack per trovare un gestore di eccezioni appropriato per l'eccezione generata. La passeggiata dello stack è un'operazione costosa. Le eccezioni devono essere usate come nome; in circostanze eccezionali o impreviste.

**HINT **Prendere in considerazione la restituzione di un risultato enumerato per i risultati previsti, anziché generare un'eccezione, per i metodi critici per le prestazioni.

**HINT **Esistono diversi contatori delle prestazioni clr CLR .NET che indicano quanti eccezioni vengono generate nell'applicazione.

**HINT **Se si usa VB.NET usare eccezioni anziché On Error Goto; l'oggetto errore è un costo non necessario.

Threading e sincronizzazione

CLR espone funzionalità di threading e sincronizzazione avanzate, inclusa la possibilità di creare thread personalizzati, un pool di thread e varie primitive di sincronizzazione. Prima di sfruttare il supporto del threading in CLR, è consigliabile considerare attentamente l'uso dei thread. Tenere presente che l'aggiunta di thread può effettivamente ridurre la velocità effettiva anziché aumentarla e assicurarsi che aumenterà l'utilizzo della memoria. Nelle applicazioni server che verranno eseguite su computer multiprocessore, l'aggiunta di thread può migliorare significativamente la velocità effettiva parallelizzando l'esecuzione (anche se dipende dalla quantità di contesa di blocco in corso, ad esempio la serializzazione dell'esecuzione) e nelle applicazioni client, aggiungendo un thread per mostrare attività e/o avanzamento può migliorare le prestazioni percepite (a un costo di velocità effettiva ridotto).

Se i thread nell'applicazione non sono specializzati per un'attività specifica o hanno uno stato speciale associato a essi, è consigliabile usare il pool di thread. Se è stato usato il pool di thread Win32 in passato, il pool di thread CLR sarà molto familiare all'utente. Esiste una singola istanza del pool di thread per processo gestito. Il pool di thread è intelligente sul numero di thread creati e si ottimizza in base al carico nel computer.

Non è possibile discutere il threading senza discutere la sincronizzazione; tutti i vantaggi della velocità effettiva che il multithreading può concedere all'applicazione può essere negato dalla logica di sincronizzazione scritta in modo errato. La granularità dei blocchi può influire significativamente sulla velocità effettiva complessiva dell'applicazione, sia a causa del costo della creazione e della gestione del blocco e del fatto che i blocchi possono potenzialmente serializzare l'esecuzione. Verrà usato l'esempio di tentativo di aggiungere un nodo a un albero per illustrare questo punto. Se l'albero sarà una struttura di dati condivisa, ad esempio, più thread devono accedervi durante l'esecuzione dell'applicazione e sarà necessario sincronizzare l'accesso all'albero. È possibile scegliere di bloccare l'intero albero durante l'aggiunta di un nodo, il che significa che si comporta solo il costo della creazione di un singolo blocco, ma altri thread che tentano di accedere all'albero probabilmente si bloccano. Si tratta di un esempio di blocco con granularità grossolana. In alternativa, è possibile bloccare ogni nodo durante l'attraversamento dell'albero, il che significa che si comporta il costo della creazione di un blocco per nodo, ma altri thread non vengono bloccati a meno che non tentino di accedere al nodo specifico bloccato. Questo è un esempio di blocco con granularità fine. Probabilmente una granularità più appropriata del blocco sarebbe bloccare solo il sottoalbero su cui si opera. Si noti che in questo esempio probabilmente si userebbe un blocco condiviso (RWLock), perché più lettori dovrebbero essere in grado di ottenere l'accesso contemporaneamente.

Il modo più semplice e più elevato per eseguire operazioni sincronizzate consiste nell'usare la classe System.Threading.Interlocked. La classe Interlocked espone una serie di operazioni atomiche di basso livello: Increment, Decrement, Exchange e CompareExchange.

Uso della classe System.Threading.Interlocked in C#

using System.Threading;
//...
public class MyClass
{
   void MyClass() //Constructor
   {
      //Increment a global instance counter atomically
      Interlocked.Increment(ref MyClassInstanceCounter);
   }

   ~MyClass() //Finalizer
   {
      //Decrement a global instance counter atomically
      Interlocked.Decrement(ref MyClassInstanceCounter);
      //... 
   }
   //...
}

Probabilmente il meccanismo di sincronizzazione più comunemente usato è la sezione Monitoraggio o critica. Un blocco di monitoraggio può essere usato direttamente o usando la lock parola chiave in C#. La lock parola chiave sincronizza l'accesso, per l'oggetto specificato, a un blocco di codice specifico. Un blocco monitor che è abbastanza leggero contestato è relativamente economico dal punto di vista delle prestazioni, ma diventa più costoso se è altamente contestato.

Parola chiave di blocco C#

//Thread will attempt to obtain the lock
//and block until it does
lock(mySharedObject)
{
   //A thread will only be able to execute the code
   //within this block if it holds the lock
}//Thread releases the lock

RWLock fornisce un meccanismo di blocco condiviso: ad esempio, "lettori" può condividere il blocco con altri "lettori", ma un "writer" non può. Nei casi in cui ciò è applicabile, RWLock può comportare una velocità effettiva migliore rispetto all'uso di un monitoraggio, che consente solo a un singolo lettore o writer di ottenere il blocco alla volta. Lo spazio dei nomi System.Threading include anche la classe Mutex. Un Mutex è una primitiva di sincronizzazione che consente la sincronizzazione tra processi. Tenere presente che questa operazione è notevolmente più costosa di una sezione critica e deve essere usata solo nel caso in cui sia necessaria la sincronizzazione tra processi.

Reflection

La reflection è un meccanismo fornito da CLR, che consente di ottenere informazioni sui tipi a livello di codice in fase di esecuzione. La reflection dipende in larga parte dai metadati, incorporati negli assembly gestiti. Molte API di reflection richiedono la ricerca e l'analisi dei metadati, che sono operazioni dispendiose.

Le API di reflection possono essere raggruppate in tre bucket di prestazioni; confronto dei tipi, enumerazione membro e chiamata dei membri. Ognuno di questi bucket diventa progressivamente più costoso. Le operazioni di confronto dei tipi, in questo caso typeof in C#, GetType, is, IsInstanceOfType e così via, sono la più economica delle API di reflection, anche se non sono a buon mercato. Le enumerazioni membro consentono di esaminare a livello di codice i metodi, le proprietà, i campi, gli eventi, i costruttori e così via di una classe. In questo caso, è possibile usare questi scenari in fase di progettazione, in questo caso enumerando le proprietà dei controlli Web doganali per il Visualizzatore proprietà in Visual Studio. Le API di reflection più costose sono quelle che consentono di richiamare dinamicamente i membri di una classe o generare dinamicamente JIT ed eseguire un metodo. Esistono certamente scenari con associazione tardiva in cui sono necessari il caricamento dinamico di assembly, le istanze di tipi e le chiamate ai metodi, ma questo accoppiamento libero richiede un compromesso esplicito sulle prestazioni. In generale, le API di reflection devono essere evitate nei percorsi di codice sensibili alle prestazioni. Si noti che, anche se non si usa direttamente la reflection, un'API usata potrebbe usarla. Tenere quindi presente anche l'uso transitivo delle API di reflection.

Associazione tardiva

Le chiamate con associazione tardiva sono un esempio di funzionalità che usa Reflection sotto le quinte. Visual Basic.NET e JScript.NET supportano entrambe le chiamate ad associazione tardiva. Ad esempio, non è necessario dichiarare una variabile prima dell'uso. Gli oggetti ad associazione tardiva sono effettivamente di tipo oggetto e Reflection viene usato per convertire l'oggetto nel tipo corretto in fase di esecuzione. Una chiamata ad associazione tardiva è un ordine di grandezza più lento rispetto a una chiamata diretta. A meno che non sia necessario specificamente un comportamento con associazione tardiva, è consigliabile evitare l'uso nei percorsi di codice critici per le prestazioni.

SUGGERIMENTO Se si usa VB.NET e non è necessaria in modo esplicito l'associazione tardiva, è possibile indicare al compilatore di non consentirlo includendo e Option Explicit OnOption Strict On all'inizio dei file di origine. Queste opzioni forzano a dichiarare e digitare fortemente le variabili e disattivare il cast implicito.

Sicurezza

La sicurezza è una parte essenziale e integrante di CLR e presenta un costo delle prestazioni associato. Nel caso in cui il codice sia completamente attendibile e i criteri di sicurezza siano l'impostazione predefinita, la sicurezza deve avere un impatto minore sulla velocità effettiva e sul tempo di avvio dell'applicazione. Il codice parzialmente attendibile, ad esempio il codice dell'area Internet o Intranet, o la riduzione del set di concessioni MyComputer aumenterà il costo delle prestazioni della sicurezza.

Interoperabilità COM e Platform Invoke

Interoperabilità COM e Platform Invoke espongono API native al codice gestito in modo quasi trasparente; la chiamata della maggior parte delle API native in genere non richiede codice speciale, anche se potrebbe richiedere alcuni clic del mouse. Come ci si può aspettare, è previsto un costo associato alla chiamata di codice nativo dal codice gestito e viceversa. A questo costo sono associati due componenti: un costo fisso associato all'operazione di transizione tra codice nativo e gestito e un costo variabile associato a qualsiasi marshalling di argomenti e valori restituiti che potrebbero essere necessari. Il contributo fisso al costo sia per l'interoperabilità COM che per P/Invoke è ridotto: in genere meno di 50 istruzioni. Il costo del marshalling da e verso i tipi gestiti dipenderà dal modo in cui le rappresentazioni si trovano su entrambi i lati del limite. I tipi che richiedono una quantità significativa di trasformazione saranno più costosi. Ad esempio, tutte le stringhe in CLR sono stringhe Unicode. Se si chiama un'API Win32 tramite P/Invoke che prevede una matrice di caratteri ANSI, ogni carattere nella stringa deve essere ristretto. Tuttavia, se viene passata una matrice integer gestita in cui è prevista una matrice integer nativa, non è necessario eseguire il marshalling.

Poiché è previsto un costo delle prestazioni associato alla chiamata al codice nativo, è necessario assicurarsi che il costo sia giustificato. Se si intende effettuare una chiamata nativa, assicurarsi che il lavoro che la chiamata nativa giustifica il costo delle prestazioni associato all'esecuzione della chiamata, ovvero mantenere i metodi "in blocchi" anziché "chatty". Un buon modo per misurare il costo di una chiamata nativa consiste nel misurare le prestazioni di un metodo nativo che non accetta argomenti e non ha alcun valore restituito e quindi misurare le prestazioni del metodo nativo da chiamare. La differenza ti darà un'indicazione del costo di marshalling.

SUGGERIMENTO Effettuare chiamate di interoperabilità COM "Chunky" e P/Invoke anziché chiamate "Chatty" e assicurarsi che il costo di effettuare la chiamata sia giustificato dalla quantità di lavoro eseguita dalla chiamata.

Si noti che non sono presenti modelli di threading associati ai thread gestiti. Quando si effettua una chiamata di interoperabilità COM, è necessario assicurarsi che il thread su cui verrà eseguita la chiamata venga inizializzato nel modello di threading COM corretto. Questa operazione viene in genere eseguita usando MTAThreadAttribute e STAThreadAttribute (anche se può essere eseguita a livello di codice).

Contatori delle prestazioni

Diversi contatori delle prestazioni di Windows vengono esposti per .NET CLR. Questi contatori delle prestazioni devono essere un'arma di scelta dello sviluppatore quando si diagnostica un problema di prestazioni o quando si tenta di identificare le caratteristiche delle prestazioni di un'applicazione gestita. Ho già menzionato alcuni dei contatori correlati alla gestione della memoria e alle eccezioni. Esistono contatori delle prestazioni per quasi tutti gli aspetti di CLR e .NET Framework. Questi contatori delle prestazioni sono sempre disponibili e non sono invasivi; hanno un sovraccarico ridotto e non modificano le caratteristiche delle prestazioni dell'applicazione.

Altri strumenti

Oltre ai contatori delle prestazioni e al profiler CLR, è consigliabile usare un profiler convenzionale per verificare quali metodi nell'applicazione richiedono più tempo ed essere chiamati più spesso. Questi saranno i metodi ottimizzati per primi. Sono disponibili diversi profiler commerciali che supportano il codice gestito, tra cui DevPartner Studio Professional Edition 7.0 da Compuware e VTune™ analizzatore prestazioni 7.0 da Intel®. Compuware produce anche un profiler gratuito per il codice gestito denominato DevPartner Profiler Community Edition.

Conclusione

Questo articolo inizia solo l'esame di CLR e .NET Framework dal punto di vista delle prestazioni. Esistono molti altri aspetti dell'architettura di CLR e .NET Framework che influiranno sulle prestazioni dell'applicazione. Il materiale sussidiario migliore che posso fornire a qualsiasi sviluppatore è quello di non fare ipotesi sulle prestazioni della piattaforma di destinazione dell'applicazione e le API in uso. Misura tutto!

Felice juggling.

Risorse