Gestione della memoria e Garbage Collection (GC) in ASP.NET Core

Di Sébastien Ros e Rick Anderson

La gestione della memoria è complessa, anche in un framework gestito come .NET. L'analisi e la comprensione dei problemi di memoria possono risultare difficili. Questo articolo:

  • È stato motivato da molte perdite di memoria e GC non funziona problemi. La maggior parte di questi problemi è stata causata dalla mancata comprensione del funzionamento dell'utilizzo della memoria in .NET Core o dalla mancata comprensione del modo in cui viene misurata.
  • Illustra l'uso problematico della memoria e suggerisce approcci alternativi.

Funzionamento di Garbage Collection (GC) in .NET Core

Il GC alloca i segmenti dell'heap in cui ogni segmento è un intervallo contiguo di memoria. Gli oggetti inseriti nell'heap vengono classificati in una delle tre generazioni: 0, 1 o 2. La generazione determina la frequenza con cui il GC tenta di rilasciare memoria sugli oggetti gestiti a cui non fa più riferimento l'app. Le generazioni con un numero inferiore sono più frequenti.

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 più elevata. Come accennato in precedenza, le generazioni più elevate sono meno frequenti. 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 vengono in genere migrati alla generazione 2.

All'avvio di un'app ASP.NET Core, GC:

  • Riserva memoria per i segmenti dell'heap iniziale.
  • Esegue il commit di una piccola parte di memoria quando il runtime viene caricato.

Le allocazioni di memoria precedenti vengono eseguite per motivi di prestazioni. Il vantaggio delle prestazioni deriva dai segmenti dell'heap in memoria contigua.

GC. Raccogliere avvertenze

In generale, ASP.NET app Core nell'ambiente di produzione non devono usare GC. Raccogliere in modo esplicito. L'induzione di Garbage Collection in momenti non ottimali può ridurre significativamente le prestazioni.

GC. La raccolta è utile durante l'analisi delle perdite di memoria. La chiamata GC.Collect() attiva un ciclo di Garbage Collection bloccante 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.

Analisi dell'utilizzo della memoria di un'app

Gli strumenti dedicati consentono di analizzare l'utilizzo della memoria:

  • Conteggio dei riferimenti agli oggetti
  • Misurazione dell'impatto dell'GC sull'utilizzo della CPU
  • Misurazione dello spazio di memoria usato per ogni generazione

Usare gli strumenti seguenti per analizzare l'utilizzo della memoria:

Rilevamento dei 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.
  • Include gli oggetti viventi dell'app e altri consumer di memoria, ad esempio l'utilizzo della memoria nativa.

Se il valore di memoria di Gestione attività aumenta a tempo indefinito e non si appiattisce mai, l'app presenta una perdita di memoria. Le sezioni seguenti illustrano e illustrano diversi modelli di utilizzo della memoria.

Esempio di app per 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.
  • Non è tuttavia uno strumento supportato, ma può essere usato per visualizzare i modelli di utilizzo della memoria delle app ASP.NET Core.

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.

Chart showing 0 Requests Per Second (RPS)

Il grafico visualizza due valori per l'utilizzo della memoria:

  • Allocata: quantità di memoria occupata da oggetti gestiti
  • Working set: set di pagine nello spazio degli indirizzi virtuali del processo attualmente residente in memoria fisica. Il working set visualizzato è lo stesso valore visualizzato da Gestione attività.

Oggetti temporanei

L'API seguente crea un'istanza stringa di 10 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 in per mostrare in che modo le allocazioni di memoria sono interessate dal processo GC.

Graph showing memory allocations for a relatively small load

Il grafico precedente mostra:

  • RPS 4K (richieste al secondo).
  • Le raccolte GC di generazione 0 vengono eseguite circa ogni due secondi.
  • Il working set è costante a circa 500 MB.
  • LA CPU è del 12%.
  • L'utilizzo e il rilascio della memoria (tramite GC) sono stabili.

Il grafico seguente viene acquisito alla velocità effettiva massima che può essere gestita dal computer.

Chart showing max throughput

Il grafico precedente mostra:

  • 22.000 RPS
  • Le raccolte GC di generazione 0 vengono eseguite più volte al secondo.
  • Le raccolte di generazione 1 vengono attivate perché l'app ha allocato una quantità significativamente maggiore di memoria al secondo.
  • Il working set è costante a circa 500 MB.
  • LA CPU è del 33%.
  • L'utilizzo e il rilascio della memoria (tramite GC) sono stabili.
  • La CPU (33%) non viene usata eccessivamente, pertanto la Garbage Collection può rimanere al passo con un numero elevato di allocazioni.

Workstation GC e Server GC

.NET Garbage Collector ha due modalità diverse:

  • Workstation GC: ottimizzato per il desktop.
  • Server GC. GC predefinito per ASP.NET app Core. Ottimizzato per il server.

La modalità GC può essere impostata in modo esplicito nel file di progetto o nel runtimeconfig.json file 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.

Nota: Il Garbage Collection del server non è disponibile nei computer con un singolo core. Per ulteriori informazioni, vedere IsServerGC.

L'immagine seguente mostra il profilo di memoria in un RPS di 5K usando workstation GC.

Chart showing memory profile for a Workstation GC

Le differenze tra questo grafico e la versione del server sono significative:

  • Il working set scende da 500 MB a 70 MB.
  • GC esegue raccolte di generazione 0 più volte al secondo anziché ogni due 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 sono in esecuzione in un computer, Workstation GC potrebbe essere più efficiente rispetto a Server GC. Per altre informazioni, vedere Running with Server GC in a Small Container and Running with Server GC in a Small Container Scenario Part 1 – Hard Limit for the GC Heap .For more information, see Running with Server GC GC and Running with Server GC GC in a Small Container Scenario Part 1 – Hard Limit for the GC Heap.

Riferimenti agli oggetti persistenti

Il 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 aumenterà nel tempo.

L'API seguente crea un'istanza stringa di 10 KB e la restituisce al client. La differenza con l'esempio precedente è che a questa istanza viene fatto riferimento da un membro statico, il che significa che 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:

  • Esempio di una tipica perdita di memoria.
  • Con chiamate frequenti, causa l'aumento della memoria dell'app fino a quando il processo non si arresta in modo anomalo con un'eccezione OutOfMemory .

Chart showing a memory leak

Nell'immagine precedente:

  • Il test di carico dell'endpoint /api/staticstring causa un aumento lineare della memoria.
  • Il processo GC tenta di liberare memoria man mano che aumenta la pressione della memoria, chiamando una raccolta di seconda generazione.
  • Il GC non può liberare la memoria persa. L'allocazione e il working set 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 in caso di pressioni di memoria. L'implementazione predefinita di IMemoryCache usa WeakReference.

Memoria nativa

Alcuni oggetti .NET Core si basano sulla memoria nativa. La memoria nativa non può essere raccolta 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 Dispose non viene chiamato, le classi implementate correttamente chiamano Dispose quando viene eseguito il finalizzatore .

Osservare il codice seguente:

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

PhysicalFileProvider è una classe gestita, quindi qualsiasi istanza verrà raccolta alla fine della richiesta.

L'immagine seguente mostra il profilo di memoria durante la chiamata continua dell'API fileprovider .

Chart showing a native memory leak

Il grafico precedente mostra un problema ovvio con l'implementazione di questa classe, in quanto continua ad aumentare l'utilizzo della memoria. Si tratta di un problema noto rilevato in questo problema.

La stessa perdita può verificarsi nel codice utente, di uno dei seguenti:

  • Non rilasciando correttamente la classe.
  • Dimenticando di richiamare il Dispose metodo degli oggetti dipendenti da eliminare.

Heap oggetti di grandi dimensioni

L'allocazione o i cicli liberi di memoria frequenti possono frammentare la memoria, 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 sulla LOH.
  • Non compattato.
  • Raccolti durante la generazione 2 GC.

Quando loH è pieno, il catalogo globale attiverà una raccolta di seconda generazione. Raccolte di seconda generazione:

  • Sono intrinsecamente lenti.
  • Inoltre, comporta il costo dell'attivazione di una raccolta in tutte le altre generazioni.

Il codice seguente compatta immediatamente lo loH:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

Vedere LargeObjectHeapCompactionMode per informazioni sulla compattazione del loH.

Nei contenitori che usano .NET Core 3.0 e versioni successive, 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:

Chart showing memory profile of allocating bytes

Il grafico seguente mostra il profilo di memoria della chiamata all'endpoint /api/loh/84976 , allocando solo un altro byte:

Chart showing memory profile of allocating one more byte

Nota: la byte[] struttura presenta byte di overhead. Ecco perché 84.976 byte attiva il limite di 85.000 byte.

Confronto tra i due grafici precedenti:

  • Il 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 over generano raccolte di generazione 2 costanti. Le raccolte di seconda generazione sono costose. È necessaria una quantità maggiore di CPU e la velocità effettiva scende quasi al 50%.

Gli oggetti temporanei di grandi dimensioni sono particolarmente problematici perché causano GC gen2.

Per ottenere prestazioni massime, è consigliabile ridurre al minimo l'uso di oggetti di grandi dimensioni. Se possibile, suddividere oggetti di grandi dimensioni. Ad esempio, il middleware di memorizzazione nella cache delle risposte 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, vedere:

HttpClient

L'uso HttpClient non corretto può comportare una perdita di risorse. Risorse di sistema, ad esempio connessioni al database, socket, handle di file e così via:

  • Sono più scarse della memoria.
  • Sono più problematici quando si perde memoria.

Gli sviluppatori .NET esperti sanno chiamare Dispose sugli oggetti che implementano IDisposable. Non eliminare oggetti che implementano IDisposable in genere comportano perdite di memoria o risorse di sistema perse.

HttpClientimplementa IDisposable, ma non deve essere eliminato 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 HttpClient istanze vengono eliminate, la connessione di rete effettiva richiede tempo per essere rilasciata dal sistema operativo. Creando continuamente nuove connessioni, si verifica 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.

Per un modo migliore per gestire la durata di un'istanza HttpClient , vedere quanto segue:

Pooling 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.

Pool di oggetti:

  • Usa il modello di riutilizzo.
  • È progettato per gli oggetti che sono costosi da creare.

Un pool è una raccolta di oggetti pre-inizializzati che possono essere riservati e rilasciati tra 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 crea un'istanza di un byte buffer pieno di numeri casuali in 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:

Chart showing calls to API with moderate load

Nel grafico precedente, le raccolte di generazione 0 vengono eseguite circa una volta al secondo.

Il codice precedente può essere ottimizzato eseguendo il pooling del byte buffer tramite 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. Ciò significa che:

  • L'oggetto è fuori dal controllo non appena si torna dal metodo .
  • Non è possibile rilasciare l'oggetto.

Per configurare l'eliminazione dell'oggetto:

RegisterForDispose si occuperà di chiamare Dispose sull'oggetto di destinazione in modo che venga rilasciato solo al termine 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 in pool restituisce il grafico seguente:

Chart showing fewer allocations

La differenza principale riguarda i byte allocati e, di conseguenza, un numero molto inferiore di raccolte di generazione 0.

Risorse aggiuntive