Nota
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare ad accedere o modificare le directory.
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare a modificare le directory.
Di Sébastien Ros e Rick Anderson
La gestione della memoria è complessa, anche in un framework gestito come .NET. L'analisi e la risoluzione dei problemi di memoria possono risultare difficili. Le perdite di memoria e i problemi di Garbage Collection (GC) sono in genere dovuti alla mancanza di comprensione del funzionamento dell'utilizzo della memoria in .NET o alla mancata comprensione del processo di misurazione dell'utilizzo.
Questo articolo illustra i modelli comuni di utilizzo della memoria che possono essere problematici e suggerisce approcci alternativi.
Esplora il Garbage Collection (GC) in .NET
Il processo GC alloca segmenti di heap, dove ogni segmento è un intervallo di memoria contiguo. Gli oggetti inseriti nell'heap vengono classificati in una delle tre generazioni: 0, 1 o 2. La generazione determina la frequenza dei tentativi di GC di rilasciare memoria sugli oggetti gestiti a cui l'app non fa più riferimento. Il GC si occupa più frequentemente delle generazioni con numeri più bassi.
Gli oggetti vengono spostati da una generazione a un'altra in base alla loro durata. Man mano che gli oggetti vivono più a lungo, vengono spostati in una generazione superiore. Come accennato in precedenza, il GC viene eseguito meno spesso nelle generazioni più elevate. Gli oggetti di breve durata rimangono sempre nella Generazione 0. Ad esempio, gli oggetti a cui viene fatto riferimento durante la durata di una richiesta Web sono di breve durata. I singleton a livello di applicazione in genere vengono migrati a Gen 2.
All'avvio di una app ASP.NET Core, il processo GC:
- Riserva memoria per i segmenti dell'heap iniziale.
- Riserva una piccola porzione di memoria durante il caricamento del runtime.
Le allocazioni di memoria precedenti vengono eseguite per motivi di prestazioni. Il vantaggio delle prestazioni deriva dai segmenti dell'heap in memoria contigua.
Esaminare le considerazioni quando si usa GC.Collect
In generale, le app ASP.NET Core nell'ambiente di produzione non dovrebbero usare il metodo GC.Collect esplicitamente. L'induzione della Garbage Collection in momenti non ottimali può ridurre significativamente le prestazioni.
GC.Collect è utile durante l'analisi delle perdite di memoria. La chiamata GC.Collect() attiva un ciclo bloccante di garbage collection che tenta di recuperare tutti gli oggetti inaccessibili dal codice gestito. È un modo utile per comprendere le dimensioni degli oggetti attivi raggiungibili nell'heap e tenere traccia della crescita delle dimensioni della memoria nel tempo.
Analizzare l'utilizzo della memoria di un'app
Gli strumenti dedicati consentono di analizzare l'utilizzo della memoria, tra cui:
- Conteggio dei riferimenti all'oggetto.
- Misurazione dell'effetto di GC sull'utilizzo della CPU.
- Misurazione dello spazio di memoria usato per ogni generazione.
Usare gli strumenti seguenti per analizzare l'utilizzo della memoria:
- Utilità dotnet-trace (può essere usata nei computer di produzione)
- Analizzare l'utilizzo della memoria senza il debugger di Visual Studio
- Misurare l'utilizzo della memoria in Visual Studio
Rilevare i problemi di memoria
Gestione attività può essere usato per ottenere un'idea della quantità di memoria usata da un'app ASP.NET. Valore di memoria di Gestione Attività:
- Rappresenta la quantità di memoria utilizzata dal processo di ASP.NET.
- Includono gli oggetti viventi dell'app e altri consumatori di memoria, come l'utilizzo della memoria nativa.
Se il valore di memoria del Task Manager aumenta indefinitamente e non si appiattisce mai, l'app presenta una perdita di memoria. Le sezioni seguenti illustrano e spiegano vari modelli di utilizzo della memoria.
Esplorare l'app di esempio per visualizzare l'utilizzo della memoria
L'app di esempio MemoryLeak è disponibile in GitHub. App MemoryLeak:
- Include un controller di diagnostica che raccoglie dati di memoria e GC in tempo reale per l'app.
- Dispone di una pagina Indice che visualizza i dati di memoria e GC. La pagina Indice viene aggiornata ogni secondo.
- Contiene un controller API che fornisce vari modelli di carico di memoria.
- Può essere usato per visualizzare i modelli di utilizzo della memoria delle app ASP.NET Core, ma non è uno strumento supportato.
Eseguire MemoryLeak. La memoria allocata aumenta lentamente fino a quando non si verifica un GC. La memoria aumenta perché lo strumento alloca un oggetto personalizzato per acquisire i dati. L'immagine seguente mostra la pagina MemoryLeak Index quando si verifica un GC di generazione 0. Il grafico mostra 0 RPS (richieste al secondo) perché non sono stati chiamati endpoint API dal controller API.
Il grafico visualizza due valori per l'utilizzo della memoria:
- Allocato: quantità di memoria occupata da oggetti gestiti.
- Working Set: gruppo di pagine nello spazio indirizzi virtuale del processo attualmente residente nella memoria fisica. Il working set visualizzato è lo stesso valore visualizzato da Gestione attività. Per altre informazioni, vedere Working Set.
Oggetti temporanei
L'API seguente crea un'istanza stringa di 20 KB e la restituisce al client. In ogni richiesta, un nuovo oggetto viene allocato in memoria e scritto nella risposta. Le stringhe vengono archiviate come caratteri UTF-16 in .NET, quindi ogni carattere accetta 2 byte in memoria.
[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
return new String('x', 10 * 1024);
}
Il grafico seguente viene generato con un carico relativamente ridotto che mostra come GC influisce sulle allocazioni di memoria.
Il grafico illustra i dettagli seguenti:
- RPS 4K (richieste al secondo)
- Le raccolte GC di generazione 0 si verificano circa ogni 2 secondi
- Working Set costante, circa 500 MB
- CPU è al 12%
- Stabilità nell'utilizzo e rilascio della memoria (tramite GC)
Il grafico seguente viene acquisito alla velocità effettiva massima che il computer può gestire.
Il grafico illustra i dettagli seguenti:
- 22 K RPS
- Le raccolte GC di generazione 0 vengono eseguite più volte al secondo
- Le raccolte di generazione 1 si attivano perché l'app alloca una quantità di memoria significativamente maggiore al secondo.
- Working Set costante, circa 500 MB
- CPU è 33%
- Stabile utilizzo e rilascio della memoria (tramite GC)
- CPU (33%) non è sovrautilizzato, quindi GC può tenere il passo con un numero elevato di allocazioni
Workstation GC contro Server GC
.NET Garbage Collector ha due modalità diverse:
- Workstation GC: ottimizzato per il desktop.
- Server GC: GC predefinito per le app ASP.NET Core. Ottimizzato per il server.
La modalità GC può essere impostata in modo esplicito nel file di progetto o nel file runtimeconfig.json dell'app pubblicata. Il markup seguente mostra l'impostazione ServerGarbageCollection nel file di progetto:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
La modifica ServerGarbageCollection nel file di progetto richiede la ricompilazione dell'app.
Note
La raccolta dei rifiuti del server non è disponibile su macchine con un singolo core. Per ulteriori informazioni, consultare la proprietà IsServerGC.
L'immagine seguente mostra il profilo di memoria sotto un carico di 5K richieste al secondo (RPS) utilizzando la Workstation GC.
Le differenze tra questo grafico e la versione del server sono significative:
- Working Set scende da 500 MB a 70 MB
- GC esegue raccolte di generazione 0 più volte al secondo invece di ogni 2 secondi
- GC scende da 300 MB a 10 MB
In un tipico ambiente server Web, l'utilizzo della CPU è più importante della memoria, pertanto il Server GC è migliore. Se l'utilizzo della memoria è elevato e l'utilizzo della CPU è relativamente basso, l'GC della workstation potrebbe essere più efficiente. Ad esempio, l'hosting ad alta densità di diverse app Web in cui la memoria è scarsa.
GC con Docker e contenitori di piccole dimensioni
Quando più app in contenitori vengono eseguite nello stesso computer, Workstation GC potrebbe essere più efficiente rispetto a Server GC. Per ulteriori informazioni, vedere Running with Server GC in a Small Container (blog) e Running with Server GC in a Small Container Scenario Part 1 – Hard Limit for the GC Heap (blog).
Riferimenti agli oggetti persistenti
L'GC non può liberare oggetti a cui viene fatto riferimento. Gli oggetti a cui si fa riferimento ma non sono più necessari generano una perdita di memoria. Se l'app alloca spesso gli oggetti e non li libera dopo che non sono più necessari, l'utilizzo della memoria aumenta nel tempo.
L'API seguente crea un'istanza stringa di 20 KB e la restituisce al client. La differenza con l'esempio precedente è che un membro statico fa riferimento a questa istanza, il che significa che l'istanza non è mai disponibile per la raccolta.
private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();
[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
var bigString = new String('x', 10 * 1024);
_staticStrings.Add(bigString);
return bigString;
}
Il codice precedente:
- Illustra una tipica perdita di memoria.
- Con l'uso frequente delle chiamate, porta all'aumento della memoria dell'app fino a far arrestare il processo con un errore
OutOfMemory.
Il grafico illustra i dettagli seguenti:
- Il test di carico dell'endpoint
/api/staticstringcausa un aumento lineare della memoria - GC tenta di liberare memoria man mano che aumenta la pressione della memoria chiamando una raccolta di generazione 2
- GC non riesce a liberare la memoria trapelata; il Set di Lavoro e la Memoria Allocata aumentano con il tempo
Alcuni scenari, ad esempio la memorizzazione nella cache, richiedono che i riferimenti agli oggetti vengano mantenuti fino a quando la pressione della memoria non forza il rilascio. La WeakReference classe può essere usata per questo tipo di codice di memorizzazione nella cache. Un WeakReference oggetto viene raccolto sotto pressione di memoria. L'implementazione predefinita dell'interfaccia IMemoryCache usa WeakReference.
Memoria nativa
Alcuni oggetti .NET si basano sulla memoria nativa, ma la memoria nativa non è raccoglibile dal GC. L'oggetto .NET che usa la memoria nativa deve liberarlo usando codice nativo.
.NET fornisce l'interfaccia IDisposable per consentire agli sviluppatori di rilasciare memoria nativa. Anche se il Dispose metodo non viene chiamato, le classi implementate correttamente chiamano Dispose quando viene eseguito il finalizzatore .
Si consideri il seguente codice :
[HttpGet("fileprovider")]
public void GetFileProvider()
{
var fp = new PhysicalFileProvider(TempPath);
fp.Watch("*.*");
}
PhysicalFileProvider è una classe gestita, quindi qualsiasi istanza viene raccolta alla fine della richiesta.
L'immagine seguente mostra il profilo di memoria durante la chiamata continua dell'API fileprovider .
Il grafico precedente mostra un problema ovvio con l'implementazione di questa classe, in quanto continua ad aumentare l'utilizzo della memoria. Questo risultato è un problema noto rilevato in GitHub problema dotnet/aspnetcore #844.
La stessa perdita si verifica nel codice utente negli scenari seguenti:
- Non rilasciare correttamente la classe
- Dimenticando di richiamare il
Disposemetodo degli oggetti dipendenti da eliminare
Heap di oggetti di grandi dimensioni
L'allocazione di memoria o i cicli liberi frequenti comportano memoria frammentata, soprattutto quando si allocano blocchi di memoria di grandi dimensioni. Gli oggetti vengono allocati in blocchi contigui di memoria. Per ridurre la frammentazione, quando GC libera memoria, tenta di deframmentarla. Questo processo è denominato compattazione. La compattazione comporta lo spostamento di oggetti. Lo spostamento di oggetti di grandi dimensioni comporta una riduzione delle prestazioni. Per questo motivo, GC crea una zona di memoria speciale per oggetti di grandi dimensioni , denominata heap di oggetti di grandi dimensioni (LOH). Gli oggetti maggiori di 85.000 byte (circa 83 KB) sono:
- Posizionato sul LOH
- Non compattato
- Elaborata durante la raccolta di Generazione 2
Quando il LOH è pieno, il garbage collector attiva una raccolta di generazione 2.
- Le raccolte di generazione 2 sono intrinsecamente lente.
- Possono incorrere nel costo di attivare la raccolta su tutte le altre generazioni.
Il codice seguente compatta immediatamente il LOH:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
Per informazioni sulla compattazione del file LOH, vedere la LargeObjectHeapCompactionMode proprietà .
Nei contenitori che usano .NET Core 3.0 o versione successiva, il loH viene compattato automaticamente.
L'API seguente che illustra questo comportamento:
[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
return new byte[size].Length;
}
Il grafico seguente mostra il profilo di memoria della chiamata all'endpoint /api/loh/84975 , sotto carico massimo:
Il grafico seguente mostra il profilo di memoria della chiamata all'endpoint /api/loh/84976 , allocando solo un altro byte:
Note
La byte[] struttura ha byte di overhead, motivo per cui 84.976 byte attiva il limite di 85.000.
Confronto tra i due grafici precedenti:
- Working Set è simile per entrambi gli scenari, circa 450 MB
- Nelle richieste LOH (84.975 byte) vengono visualizzate principalmente raccolte di generazione 0
- Le richieste LOH generano raccolte di generazione 2 costanti, che sono costose. È necessario un numero maggiore di CPU e la velocità effettiva scende di circa 50%.
Gli oggetti temporanei di grandi dimensioni sono problematici perché causano raccolte di generazione 2.
Per ottenere prestazioni massime, ridurre al minimo l'uso di oggetti di grandi dimensioni. Se possibile, suddividere oggetti di grandi dimensioni. Ad esempio, Response Caching Middleware in ASP.NET Core suddivide le voci della cache in blocchi inferiori a 85.000 byte.
I collegamenti seguenti illustrano l'approccio ASP.NET Core per mantenere gli oggetti al di sotto del limite LOH:
Per altre informazioni, vedi:
HttpClient
L'uso non corretto della HttpClient classe può comportare una perdita di risorse.
Le risorse di sistema, ad esempio connessioni di database, socket, handle di file e così via, presentano due problemi:
- Sono più rare della memoria.
- Sono più problematici quando trapelano, rispetto alla memoria.
Esperti .NET sviluppatori sanno chiamare il metodo
HttpClient implementa IDisposable, ma non deve essere rimosso in ogni chiamata. Invece, HttpClient deve essere riutilizzato.
L'endpoint seguente crea ed elimina una nuova HttpClient istanza in ogni richiesta:
[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
using (var httpClient = new HttpClient())
{
var result = await httpClient.GetAsync(url);
return (int)result.StatusCode;
}
}
In fase di caricamento vengono registrati i messaggi di errore seguenti:
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031":
An unhandled exception was thrown by the application.
System.Net.Http.HttpRequestException: Only one usage of each socket address
(protocol/network address/port) is normally permitted --->
System.Net.Sockets.SocketException: Only one usage of each socket address
(protocol/network address/port) is normally permitted
at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port,
CancellationToken cancellationToken)
Anche se le istanze HttpClient vengono eliminate, il sistema operativo richiede tempo per rilasciare effettivamente la connessione di rete. Il processo di creazione continua di nuove connessioni comporta l'esaurimento delle porte. Ogni connessione client richiede la propria porta client.
Un modo per evitare l'esaurimento delle porte consiste nel riutilizzare la stessa HttpClient istanza:
private static readonly HttpClient _httpClient = new HttpClient();
[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
var result = await _httpClient.GetAsync(url);
return (int)result.StatusCode;
}
L'istanza HttpClient viene rilasciata quando l'app si arresta. Questo esempio mostra che non tutte le risorse eliminabili devono essere eliminate dopo ogni utilizzo.
Gli articoli seguenti descrivono un modo migliore per gestire la durata di un'istanza HttpClient :
Gestione di pool di oggetti
Nell'esempio precedente è stato illustrato come l'istanza HttpClient può essere resa statica e riutilizzata da tutte le richieste. Il riutilizzo impedisce l'esaurimento delle risorse.
Il pooling degli oggetti è un'alternativa:
- Usa il modello di riutilizzo.
- La progettazione è ideale per gli oggetti che sono costosi da creare.
Un pool è una raccolta di oggetti preinizializzati che possono essere riservati e rilasciati tra diversi thread. I pool possono definire regole di allocazione, ad esempio limiti, dimensioni predefinite o tasso di crescita.
Il pacchetto NuGet Microsoft.Extensions.ObjectPool contiene classi che consentono di gestire tali pool.
L'endpoint API seguente istanzia un byte buffer che viene riempito di numeri casuali a ogni richiesta.
[HttpGet("array/{size}")]
public byte[] GetArray(int size)
{
var random = new Random();
var array = new byte[size];
random.NextBytes(array);
return array;
}
Il grafico seguente mostra la chiamata all'API precedente con carico moderato:
Il grafico rivela che le raccolte di generazione 0 vengono eseguite circa una volta al secondo.
Il codice può essere ottimizzato eseguendo il pooling del byte buffer usando la classe ArrayPool<T> . Un'istanza statica viene riutilizzata tra le richieste.
Ciò che è diverso da questo approccio è che un oggetto in pool viene restituito dall'API:
- L'oggetto è fuori dal controllo non appena si torna dal metodo .
- Non è possibile rilasciare l'oggetto.
Per configurare l'eliminazione dell'oggetto:
- Incapsulare l'array condiviso in un oggetto eliminabile.
- Registrare l'oggetto in pool usando il metodo HttpContext.Response.RegisterForDispose .
RegisterForDispose si occupa di chiamare Dispose sull'oggetto di destinazione, quindi l'oggetto viene rilasciato solo dopo il completamento della richiesta HTTP.
private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();
private class PooledArray : IDisposable
{
public byte[] Array { get; private set; }
public PooledArray(int size)
{
Array = _arrayPool.Rent(size);
}
public void Dispose()
{
_arrayPool.Return(Array);
}
}
[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
var pooledArray = new PooledArray(size);
var random = new Random();
random.NextBytes(pooledArray.Array);
HttpContext.Response.RegisterForDispose(pooledArray);
return pooledArray.Array;
}
L'applicazione dello stesso carico della versione non con pool restituisce il grafico seguente:
La differenza principale riguarda i byte allocati e, di conseguenza, un minor numero di raccolte di generazione 0.