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.
- La memoria che sopravvive anche dopo il GC sta crescendo?
- Quali tipi stanno crescendo?
- 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.
flowchart LR
accTitle: Mappa della conoscenza di come distinguere l'attesa del GC da una memory leak in .NET
accDescr: Diagramma 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 gcroot
dotnet_garbage_collection["Garbage collection di .NET (GC)"]
dotnet_memory_leak["Memory leak di .NET (ritenzione non intenzionale)"]
dotnet_counters["dotnet-counters"]
dotnet_gcdump["dotnet-gcdump"]
dotnet_dump["dotnet-dump"]
gcroot_command["Comando gcroot"]
gc_heap["Heap GC (heap gestito)"]
dumpheap_command["Comando dumpheap -stat"]
dotnet_trace["dotnet-trace"]
gen2_heap["Gen 2 (heap di seconda generazione)"]
total_allocated_memory["Total Allocated (allocazione cumulativa)"]
working_set_rss["Working Set / RSS"]
induced_gc["GC forzato (Induced Collection)"]
static_collection_leak["Ritenzione tramite collezione static"]
unbounded_cache["Cache senza limite né scadenza"]
event_subscription_leak["Mancata disiscrizione dagli eventi"]
timer_disposal_leak["Mancato Dispose del Timer"]
idisposable_leak["Mancato rilascio di IDisposable"]
di_lifetime_mismatch["Scambio del lifetime DI"]
large_object_heap["LOH (Large Object Heap)"]
dotnet_framework[".NET Framework"]
dotnet[".NET (da Core in poi)"]
dotnet_garbage_collection -->|"verificato da"| dotnet_counters
dotnet_memory_leak -->|"verificato da"| dotnet_gcdump
dotnet_memory_leak -->|"verificato da"| dotnet_dump
dotnet_memory_leak -->|"verificato da"| gcroot_command
gc_heap -->|"verificato da"| dumpheap_command
dotnet_memory_leak -.->|"verificato da"| dotnet_trace
dotnet_memory_leak -->|"verificato da"| gen2_heap
total_allocated_memory -->|"sconsigliato per"| dotnet_memory_leak
working_set_rss -->|"sconsigliato per"| dotnet_memory_leak
induced_gc -->|"sconsigliato per"| dotnet_memory_leak
static_collection_leak -.->|"può causare"| dotnet_memory_leak
unbounded_cache -.->|"può causare"| dotnet_memory_leak
event_subscription_leak -.->|"può causare"| dotnet_memory_leak
timer_disposal_leak -.->|"può causare"| dotnet_memory_leak
idisposable_leak -.->|"può causare"| dotnet_memory_leak
di_lifetime_mismatch -.->|"può causare"| dotnet_memory_leak
large_object_heap -.->|"può causare"| dotnet_memory_leak
dotnet_counters -->|"deve precedere"| dotnet_gcdump
dotnet_gcdump -->|"deve precedere"| dotnet_dump
dotnet_counters -->|"incompatibile con"| dotnet_framework
dotnet_dump -->|"incompatibile con"| dotnet_framework
dotnet_garbage_collection -->|"usa"| gc_heap
dotnet_garbage_collection -->|"richiede"| dotnet
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.
flowchart TB
accTitle: Relazione tra le metriche di memoria di un processo .NET
accDescr: Il 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 fisica
PROC["Memoria usata dal processo"] --> MANAGED["Lato managed<br/>Ambito visibile in GC Heap Size"]
PROC --> NATIVE["Lato native<br/>Ambito che non compare in GC Heap Size"]
MANAGED --> G01["Gen 0 / Gen 1<br/>Allocazioni di breve vita"]
MANAGED --> G2["Gen 2<br/>Oggetti longevi sopravvissuti"]
MANAGED --> LOH["LOH<br/>Oggetti grandi di 85.000 byte o più"]
MANAGED --> POH["POH<br/>Oggetti pinned"]
NATIVE --> STK["Stack dei thread"]
NATIVE --> JITC["Codice JIT, assembly già caricati"]
NATIVE --> INTEROP["Buffer di P/Invoke, COM, librerie esterne"]
MANAGED -.vengono conteggiati.-> COMMIT["Private Bytes / Commit<br/>Memoria committed di proprietà del processo"]
NATIVE -.vengono conteggiati.-> COMMIT
COMMIT -.solo la porzione residente in RAM fisica.-> WS["Working Set / RSS"]
Da questo diagramma voglio che restino due punti.
- Ciò che
dumpheaprende visibile è solo il lato managed. Se sta crescendo il lato native, per quanto si guardi l’heap il colpevole non esce. - 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.
- Creare di frequente oggetti grandi
- Tenere a lungo oggetti grandi
- 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[]oStringenormi - Dopo elaborazione di immagini, PDF, Excel, ZIP, crittografia, compressione, non torna
- Array presi con
ArrayPool<T>.Rentnon 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.Stringnon c’è un tipo proprio? - Chi tiene
System.Byte[]? - Non stanno crescendo
List<T>oDictionary<TKey,TValue>? - Non stanno crescendo
Tasko async state machine? - Non stanno crescendo
TimeroCancellationTokenSource?
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.AllocHGlobalNativeMemory.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
FulloHeapdiventano 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.
- Non giudicare solo da Working Set / RSS
- Con
dotnet-countersguardare GC Heap, Gen 2, LOH, numero di GC - Confrontare sotto carico, dopo lo stop del carico, nel tempo
- Con
dotnet-gcdumpodotnet-dumpguardare i tipi cresciuti - Con
gcrootguardare chi referenzia - Confermare static, cache, eventi, Timer, lifetime DI, contesto asincrono
- 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
- Codice di esempio completo di questo articolo (libreria, demo, unit test) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
- .NET Framework: Performance counters
- .NET Framework: SOS.dll (SOS Debugging Extension)
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
Best practice pratiche sul multithreading: edizione .NET — Cosa decidere prima di aggiungere altri thread
Le regole di progettazione per .NET/C# che impediscono che, dopo aver avviato un thread, l'app «crashi o si blocchi di tanto in tanto». L...
Usare WMI/CIM da C# e PowerShell — Guida pratica a lettura hardware, monitoraggio processi e interrogazioni remote
Il modo classico per leggere il numero di serie del PC, monitorare lo spazio disco e rilevare l'avvio dei processi è WMI/CIM. L'articolo ...
Le profondità dell'I/O di Windows (4ª puntata) — Cache Manager: quando il vostro WriteFile arriva sul disco
Quarta puntata della serie che spiega con i diagrammi il Cache Manager di Windows. Si organizzano la cache implementata come file mapping...
Icone nella system tray e notifiche toast nelle app Windows — le insidie di NotifyIcon e come scegliere l'AppNotification giusta
Una guida pratica per mantenere un'applicazione Windows aziendale residente nella system tray (area di notifica) e avvisare l'utente tram...
Fino a quando funzioneranno le applicazioni VB6? — Lo stato del supporto al runtime e un percorso pratico verso la migrazione a .NET
Fino a quando continueranno a funzionare le applicazioni VB6? Questo articolo chiarisce l'asimmetria tra la politica di supporto del runt...
Argomenti correlati
Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.
Argomenti tecnici Windows
Portale su sviluppo Windows, analisi dei problemi e valorizzazione delle risorse esistenti.
Servizi collegati all’argomento
L’articolo è direttamente collegato ai servizi seguenti.
Sviluppo di applicazioni Windows
Applicazioni aziendali, integrazione di dispositivi e strumenti di comunicazione, dai requisiti allo sviluppo.
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.