Distinguere l'attesa del GC da una perdita di memoria in .NET — Procedura pratica per osservare, confrontare e dimostrare la crescita della memoria

· Aggiornato il: · · .NET, C#, GC, Memory leak, Diagnostica, dotnet-counters, dotnet-dump, Operazioni, Riuso degli asset esistenti

1. Prima di tutto, i punti da fissare

Quando si gestisce un’applicazione .NET, capita di vedere l’uso di memoria salire a poco a poco.

Task Manager o top mostrano che la memoria del processo cresce. Cresce anche l’uso di memoria del container. Nel monitoraggio, il grafico di Working Set o RSS sale verso destra.

Davanti a questo stato viene naturale pensare subito «sarà una memory leak?». In .NET, però, che la memoria del processo cresca e che ci sia una memory leak non sono la stessa cosa.

.NET ha la garbage collection. Nel momento in cui un oggetto diventa inutile, la memoria non torna subito al sistema operativo. Il GC agisce guardando lo stato delle allocazioni, le soglie dell’heap, la pressione di memoria, le generazioni e le condizioni del workload.

Per questo si verificano stati come questi.

  • Oggetti ormai inutili che non sono ancora stati raccolti dal GC
  • Il GC è già passato, ma il Working Set del processo non scende subito
  • Un aumento una tantum per primo accesso, JIT, cache o connection pool, e poi la situazione si stabilizza
  • L’heap managed è stabile, ma crescono memoria native, thread, socket o una libreria di elaborazione immagini
  • Oggetti che davvero avrebbero dovuto diventare inutili restano referenziati da qualche parte

Questo articolo tratta come distinguere l’ultimo caso: «sta davvero leakando». Ciò che va guardato non è il mero uso di memoria, ma questi tre punti.

  1. La memoria che sopravvive anche dopo il GC sta crescendo?
  2. Quali tipi stanno crescendo?
  3. Chi sta referenziando quegli oggetti?

Indagare una memory leak in .NET non finisce con «la memoria sta crescendo»: è il lavoro di arrivare fino a «stanno crescendo oggetti di questo tipo, e restano referenziati da questa root».

Il codice che compare in questo articolo è pubblicato su GitHub come insieme completo di sample compilabili ed eseguibili (una libreria dei pattern di leak tipici, una demo che osserva la differenza tra attesa del GC e sopravvivenza, e unit test che verificano ritenzione e raccolta con WeakReference).

dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)

Mappa della conoscenza di questo articolo

L’aumento di memoria di un’app .NET si divide tra il caso in cui la garbage collection non ha ancora raccolto gli oggetti e il caso in cui i riferimenti restano davvero, a causa di collezioni static, cache senza limite, mancata disiscrizione dagli eventi, mancato Dispose del Timer, mancato rilascio di IDisposable, mismatch di lifetime DI e simili. Per distinguerli, è importante prima osservare con dotnet-counters le tendenze del GC Heap, di Gen 2 e del LOH, e non giudicare solo dall’aumento di Total Allocated, che è un valore cumulativo, o del Working Set dal punto di vista del sistema operativo. Poi, con dotnet-gcdump si confrontano Count e Size per tipo prima e dopo il carico e si identificano i tipi in aumento; con i comandi dumpheap e gcroot di dotnet-dump si arriva fino a chi continua a referenziare quell’oggetto. Anche se con un GC forzato la memoria scende, la causa di fondo non sparisce, e non è un rimedio da mettere in produzione.

Mappa della conoscenza di come distinguere l'attesa del GC da una memory leak in .NETDiagramma che mostra le relazioni con cui, in .NET, si distingue se l'aumento di memoria è attesa del GC o una memory leak vera, usando dotnet-counters, dotnet-gcdump, dotnet-dump e gcrootverificato daverificato daverificato daverificato daverificato daverificato daverificato dasconsigliato persconsigliato persconsigliato perpuò causarepuò causarepuò causarepuò causarepuò causarepuò causarepuò causaredeve precederedeve precedereincompatibile conincompatibile conusarichiedeGarbage collection di .NET (GC)Memory leak di .NET (ritenzione non intenzionale)dotnet-countersdotnet-gcdumpdotnet-dumpComando gcrootHeap GC (heap gestito)Comando dumpheap -statdotnet-traceGen 2 (heap di seconda generazione)Total Allocated (allocazione cumulativa)Working Set / RSSGC forzato (Induced Collection)Ritenzione tramite collezione staticCache senza limite né scadenzaMancata disiscrizione dagli eventiMancato Dispose del TimerMancato rilascio di IDisposableScambio del lifetime DILOH (Large Object Heap).NET Framework.NET (da Core in poi)

Nel diagramma, una linea continua indica una relazione che vale sempre e una linea tratteggiata indica una relazione condizionale (le condizioni sono nella spiegazione di ciascuna relazione nella pagina di dettaglio). L’elenco completo delle relazioni (in totale 23, con evidenza e livello di certezza) e le definizioni dei concetti principali sono raccolti nella pagina di dettaglio della mappa della conoscenza (in giapponese). Dati: JSON-LD / Turtle

2. Prima allineare il significato di «memory leak»

Una memory leak in .NET non ha solo la forma di C o C++ «ho allocato e mi sono dimenticato di fare free».

Nel codice managed, il GC raccoglie gli oggetti. Se il GC può raccogliere un oggetto dipende da «se esiste ancora un riferimento raggiungibile a quell’oggetto».

In altre parole, la memory leak tipica in .NET è questa.

Oggetti che dal punto di vista del business non servono più, ma che restano referenziati da un campo static, una cache, un evento, un Timer, una collezione, la lifetime DI, un contesto asincrono e così via, e quindi dal punto di vista del GC appaiono ancora in uso.

Il GC è intelligente, ma non sa se qualcosa è ancora necessario per il business. Se è referenziato, lo giudica vivo.

Per questo, in .NET è più chiaro pensarla come «ritenzione non intenzionale» piuttosto che come «leak».

Dall’altra parte, i seguenti stati non si possono subito chiamare memory leak.

Stato Perché non è necessariamente una leak
Working Set / RSS in crescita È memoria che il sistema operativo ha assegnato al processo; non coincide con la quantità di oggetti vivi sull’heap managed
Total Allocated in crescita È la quantità cumulativa allocata dall’avvio, quindi cresce di base se l’app sta facendo qualcosa
GC Heap che sale di colpo Possono essere semplicemente oggetti non ancora raccolti, in attesa del prossimo GC
Crescita subito dopo l’avvio Succede spesso con JIT, caricamento dei tipi, cache iniziali, connection pool, espansione di template
LOH grande Può dipendere dal riuso di array e buffer grandi, dalla frammentazione o dalla strategia di pooling
La memoria non scende Anche se il GC ha raccolto, il processo non necessariamente restituisce subito memoria al sistema operativo

Al contrario, più i seguenti stati si presentano insieme, più si rafforza il sospetto di memory leak.

Osservazione Significato
A ogni ripetizione della stessa operazione, l’heap dopo il GC cresce Stanno aumentando gli oggetti che sopravvivono
La dimensione di Gen 2 o del LOH continua a crescere Restano oggetti longevi, oppure oggetti grandi
In più dump, Count / Size dello stesso tipo crescono Si può identificare il tipo in aumento
gcroot mostra riferimenti da static, eventi, cache o servizi longevi Si può spiegare perché il GC non può raccogliere
Anche dopo aver fermato il carico, dopo tempo sufficiente o dopo un GC di verifica, non torna indietro È probabile che non sia una semplice allocazione temporanea

3. Distinguere «quale memoria» si sta guardando

La prima fonte di confusione in un’indagine sulla memoria è che si mescolano indicatori diversi. Sono tutti «memoria», ma significano cose diverse.

Indicatore Cosa misura Come leggerlo
Working Set / RSS Pagine del processo residenti in memoria fisica Vista del sistema operativo; non è l’heap del GC in sé
Private Bytes / Commit Memoria committed di proprietà del processo Include anche memoria native, stack, codice JIT, segmenti GC
GC Heap Size Quantità di oggetti sull’heap managed Punto di ingresso per guardare la memoria oggetto del GC in .NET
Total Allocated Quantità cumulativa allocata dall’avvio Cresce di base. Non usarlo da solo per giudicare una leak
Gen 0 / Gen 1 / Gen 2 Heap per generazione Ciò che resta in Gen 2 è longevo
LOH Heap in cui finiscono gli oggetti grandi da 85.000 byte in su Tende a crescere con array, stringhe, buffer grandi
POH Heap per oggetti pin Un indizio degli effetti di interop native e pinning
Finalization Queue Oggetti in attesa di finalizzazione Un indizio di Dispose mancati o di un finalizer intasato

Se si mette in un diagramma quale indicatore guarda dove, si ottiene questo.

Relazione tra le metriche di memoria di un processo .NETIl Working Set e il Commit non sono nidificati: il GC Heap copre solo il lato managed, e nel Working Set entra solo la porzione residente in memoria fisicavengono conteggiativengono conteggiatisolo la porzione residente in RAM fisicaMemoria usata dal processoLato managedAmbito visibile in GC Heap SizeLato nativeAmbito che non compare in GC Heap SizeGen 0 / Gen 1Allocazioni di breve vitaGen 2Oggetti longevi sopravvissutiLOHOggetti grandi di 85.000 byte o piùPOHOggetti pinnedStack dei threadCodice JIT, assembly già caricatiBuffer di P/Invoke, COM, librerie esternePrivate Bytes / CommitMemoria committed di proprietà del processoWorking Set / RSS

Da questo diagramma voglio che restino due punti.

  1. Ciò che dumpheap rende visibile è solo il lato managed. Se sta crescendo il lato native, per quanto si guardi l’heap il colpevole non esce.
  2. Working Set e Commit non sono in un rapporto di nidificazione. Anche se è committed, se non è residente in memoria fisica non compare nel Working Set; all’inverso, nel Working Set possono essere contate cose che non sono private, come pagine di librerie condivise. È per questo che «il Working Set non scende, quindi il GC non ha raccolto» non si può dire.

Non serve esaminare tutto in dettaglio fin dall’inizio. Per prima cosa, scomponete la domanda così.

La memoria del processo sta crescendo
  ↓
Sta crescendo anche l'heap managed?
  ↓
Sta crescendo anche la quantità che sopravvive al GC?
  ↓
Quali tipi stanno crescendo?
  ↓
Chi li sta referenziando?

Rispettare quest’ordine rende più difficile confondere «crescita di memoria che sembra brutta» e «leak vera».

4. Flusso di giudizio

In pratica, il triage avanza meglio con questo flusso.

1. Fissare le condizioni di riproduzione
   - Quale API, schermata, job o batch fa crescere
   - Quante esecuzioni servono perché cresca
   - Che succede se si ferma il carico

2. Guardare le tendenze con dotnet-counters
   - Working Set
   - GC Heap
   - Gen 2 / LOH
   - Total Allocated
   - Numero di GC

3. Confrontare nel tempo
   - Subito dopo l'avvio
   - Dopo il warm-up
   - Sotto carico
   - Dopo lo stop del carico
   - Dopo aver ripetuto la stessa operazione N volte

4. Prendere due o più dump
   - before
   - after
   - se possibile, anche dopo lo stop del carico

5. Cercare i tipi in crescita
   - dumpheap -stat
   - gcdump report
   - Visual Studio / PerfView

6. Verificare chi referenzia
   - gcroot
   - gchandles
   - finalizequeue

7. Giudicare
   - Attesa del GC
   - Crescita normale di cache
   - Memory leak managed
   - Problema di memoria native
   - Frammentazione del LOH o allocazioni temporanee di grande volume

L’importante è non giudicare da un singolo numero. Una memory leak è «una tendenza a continuare a crescere», quindi si confronta nel tempo a parità di condizioni, non su un punto solo.

5. Strumenti da usare

In questo articolo usiamo principalmente questi strumenti.

Strumento Quando usarlo
dotnet-counters Guardare le tendenze di GC e Working Set di un processo in esecuzione
dotnet-gcdump Prendere in modo leggero le statistiche degli oggetti managed vivi
dotnet-dump Guardare l’heap in dettaglio e, con dumpheap e gcroot, arrivare fino a chi referenzia
Visual Studio Memory Usage Quando su Windows si vuole un confronto in GUI
PerfView Quando su Windows si vogliono approfondire GC / heap / trace
dotnet-trace Quando si vogliono seguire allocazioni ed eventi GC nel tempo

Per prima cosa si installano gli strumenti CLI.

dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace

Se sono già installati, si aggiornano.

dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace

Si cerca il processo da indagare.

dotnet-counters ps

Negli esempi successivi, l’ID del processo target è scritto <PID>.

Su Linux, macOS e in ambienti container, gli strumenti di diagnostica e il processo target devono girare con lo stesso utente. Inoltre, a seconda dell’ambiente, influiscono TMPDIR, la porta di diagnostica e il namespace PID del container.

Se si esegue in produzione, non si prende un dump di colpo: prima, in un ambiente di verifica, si confermano carico e impatto.

5.1 Se il target dell’indagine è .NET Framework 4.x

dotnet-counters, dotnet-dump e dotnet-gcdump sono strumenti che usano le funzionalità di diagnostica del runtime da .NET Core 3.0 in poi. Se il target è un’applicazione .NET Framework 4.x, non si possono usare. È il caso in cui l’oggetto della manutenzione è un’app esistente Windows Forms, WPF o ASP.NET.

La sostituzione si ragiona con questa corrispondenza.

Strumento di questo articolo Alternativa su .NET Framework 4.x
Guardare le tendenze con dotnet-counters Performance Monitor, oppure Get-Counter sui counter della categoria .NET CLR Memory
Confrontare le statistiche per tipo con dotnet-gcdump Dump dell’heap GC di PerfView, oppure confronto di snapshot con «Memory Usage» di Visual Studio
Prendere un dump con dotnet-dump collect ProcDump, «Create dump file» di Task Manager, oppure far emettere un dump dalle impostazioni di Windows Error Reporting
dumpheap / gcroot con dotnet-dump analyze In WinDbg eseguire .loadby sos clr e poi usare !dumpheap -stat e !gcroot
Seguire le allocazioni con dotnet-trace Raccolta delle allocazioni dell’heap GC di PerfView

Il ragionamento è esattamente lo stesso: tre livelli, «guardare le tendenze», «confrontare due volte le statistiche per tipo», «seguire chi referenzia». Cambiano solo gli strumenti.

Se si guarda con Performance Monitor, i counter della categoria .NET CLR Memory che si vogliono vedere per primi sono più o meno questi.

Counter Cosa misura
# Bytes in all Heaps Totale di Gen 1, Gen 2 e LOH. Vicino al GC Heap Size di questo articolo
Gen 2 heap size Byte attuali di Gen 2. Se continua a crescere, sospettare una leak
Large Object Heap size Dimensione attuale del LOH
# Gen 2 Collections Numero di full GC. Se cresce di colpo, sospettare troppe allocazioni
% Time in GC Percentuale di tempo spesa nel GC nel ciclo GC più recente
Finalization Survivors Numero di oggetti sopravvissuti in attesa di finalizzazione. Indizio di Dispose mancati
# Total committed Bytes Quantità di memoria virtuale committed dal GC

I nomi dei counter, in un ambiente in italiano, possono comparire in italiano. Se non li trovate, non cercate solo il nome inglese: cercate anche il nome di categoria localizzato, ad esempio .NET CLR memoria.

Il punto di ingresso per l’analisi in WinDbg è il caricamento di SOS.

0:000> .loadby sos clr
0:000> !dumpheap -stat
0:000> !gcroot <OBJECT_ADDRESS>

In .NET Framework i comandi SOS si prefissano con !. I dumpheap -stat e gcroot che compariranno dal capitolo 9 in poi si possono usare con la stessa procedura, rileggendoli come !dumpheap -stat e !gcroot.

6. Prima guardare le tendenze con dotnet-counters

La prima cosa da guardare non è un dump dettagliato, ma la tendenza.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime

L’output varia un po’ a seconda della versione di .NET. Da .NET 9 in poi può comparire con il nome Meter di System.Runtime; fino a .NET 8 può comparire con i nomi EventCounter tradizionali.

Si guardano principalmente queste voci.

Voce da guardare Cosa si guarda
dotnet.process.memory.working_set Memoria residente del processo, vista del sistema operativo
dotnet.gc.last_collection.heap.size Dimensione dell’heap per generazione dopo il GC più recente
dotnet.gc.last_collection.memory.committed_size Quantità di memoria committed dal GC
dotnet.gc.heap.total_allocated Quantità cumulativa allocata dall’avvio
dotnet.gc.collections Numero di GC per generazione
dotnet.gc.pause.time Tempo di pausa GC cumulativo

Per prima cosa si può anche restringere il target e monitorare solo quello.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]

Se vorrete rivederlo dopo, salvatelo in CSV.

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output counters.csv \
  --counters System.Runtime

A questo punto, ciò che si vuole vedere è la differenza seguente.

6.1 Cresce solo Total Allocated

dotnet.gc.heap.total_allocated è un valore cumulativo. Se l’applicazione elabora richieste, alloca oggetti; e anche se gli oggetti allocati diventano subito inutili e il GC li raccoglie, la quantità cumulativa allocata continua a crescere.

Per questo, che Total Allocated cresca da solo non si può chiamare memory leak. Ciò che va guardato è se, dopo l’allocazione, resta.

Total Allocated: cresce
GC Heap Size:    oscilla un po' e si stabilizza
Gen 2 / LOH:     non continuano a crescere

In questo caso, più che una leak, è un’applicazione con molte allocazioni.

La misura non è una correzione di leak, ma riduzione delle allocazioni, riuso di buffer, revisione dell’uso eccessivo di LINQ, riduzione della generazione di stringhe, revisione della serializzazione e così via.

6.2 Il Working Set cresce ma il GC Heap è stabile

Capita che Working Set o RSS crescano mentre il GC Heap resta stabile. In questo caso non è necessariamente una leak di oggetti managed.

I fattori possibili sono più o meno questi.

  • Codice JIT
  • Assembly caricati
  • Stack dei thread
  • Memoria di librerie native
  • Memoria unmanaged come Marshal.AllocHGlobal
  • Buffer lato native di immagini, compressione, crittografia, driver DB e simili
  • Buffer interni di socket, handle di file, SSL, HTTP/2, gRPC e simili
  • Il sistema operativo che semplicemente non sta recuperando subito le pagine fisiche dal processo

In questo stato, per quanto si guardi dumpheap, il colpevole principale può non uscire.

Il criterio di giudizio è questo.

Working Set / RSS: cresce
GC Heap Size:      stabile
Gen 2 / LOH:       stabile

In questo caso non è una managed heap leak di .NET: si sospettano memoria native, handle, numero di thread, socket, librerie esterne.

Non ci si ferma a dotnet-counters: si guardano anche gli strumenti del sistema operativo, le metriche del container, il numero di handle, il numero di thread, l’heap native, le metriche delle librerie esterne.

6.3 Il GC Heap cresce, ma torna dopo lo stop del carico

Che il GC Heap cresca sotto carico è naturale.

Ci sono molte richieste. Ci sono molti oggetti temporanei. Si maneggia JSON grande. Si creano temporaneamente liste e array.

In questi casi l’heap cresce fino al prossimo GC. Se si ferma il carico, il GC gira e l’heap può tornare indietro.

Sotto carico:          il GC Heap cresce
Dopo lo stop del carico: il GC Heap scende, o torna a un valore costante
Dopo le ripetizioni:   la baseline non continua a salire

In questo caso si può giudicare «semplicemente non ancora raccolto dal GC» oppure «molte allocazioni temporanee».

Tuttavia, se le allocazioni temporanee sotto carico sono troppe, aumentano il numero di GC e i tempi di pausa, e diventano un problema di prestazioni. Anche se non è una leak, resta un oggetto di miglioramento delle prestazioni.

6.4 Gen 2 / LOH dopo il GC continuano a crescere

Il pattern da tenere d’occhio è questo.

Si ripete la stessa operazione
  ↓
Gen 2 cresce
  ↓
LOH cresce
  ↓
Anche fermando il carico non torna
  ↓
Alla misura successiva cresce ancora

Gen 2 è la generazione in cui finiscono gli oggetti longevi. Il LOH è l’heap in cui finiscono facilmente array grandi, stringhe e simili.

Se qui continua a crescere, si sospettano leak, cache senza limiti, ritenzione di buffer enormi, mancata disiscrizione da eventi, collezioni static, ritenzione da parte di servizi longevi.

A questo punto si passa al passo successivo.

7. Come ragionare se è solo «attesa del GC»

Per vedere se «semplicemente non è ancora stato raccolto dal GC», si guarda lo stato dopo che ci sono state opportunità sufficienti di GC.

Tuttavia, non si deve inserire alla leggera GC.Collect() nel codice di produzione.

GC.Collect() forza il GC. In particolare un GC bloccante su tutte le generazioni crea tempi di pausa dell’applicazione. Nel funzionamento ordinario, la regola di base è lasciare fare al GC.

Ciononostante, in indagine, in un ambiente di verifica controllato, a volte si guarda «resta anche dopo un GC forzato?».

In un’app console di verifica o in un ambiente di riproduzione, si può confermare lo stato dopo un full GC con codice come questo.

static void ForceFullGcForDiagnosticsOnly()
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
}

Il punto è non usarlo come soluzione. È solo per l’indagine.

Ciò che si vuole confermare è questo flusso.

Prima dell'operazione
  ↓
Ripetere l'operazione N volte
  ↓
Fermare il carico
  ↓
Aspettare a sufficienza, oppure in ambiente di verifica indurre un full GC
  ↓
L'heap dopo il GC torna a un valore vicino a quello precedente l'operazione?

Se torna, è alta la probabilità di attesa del GC o di allocazioni temporanee. Se non torna, e a ogni ripetizione della stessa operazione la baseline sale, qualcosa sta sopravvivendo. Quel «qualcosa» lo si cerca nei dump.

8. Confronti leggeri con dotnet-gcdump

Per il primo confronto è comodo dotnet-gcdump.

dotnet-gcdump si usa per prendere un GC dump da un processo .NET in esecuzione e vedere le statistiche per tipo sull’heap.

dotnet-gcdump collect --process-id <PID> --output before.gcdump

Dopo aver applicato il carico, si prende di nuovo.

dotnet-gcdump collect --process-id <PID> --output after.gcdump

Si può anche vedere un report semplice da CLI.

dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump  > after-heap.txt

Si guardano Count e Size per tipo.

Ad esempio, se in after tipi come questi sono cresciuti di molto, diventano oggetto di indagine.

Size (Bytes)   Count       Type
============   =====       ====
180,000,000    2,000,000   System.String
120,000,000    1,000,000   MyApp.Models.Customer
 90,000,000       25,000   System.Byte[]

L’importante non è «il tipo grande», ma «il tipo cresciuto».

System.String e System.Byte[] escono in alto in molte app. Essere in alto da soli non significa essere il colpevole.

I criteri di confronto sono questi.

before → after Come leggerlo
Count quasi uguale È alta la probabilità che quel tipo non sia il colpevole principale
Crescono sia Count sia Size Diventa un candidato
Crescono tipi MyApp.* È facile sospettare ritenzione nella logica di business
Cresce System.Byte[] Sospettare buffer, serializzazione, immagini, compressione, HTTP, DB
Cresce System.String Sospettare cache, log, JSON, chiavi di dizionario, stringhe duplicate
Crescono Task, Timer, CancellationTokenSource Sospettare elaborazione asincrona, Timer, mancata cancellazione

dotnet-gcdump è comodo come ingresso al confronto; d’altra parte, al momento della cattura induce un Gen 2 GC. In ambienti con heap grande o latenza stretta, fate attenzione ai tempi di pausa e al consumo aggiuntivo di memoria.

Su Windows, un .gcdump si può aprire e confrontare in Visual Studio o PerfView. In ambienti non Windows, in pratica si guardano le statistiche per tipo con report da CLI e, per approfondire chi referenzia, si passa a dotnet-dump.

9. Guardare heap e origini dei riferimenti con dotnet-dump

Quando si è visto «quale tipo sta crescendo», il passo successivo è «perché non viene raccolto».

Per questo si prende un dump con dotnet-dump e si analizza con i comandi SOS.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-1.dmp

Si aspetta e si prende di nuovo.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-2.dmp

Prendere un dump è un’operazione pesante. In particolare i dump Full / Heap sono grandi e caricano processo e container. Se si prende in produzione, si fa attenzione a fascia oraria, spazio disco, limiti di memoria del container, e alla possibile presenza di dati personali o riservati.

Si analizza il dump ottenuto.

dotnet-dump analyze myapp-2.dmp

Per prima cosa si guardano le statistiche dell’heap intero.

> dumpheap -stat

L’output è conteggio e dimensione per tipo.

MT               Count       TotalSize Class Name
00007f...        120000      3840000   MyApp.Models.Order
00007f...        250000      8000000   System.String
00007f...         10000     40000000   System.Byte[]

Si restringe a un tipo specifico.

> dumpheap -stat -type MyApp.Models.Order

Oppure si restringe a un MethodTable specifico.

> dumpheap -mt <MT>

Quando si ha l’indirizzo di un’istanza, si indaga chi referenzia.

> gcroot <OBJECT_ADDRESS>

Questo è il punto più importante. Con gcroot si conferma perché quell’oggetto è vivo.

Ad esempio, supponiamo che si veda un percorso di riferimento come questo.

static MyApp.CustomerCache._items
  -> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
  -> MyApp.Models.Customer
  -> System.String

In questo caso, il motivo per cui il GC non raccoglie è chiaro. Customer è referenziato da una cache static, e dal punto di vista del GC è ancora in uso.

Solo qui si possono fare i giudizi seguenti.

  • Quella cache è davvero necessaria?
  • C’è un tetto?
  • C’è una scadenza?
  • Il design delle chiavi è tale da farle crescere senza fine?
  • Non sta crescendo all’infinito usando come chiave tenant, utente, data, request ID e simili?

In un’indagine di memory leak l’importante è non fermarsi a dumpheap -stat. dumpheap -stat dice «di che cosa ce n’è tanto»; gcroot dice «perché resta». È il secondo che porta alla correzione.

10. Tabella rapida per distinguere

Organizziamo i pattern che si vedono spesso in pratica.

Osservazione Possibilità Cosa guardare dopo
Cresce solo Total Allocated Allocazione ordinaria, oppure troppe allocazioni Allocation Rate, numero di GC, CPU, dotnet-trace
Working Set cresce ma GC Heap è stabile Memoria native, JIT, stack, ritenzione lato sistema operativo Numero di thread, numero di handle, strumenti native, librerie esterne
Il GC Heap cresce solo sotto carico e torna dopo lo stop Attesa del GC, allocazioni temporanee Gen 2 / LOH dopo lo stop del carico, numero di GC
Gen 2 dopo il GC continua a crescere Ritenzione di oggetti longevi dumpheap -stat, gcroot
Il LOH continua a crescere Array grandi, buffer, frammentazione, stringhe enormi System.Byte[], System.Char[], LOH, aree Free
System.String è grande Cache di stringhe, JSON, log, chiavi di dizionario Cercare i tipi propri che tengono le stringhe
System.Byte[] è grande Buffer, serializzazione, immagini, compressione, comunicazione Tipo proprietario, mancata restituzione ad ArrayPool, interop native
Crescono i Task Elaborazione asincrona che non completa, code di attesa Attese async, cancellazione, channel, code
Crescono i Timer Mancato Dispose di Timer Dispose, deregistrazione, servizi longevi
Crescono i CancellationTokenSource Mancato Dispose di CTS, troppi token collegati Dispose, scollegamento, punti in cui si genera un timeout
Restano EventHandler o delegate Mancata disiscrizione da eventi Differenza di vita tra publisher e subscriber
Cresce la Finalization Queue Dispose mancati, finalizer intasato finalizequeue, thread del finalizer
Molti pinned handle Buffer pin, interop native gchandles, POH, punti di pinning

11. Forme comuni di leak

Elenco sette pattern, ma non è necessario leggerli in ordine dalla testa. Entrate dalla riga più vicina al sintomo che state vedendo.

Sezione Pattern Sintomo tipico Indicatore da guardare per primo
11.1 Collezioni static Cresce in proporzione al numero di operazioni e, anche fermando il carico, non torna Gen 2. Se gcroot mostra un static field
11.2 Cache senza limiti Cresce in proporzione al tempo di attività. Al riavvio torna Gen 2. Numero di elementi in cache e crescita di System.String
11.3 Mancata disiscrizione da eventi Cresce ogni volta che si apre e si chiude una schermata o uno scope Count del ViewModel o del tipo handler corrispondente. gcroot via delegate
11.4 Mancato Dispose di Timer Oggetti che si pensavano di breve vita non spariscono, e i callback continuano a girare Count di System.Threading.Timer o TimerQueueTimer
11.5 Mancato rilascio di IDisposable Il GC Heap è stabile, ma crescono il numero di handle e la memoria del processo Numero di handle, Finalization Queue, Working Set
11.6 AsyncLocal e ritenzione di contesto Anche a elaborazione della richiesta finita, restano i DTO gcroot via async state machine
11.7 Mismatch di lifetime DI Cresce in proporzione al numero di richieste gcroot dal tipo singleton

La colonna «indicatore da guardare per primo» va usata in coppia con la tabella rapida del capitolo 10. Il capitolo 10 è «la tabella che dai risultati di osservazione restringe le possibilità»; questa tabella è «la tabella che dal pattern torna all’indicatore da confermare».

11.1 Collezioni static

È la forma più chiara.

public static class CustomerStore
{
    private static readonly List<Customer> Customers = new();

    public static void Add(Customer customer)
    {
        Customers.Add(customer);
    }
}

In questo codice, un Customer aggiunto a Customers resta finché il processo è vivo. Anche se si pensava a un salvataggio temporaneo, finché è referenziato da static il GC non lo raccoglie.

La direzione della correzione cambia a seconda dell’uso.

  • Mettere un tetto
  • Mettere una scadenza
  • Usare un meccanismo di cache come MemoryCache
  • Eliminare in modo esplicito
  • Abbandonare static e spostare in un servizio con lifetime adeguata
  • Se lo scopo è la persistenza, spostare su DB o storage esterno

L’importante non è «static è cattivo», ma usare static capendo la proprietà che ciò che ci si mette diventa longevo.

11.2 Cache senza limiti

La cache usa memoria di proposito, quindi una crescita secondo il design non è una leak. Tuttavia, una cache senza tetto né scadenza diventa una memory leak di fatto.

public sealed class ReportCache
{
    private readonly Dictionary<string, Report> _cache = new();

    public Report GetOrCreate(string userId, DateTime date)
    {
        var key = $"{userId}:{date:O}";

        if (_cache.TryGetValue(key, out var report))
        {
            return report;
        }

        report = BuildReport(userId, date);
        _cache[key] = report;
        return report;
    }
}

In questo esempio, se le combinazioni di userId e date continuano a crescere, continua a crescere anche la cache.

Particolarmente pericoloso è il caso in cui la chiave contiene valori come questi.

  • Request ID
  • Ora corrente
  • GUID
  • Session ID
  • Stringhe derivate da input utente senza normalizzazione
  • SQL o condizioni di ricerca stringate così come sono

Per la cache si fissano in anticipo queste condizioni.

Condizione Esempio
Numero massimo di elementi Fino a 10.000
Dimensione massima Fino a 256 MB
Scadenza 30 minuti dall’ultimo accesso
Scadenza assoluta 6 ore dalla creazione
Condizione di scarto Eliminazione tenant, eliminazione utente, cambio di impostazioni
Voci di monitoraggio Conteggio, dimensione stimata, hit rate, numero di eviction

Non «è cache, quindi può crescere», ma «fino a dove può crescere» va deciso.

11.3 Mancata disiscrizione da eventi

Un evento diventa una leak se un publisher di vita lunga continua a referenziare un subscriber di vita corta.

public sealed class OrderViewModel
{
    private readonly OrderService _service;

    public OrderViewModel(OrderService service)
    {
        _service = service;
        _service.OrderChanged += OnOrderChanged;
    }

    private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
    {
        // update view model
    }
}

Se OrderService è un singleton e OrderViewModel viene creato per ogni schermata, l’evento di OrderService continua a referenziare OrderViewModel. Anche chiudendo la schermata, se non ci si disiscrive il ViewModel resta.

Esempio di correzione.

public sealed class OrderViewModel : IDisposable
{
    private readonly OrderService _service;

    public OrderViewModel(OrderService service)
    {
        _service = service;
        _service.OrderChanged += OnOrderChanged;
    }

    public void Dispose()
    {
        _service.OrderChanged -= OnOrderChanged;
    }

    private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
    {
        // update view model
    }
}

In gcroot può comparire come riferimento via delegate o event handler.

Questo pattern esce spesso in WPF, WinForms, servizi longevi, message broker, event aggregator.

11.4 Mancato Dispose di Timer

Anche System.Threading.Timer, PeriodicTimer, le subscription di Reactive Extensions e simili restano se non si fa Dispose.

public sealed class PollingWorker
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    private void Poll()
    {
        // polling
    }
}

Se questo PollingWorker si pensava un oggetto temporaneo, serve un design che faccia Dispose del Timer.

public sealed class PollingWorker : IDisposable
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    public void Dispose()
    {
        _timer.Dispose();
    }

    private void Poll()
    {
        // polling
    }
}

Un Timer tiene il delegate del callback, e da lì il riferimento può collegarsi all’oggetto target.

11.5 Mancato rilascio di IDisposable

Un Dispose mancato di IDisposable non necessariamente si vede come leak dell’heap managed.

Può uscire come problema di risorse: file, socket, connessioni DB, handle native, buffer e simili.

public async Task<string> ReadAsync(string path)
{
    var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

In questo esempio StreamReader chiude stream, quindi spesso non è un grosso problema; ma in codice in cui la proprietà è ambigua, le perdite succedono.

La base è rendere chiara la proprietà con using / await using.

public async Task<string> ReadAsync(string path)
{
    await using var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

Un Dispose mancato esce con sintomi come questi.

  • Cresce il numero di handle
  • Crescono i socket
  • I file non si chiudono
  • Cresce la memoria native
  • Cresce la Finalization Queue
  • Il GC Heap è stabile ma cresce la memoria del processo

In questo caso dumpheap da solo non basta. Si guardano anche handle e socket del sistema operativo, e lo stato delle librerie esterne.

11.6 AsyncLocal e ritenzione di contesto

AsyncLocal<T> è comodo, ma se ci si mette qualcosa di grande può restare a lungo.

Un valore piccolo come un correlation ID di log difficilmente diventa un problema. Tuttavia, se ci si mettono informazioni utente, body della richiesta, DTO grandi, un contesto DB e simili, si arriva a ritenzione non intenzionale.

public static class RequestContext
{
    public static readonly AsyncLocal<RequestInfo?> Current = new();
}

AsyncLocal viaggia sul flusso asincrono, quindi a volte è più difficile da trovare di un semplice campo static.

Si valuta un design che tenga piccolo e chiaro ciò che ci si mette, e che torni a null quando non serve più.

11.7 Mismatch di lifetime DI

Nella DI di ASP.NET Core e simili, le vite di singleton, scoped e transient sono diverse.

Se un singleton longevo trattiene dati per-request, gli oggetti possono restare anche a richiesta finita.

public sealed class AuditBuffer
{
    private readonly List<RequestAudit> _items = new();

    public void Add(RequestAudit item)
    {
        _items.Add(item);
    }
}

Se questo è un singleton, _items ha la stessa vita dell’app.

Se il design è di bufferizzare, servono tetto, invio, eliminazione, backpressure. Se è solo «forse lo guarderò dopo», andrebbe mandato su log o storage esterno.

12. Il LOH è particolarmente facile da fraintendere

LOH è l’abbreviazione di Large Object Heap. In .NET, gli oggetti grandi vengono messi in un heap distinto da quello degli oggetti piccoli ordinari. L’esempio rappresentativo è un array grande.

var buffer = new byte[1024 * 1024 * 10]; // 10MB

I problemi frequenti sul LOH sono questi tre.

  1. Creare di frequente oggetti grandi
  2. Tenere a lungo oggetti grandi
  3. Frammentare creando e distruggendo oggetti grandi

Che il LOH stia crescendo non significa subito una leak. Con un design che riusa buffer grandi, può crescere fino a una certa dimensione e poi stabilizzarsi; e anche se il GC ha raccolto, il Working Set non necessariamente scende subito.

Tuttavia, i seguenti stati vanno sospettati.

  • System.Byte[] cresce a ogni operazione
  • Crescono System.Char[] o String enormi
  • Dopo elaborazione di immagini, PDF, Excel, ZIP, crittografia, compressione, non torna
  • Array presi con ArrayPool<T>.Rent non vengono restituiti
  • Una risposta grande viene caricata interamente in memoria
  • Si usa molto MemoryStream.ToArray()

Se si usa ArrayPool<T>, si restituisce sempre.

var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);

try
{
    // use buffer
}
finally
{
    pool.Return(buffer);
}

Tuttavia, aver restituito al pool non significa che la memoria del processo scenda subito. Il pool, per il riuso, può trattenere memoria.

Anche qui, ciò che va guardato è «continua a crescere?», «c’è un tetto?», «viene riusata?».

13. Come leggere gcroot

gcroot mostra da dove è referenziato un certo oggetto.

Riassumiamo in tabella le root tipiche.

Root Significato
static field Referenziato da un campo static del tipo
local variable / stack Referenziato dallo stack di un thread in esecuzione
GC handle Referenziato da GCHandle, pin, delegate, interop e simili
finalization queue Trattenuto in attesa di finalizzazione
thread / async state machine Trattenuto da un’elaborazione asincrona in esecuzione o in attesa

Il punto da guardare spesso in indagine è la differenza di vita.

Oggetto longevo
  -> Oggetto che dovrebbe essere di breve vita

Se esce questa forma, è un candidato di leak.

Ad esempio, questo è sospetto.

SingletonService
  -> List<RequestContext>
  -> RequestContext
  -> LargeDto

SingletonService vive per tutta l’app. Se al suo interno si accumulano RequestContext per-request, il design va rivisto.

Dall’altra parte, una root come questa, a seconda del timing, è normale.

Thread stack
  -> Controller action local variable
  -> RequestDto

Se si è in mezzo all’elaborazione di una richiesta, è ovvio che resti una variabile locale.

Per questo, il timing del dump è importante.

Oltre che sotto carico, se si prendono dump anche dopo lo stop del carico, dopo che la coda è vuota, dopo un idle di un certo tempo, il giudizio diventa più facile.

14. «È sceso dopo un GC forzato» non significa «risolto»

Durante l’indagine si chiama GC.Collect() e la memoria scende. A quel punto, pensare «allora basta eseguire periodicamente GC.Collect()» è pericoloso.

Un GC forzato non elimina la causa. Ha solo raccolto sul momento gli oggetti che non erano ancora stati raccolti.

Se il problema è un alto tasso di allocazione, il GC forzato aumenta i tempi di pausa e peggiora le prestazioni. Se è una leak vera, gli oggetti ancora referenziati non vengono raccolti nemmeno da un GC forzato.

Ciò che in indagine va guardato è questa differenza.

Dopo un GC forzato Giudizio
Scende di molto, e poi la baseline resta stabile Attesa del GC, oppure allocazioni temporanee come causa principale
Scende un po’, ma a ogni ripetizione il floor sale Qualcosa sta sopravvivendo. Candidato di leak
Quasi non scende Resta referenziato, oppure la causa principale è fuori dall’heap GC
Il GC Heap scende ma il Working Set no Possibile ritenzione lato sistema operativo / segmenti GC / native

Prima di eseguire periodicamente GC.Collect() in produzione, si identifica sempre «che cosa sta crescendo».

15. Procedura di indagine da usare in pratica

Da qui, la riassumiamo come procedura da usare quando si indaga sul serio.

15.1 Fissare lo scenario di riproduzione

Per prima cosa si fissano le condizioni di indagine.

Target:        /api/report/export
Operazione:    100 esecuzioni nelle stesse condizioni
Intervallo:    5 secondi
Tempo di oss.: warm-up 5 min + carico 10 min + idle 5 min
Ambiente:      staging / Release build / impostazioni equivalenti alla produzione

In un’indagine sulla memoria, se si guarda mentre si fanno ogni volta operazioni diverse non si può giudicare. Si fissa «che cosa si è fatto perché crescesse».

15.2 Prendere una baseline

La baseline non è subito dopo l’avvio, ma dopo il warm-up.

Il motivo è che subito dopo l’avvio ci sono aumenti una tantum come questi.

  • JIT
  • Costruzione del container DI
  • Lettura delle impostazioni
  • Prima connessione DB
  • Prima connessione TLS / HTTP
  • Generazione di metadati del serializer JSON
  • Inizializzazione di Razor / template
  • Inizializzazione di logger e metriche

L’ordine è questo.

1. Avviare l'app
2. Chiamare alcune volte l'health check o API rappresentative
3. Aspettare 1–5 minuti
4. Prendere counters e dump come baseline

15.3 Raccogliere i counters sotto carico

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output report-export-counters.csv \
  --counters System.Runtime

In parallelo si esegue l’operazione di riproduzione. Ciò che si vuole vedere è la forma del grafico.

Forma vicina al normale:
  Cresce sotto carico
  Sale e scende con il GC
  Torna dopo lo stop del carico
  La baseline non continua a salire

Forma sospetta:
  Cresce in proporzione al numero di operazioni
  Sale il floor di Gen 2 / LOH
  Non torna nemmeno dopo lo stop del carico
  Al carico successivo il floor sale ancora

15.4 Prendere due dump

Si prendono prima e dopo il carico.

dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# applicare il carico
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp

Se c’è margine, si prende anche dopo lo stop del carico.

# dopo lo stop del carico, coda vuota, e dopo un'attesa di un certo tempo
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp

Nel confronto, oltre a before e after, è importante idle-after.

Anche se è cresciuto sotto carico, se dopo l’idle torna, può non essere una leak.

15.5 Guardare i tipi cresciuti

dotnet-dump analyze after.dmp
> dumpheap -stat

Si guarda allo stesso modo anche il lato before. Va bene anche a mano, ma per prima cosa si confrontano i tipi in alto.

I punti di vista da guardare.

  • Stanno crescendo tipi del namespace della propria azienda?
  • Dietro System.String non c’è un tipo proprio?
  • Chi tiene System.Byte[]?
  • Non stanno crescendo List<T> o Dictionary<TKey,TValue>?
  • Non stanno crescendo Task o async state machine?
  • Non stanno crescendo Timer o CancellationTokenSource?

15.6 Guardare le origini dei riferimenti

Si raccolgono gli indirizzi degli oggetti candidati e si fa gcroot.

> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>

Dal risultato di gcroot si cerca il padre che trattiene.

MyApp.Services.ReportCache
  -> Dictionary<string, ReportResult>
  -> ReportResult

Arrivati qui, si vede l’oggetto della code review.

  • ReportCache è un singleton?
  • C’è un tetto?
  • Viene eliminato?
  • Le chiavi continuano a crescere?
  • ReportResult non è troppo grande?
  • Andrebbe scaricato su DB o file invece che in cache?

16. Quando usare dotnet-trace

dotnet-dump è adatto a guardare, in uno snapshot di un certo istante, «che cosa resta come risultato». Dall’altra parte, se si vuole vedere «quando e dove si sta allocando in grande quantità», si usa dotnet-trace.

Ad esempio, si traccia includendo gli eventi legati al GC.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:01:00 \
  --clrevents gc+gchandle \
  --clreventlevel informational \
  --output gc-trace.nettrace

Se si vogliono vedere anche i campionamenti di allocazione, la quantità di eventi cresce, quindi in un ambiente di verifica si comincia da un tempo breve.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:00:30 \
  --clrevents gc+gcsampledobjectallocationhigh \
  --clreventlevel informational \
  --output allocation-trace.nettrace

La traccia è utile da un angolo diverso dal dump.

Cosa si vuole vedere Strumento adatto
Che cosa resta dump / gcdump
Chi referenzia dump + gcroot
Quando si è allocato in grande quantità trace
Quando è avvenuto il GC counters / trace
Se il problema sono i tempi di pausa counters / trace

In un’indagine di leak, è efficiente guardare prima con il dump «ciò che resta» e, se serve, con la traccia «il posto in cui si crea».

17. Esporre metriche di verifica nel codice

La diagnostica vera andrebbe fatta con strumenti esterni, ma mettere nell’app un log di diagnostica semplice è utile.

Ad esempio, un modo per far uscire informazioni di GC da un endpoint per amministratori o da un log periodico.

public static class GcDiagnostics
{
    public static object Snapshot()
    {
        var info = GC.GetGCMemoryInfo();

        return new
        {
            TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
            HeapSizeBytes = info.HeapSizeBytes,
            FragmentedBytes = info.FragmentedBytes,
            MemoryLoadBytes = info.MemoryLoadBytes,
            HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
            Gen0Collections = GC.CollectionCount(0),
            Gen1Collections = GC.CollectionCount(1),
            Gen2Collections = GC.CollectionCount(2)
        };
    }
}

Con queste informazioni da sole non si può giudicare una leak. Tuttavia, in caso di incidente, i giudizi seguenti diventano più facili.

  • Gen 2 sta aumentando di colpo?
  • HeapSize sta crescendo?
  • FragmentedBytes sta crescendo?
  • La differenza tra TotalMemory e la memoria del processo è grande?
  • La tendenza è cambiata dopo un deploy?

Se si mette nel log dell’app, si fa attenzione a non farne troppi. Una diagnostica pesante ad alta frequenza diventa essa stessa un carico.

18. Il criterio per dichiarare «memory leak»

Alla fine dell’indagine, si deve poter spiegare in questa forma. Come modello da usare in un report, elenco prima i campi da riempire.

Campo Cosa scrivere
Evento Chi è in difficoltà. Con quale operazione, di quanto cresce
Condizioni di osservazione Ambiente, configurazione di build, volume di dati, numero di esecuzioni, tempo di warm-up, intervallo di osservazione, strumenti usati e versioni
Osservazione Come si sono mossi i valori dei counters. Scrivere Working Set e GC Heap separati
Confronto Nei dump before e after, quale tipo è cresciuto di quanti elementi
Origine del riferimento Percorso di ritenzione visto con gcroot
Causa Quale design nel codice sta generando quella ritenzione
Contromisura Che cosa si cambia. Come si misura l’effetto
Temi residui Se serve. Ciò che questa osservazione non ha spiegato, e che cosa guardare dopo

Non omettere le condizioni di osservazione è particolarmente importante. Se le condizioni non sono scritte, non si può confrontare con la rimisurazione dopo la correzione, e la verifica del capitolo 20 perde senso.

Riempiendo secondo questo modello, ad esempio, viene così.

Evento:
  Eseguendo /api/report/export 100 volte, anche dopo lo stop del carico il GC Heap resta cresciuto di 300 MB e non torna.

Condizioni di osservazione:
  Ambiente staging / Release build / impostazioni e volume di dati equivalenti alla produzione.
  Dopo 5 minuti di warm-up, 100 esecuzioni nelle stesse condizioni, poi 5 minuti di idle.
  Raccolta di dotnet-counters a intervallo di 5 secondi.

Osservazione:
  Con dotnet-counters, la heap size di Gen 2 è cresciuta in proporzione al numero di operazioni.
  Non solo il Working Set: era cresciuto anche il GC Heap.

Confronto:
  Confrontando before.dmp e after.dmp, MyApp.Models.ReportResult era cresciuto di 12.000 elementi.

Origine del riferimento:
  Con gcroot era referenziato da MyApp.Services.ReportCache._items.

Causa:
  ReportCache era un singleton, usava come chiave user ID + ora corrente, e non c'erano eliminazione, scadenza né tetto.

Contromisura:
  Sostituito con MemoryCache, impostati tetto di dimensione e scadenza.
  Il numero di elementi in cache è stato reso metrica.

Se si arriva a spiegarlo così, non è più un mero «la memoria sta crescendo», ma un report in cui si tengono insieme condizioni di riproduzione, valori osservati, tipi cresciuti, origini dei riferimenti, causa e contromisura.

19. Attenzioni durante l’indagine

19.1 Guardare una build Release

Una build Debug, per gli effetti di ottimizzazione, vita delle variabili locali e informazioni di debug, può apparire diversa da quella in produzione.

In un’indagine equivalente alla produzione, si conferma con una build Release, impostazioni vicine alla produzione, volume di dati vicino.

19.2 Non giudicare solo dall’avvio

Subito dopo l’avvio, la memoria cresce per varie inizializzazioni.

Si prende una baseline dopo il warm-up, e da lì si guarda se cresce.

19.3 Non trattare un singolo dump come il colpevole

Il tipo in alto nell’heap non è necessariamente il colpevole.

System.String e System.Byte[] appaiono grandi in molte app.

L’importante è se sono cresciuti nel tempo, e chi li tiene.

19.4 I dump contengono informazioni riservate

Un dump di memoria può contenere richieste, credenziali, connection string, dati personali, dati di business.

Si decidono luogo di conservazione, estrazione, condivisione, regole di cancellazione.

19.5 Nei container, catturare un dump è un rischio

Se il limite di memoria del container è stretto, la memoria aggiuntiva o il page-in della cattura del dump possono provocare un OOM Kill.

Prima di prendere in un container di produzione, si prova in staging e si confermano limiti, spazio disco, permessi, namespace PID.

19.6 Esistono leak anche fuori dal GC Heap

Anche se è un’indagine .NET, non tutto esce sull’heap GC.

Con problemi come i seguenti, il GC Heap può essere stabile e la memoria del processo crescere comunque.

  • Librerie native
  • P/Invoke
  • COM
  • Elaborazione immagini
  • Librerie di compressione
  • Elaborazione crittografica
  • Driver DB
  • Socket
  • Marshal.AllocHGlobal
  • NativeMemory.Alloc
  • Troppi thread

In questo caso dumpheap di dotnet-dump da solo non basta. Servono diagnostica lato sistema operativo, metriche delle librerie esterne, handle, thread, memoria native.

19.7 Cosa decidere con gli stakeholder prima di prendere un dump in produzione

Prendere un dump in produzione, prima di essere un’operazione tecnica, è un lavoro che richiede coordinamento. Se si salta questo e si dice solo «è un’indagine, fatemi prendere», di solito si ferma.

Per prima cosa si organizzano gli impatti da spiegare.

  • Prendere un dump è un’operazione pesante per il processo target, e durante la cattura può sembrare che la risposta si fermi. Quanto si ferma dipende da dimensione dell’heap, velocità del disco, ambiente, quindi in staging si fa passare una volta la stessa procedura e si misura sul campo.
  • Se il tempo in cui la risposta si ferma è lungo, si possono innescare timeout dell’health check, distacco dal load balancer, failover del cluster.
  • I dump Full o Heap diventano grandi in funzione dell’uso di memoria del processo. Si conferma prima lo spazio disco libero della destinazione di scrittura.
  • Nei container, il page-in associato alla cattura del dump può superare il limite di memoria e il container può essere terminato forzatamente (19.5).
  • Il dump può contenere dati personali o credenziali (19.4).

Su questa base, prima della cattura si decide quanto segue.

Cosa decidere Esempio concreto
Chi approva Il responsabile del servizio e il dipartimento sistemi informativi. Prendere prima l’accordo di entrambi
Quando prendere Fascia oraria in cui un ritardo temporaneo di risposta è accettabile, ad esempio fuori orario di lavoro
Dove scrivere Disco locale con spazio sufficiente. Non scrivere direttamente su una cartella condivisa
Chi può accedere Limitare i diritti di accesso del luogo di conservazione ai responsabili dell’indagine
Quando cancellare Decidere e registrare in anticipo il termine di cancellazione dopo il completamento dell’indagine
Cosa fare in caso di fallimento Procedura di riavvio se la risposta non torna, e chi decide
Verifica preventiva Far passare una volta in staging la stessa procedura e annotare tempo richiesto e dimensione del file

Quando si fa la richiesta, è più sicuro non farlo a voce, ma consegnare questo contenuto su un foglio. Esempio di formato.

Scopo:      Identificare perché, dopo l'esecuzione di /api/report/export, la memoria non torna
Metodo:     Due catture di dotnet-dump collect --type Heap, prima e dopo il carico
Impatto:    Durante la cattura la risposta del processo target ritarda.
            I valori misurati in staging sono in allegato
Fascia:     Fuori orario di lavoro. Orario in cui il responsabile e i sistemi informativi possono essere presenti
Destinazione: Disco locale del server target. Confermare in anticipo lo spazio libero
Contenuto:  Possono esserci dati di richiesta in memoria, connection string e simili
Trattamento: Diritti di accesso del luogo di conservazione solo per i responsabili dell'indagine. Nessuna estrazione
Cancellazione: Dopo il completamento dell'indagine, cancellare al più tardi entro un mese e lasciare traccia della cancellazione
Rollback:   Se dopo la cattura la risposta non torna, riavviare il processo

Se sono allineati «perché», «quanto si ferma», «dove si mette e quando si cancella», chi deve giudicare può rispondere più facilmente sì o no. Al contrario, se questo è vago, si ferma l’indagine stessa.

20. Verifica dopo la correzione

Quando si è corretto un punto che sembra una leak, si rimisura con la stessa procedura.

Prima della correzione:
  Dopo 100 esecuzioni, Gen 2 +300 MB
  ReportResult +12.000 elementi

Dopo la correzione:
  Dopo 100 esecuzioni, Gen 2 stabile entro +20 MB
  ReportResult, dopo lo stop del carico, torna alla baseline
  Il numero di elementi in cache è stabile al tetto di 1.000

Nella verifica della correzione, si confronta sempre a parità di condizioni.

  • Stesso volume di dati
  • Stesso numero di volte
  • Stesso tempo di carico
  • Stesso warm-up
  • Stesso intervallo di osservazione
  • Stessi strumenti

Un’indagine sulla memoria, se il confronto prima/dopo la correzione è debole, non convince.

21. Sintesi

Quando in .NET la memoria sta crescendo, non si decide di colpo che è una leak: si fa il triage in quest’ordine.

  1. Non giudicare solo da Working Set / RSS
  2. Con dotnet-counters guardare GC Heap, Gen 2, LOH, numero di GC
  3. Confrontare sotto carico, dopo lo stop del carico, nel tempo
  4. Con dotnet-gcdump o dotnet-dump guardare i tipi cresciuti
  5. Con gcroot guardare chi referenzia
  6. Confermare static, cache, eventi, Timer, lifetime DI, contesto asincrono
  7. Se il GC Heap è stabile, sospettare anche problemi di memoria native o lato sistema operativo

La differenza tra «semplicemente non ancora raccolto dal GC» e «sta leakando» alla fine la decide il riferimento.

Se un oggetto ormai inutile non è referenziato, viene raccolto al timing del GC. Se, pur dovendo essere inutile, resta referenziato, il GC non può raccoglierlo.

In altre parole, l’obiettivo dell’indagine è questo.

Che cosa sta crescendo.
Che cosa resta anche dopo il GC.
Chi lo sta referenziando.
Quel riferimento è necessario a livello di design.

Se si arriva fin qui, non ci si fa trascinare dal grafico della memoria e si può far scendere il tutto su un punto di correzione nel codice.

Riferimenti

Articoli recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

L’articolo è direttamente collegato ai servizi seguenti.

Domande frequenti

Domande che ricorrono nelle consulenze sull’argomento dell’articolo.

Se l'uso di memoria di un'app .NET cresce, è una memory leak?
Che la memoria del processo cresca e che ci sia una memory leak non sono la stessa cosa. In .NET il GC agisce in base allo stato delle allocazioni e alle soglie dell'heap, quindi può succedere che oggetti ormai inutili non siano ancora stati raccolti, o che dopo il GC il sistema operativo non recuperi subito la memoria. Le tre cose da guardare sono: se la memoria che sopravvive al GC continua a crescere, di quali tipi si tratta, e chi sta tenendo il riferimento a quegli oggetti.
Quali strumenti usare per indagare una memory leak in .NET?
Per prima cosa, con dotnet-counters osservate le tendenze di Working Set, GC Heap, Gen 2/LOH e del numero di GC. Poi con dotnet-gcdump prendete un GC dump prima e dopo il carico, confrontate Count e Size per tipo e identificate i tipi cresciuti. Infine con dotnet-dump prendete un heap dump e, con dumpheap -stat e gcroot, seguite fino a chi tiene il riferimento e perché l'oggetto non viene raccolto. Non basta un singolo numero: conta il confronto nel tempo, a parità di condizioni.
Quali sono i pattern più comuni di memory leak in .NET?
I casi tipici sono: aggiunte senza fine a collezioni static, cache illimitate senza tetto né scadenza, mancata disiscrizione da eventi di un publisher longevo, Timer di cui non si fa Dispose, IDisposable non rilasciati, e mismatch di lifetime DI in cui un singleton trattiene dati per-request. Una leak in .NET si capisce meglio come «ritenzione non intenzionale» — oggetti che restano referenziati pur non servendo più — piuttosto che come «dimenticanza di free».
Eseguire periodicamente GC.Collect() risolve i problemi di memoria?
No. Un GC forzato raccoglie sul momento solo gli oggetti che non erano ancora stati raccolti; non elimina la causa. Se il problema è un alto tasso di allocazione, aumenta i tempi di pausa e peggiora le prestazioni; se è una leak vera, gli oggetti ancora referenziati non vengono raccolti nemmeno da un GC forzato. In un ambiente di verifica controllato può servire a vedere se restano dopo un GC forzato, ma prima di metterlo in produzione come rimedio dovete identificare che cosa sta crescendo.

Profilo dell’autore

Pagina di presentazione dell’autore dell’articolo.

Go Komura

Rappresentante di KomuraSoft LLC

Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.

Torna al blog