Profiling de memória é a arte de descobrir por que sua aplicação .NET está consumindo mais memória do que deveria. Diferente de fragmentação (memória livre mas inutilizável), um memory leak ocorre quando objetos permanecem vivos indefinidamente porque ainda existem referências para eles — mesmo que logicamente não sejam mais necessários. Este artigo apresenta um guia completo e prático para diagnosticar leaks usando as ferramentas oficiais da Microsoft: dotnet-dump para análise profunda e dotnet-gcdump para inspeções leves em produção.
Você aprenderá a capturar dumps de memória em diferentes cenários (Windows, Linux, containers), analisar o heap com comandos SOS como dumpheap, gcroot, dumpobj e objsize, identificar padrões comuns de leak (eventos não desinscritos, closures capturando referências, caches sem expiração, CancellationTokenSource não liberados), e implementar soluções definitivas. Todos os exemplos são 100% funcionais e podem ser copiados e testados localmente. Ao final, você terá domínio completo sobre diagnóstico de memória no .NET 10.
Insights
- Memory leak não é memória “perdida”, é memória “esquecida”: Objetos que deveriam morrer continuam vivos porque algo ainda os referencia. O GC funciona perfeitamente — ele só não coleta o que você acidentalmente mantém vivo.
- dotnet-gcdump é seu aliado leve em produção: Captura apenas o grafo de objetos do heap gerenciado usando EventPipe, sem pausar significativamente a aplicação. Ideal para comparações antes/depois e análise rápida de tendências.
- dotnet-dump é o bisturi cirúrgico: Captura um dump completo do processo (memória, threads, handles) permitindo análise profunda com comandos SOS. Mais pesado, mas revela absolutamente tudo.
- O comando
gcrooté seu melhor amigo: Mostra a cadeia completa de referências que mantém um objeto vivo. Sem ele, você sabe o que está vazando mas não por que.
- *Dois dumps são melhor que um: Capture um dump logo após o startup* e outro quando a memória está alta. Compare os dois para identificar o que cresceu — isso elimina falsos positivos de objetos legítimos de longa vida.
O Que é um Memory Leak no .NET?
Um memory leak em .NET ocorre quando objetos permanecem no heap gerenciado indefinidamente porque ainda existem referências ativas para eles, mesmo que logicamente não sejam mais necessários para a aplicação.
Importante: o Garbage Collector (GC) do .NET funciona corretamente. Ele coleta todo objeto que não possui referências. O problema está no seu código — você está mantendo referências que não deveria.
flowchart TB
subgraph SEM["Cenário sem leak"]
R1["GC Root (stack, static)"] -->|referência| A1["Objeto A"]
A1 -->|sai de escopo| X1["Referência removida"]
X1 --> C1["GC coleta A — memória livre"]
end
subgraph COM["Cenário com leak"]
R2["GC Root (evento static)"] -->|referência esquecida| A2["Objeto A"]
A2 -->|deveria sair de escopo| X2["Referência persiste"]
X2 --> C2["GC não coleta — memória cresce"]
endDiferença Entre Leak e Fragmentação
| Característica | Memory Leak | Fragmentação |
|---|---|---|
| Causa | Referências mantidas indevidamente | Buracos entre objetos vivos |
| Heap cresce? | Sim, continuamente | Pode não crescer |
| OOM ocorre por | Esgotamento total | Falta de bloco contíguo |
| GC consegue ajudar? | Não (objetos estão “vivos”) | Parcialmente (compactação) |
| Ferramenta principal | gcroot (quem referencia?) | dumpheap -type Free |
> Artigo anterior: Se você ainda não leu sobre fragmentação de memória, recomendo o artigo Fragmentação de Memória no .NET: Diagnóstico Profundo com WinDbg que complementa este material.
Ferramentas de Diagnóstico — Visão Geral
O ecossistema .NET oferece várias ferramentas para diagnóstico de memória. Este artigo foca nas duas principais ferramentas de linha de comando:
flowchart TB
C["dotnet-counters — tempo real (detectar SE há problema)"]
G["dotnet-gcdump — grafo de objetos via EventPipe (QUAIS tipos crescem)"]
D["dotnet-dump — dump completo + SOS (POR QUE estão vivos, gcroot)"]
C -->|problema detectado| G
G -->|precisa de mais detalhes| DComparação — dotnet-dump vs dotnet-gcdump
| Aspecto | dotnet-dump | dotnet-gcdump |
|---|---|---|
| Tamanho do arquivo | Grande (centenas de MB a GB) | Pequeno (alguns MB) |
| Overhead na captura | Médio-alto (pausa breve) | Baixo |
| Informações capturadas | Tudo (memória, threads, handles) | Apenas objetos do heap |
| Análise offline | Sim, com analyze | Sim, com Visual Studio/PerfView |
Comando gcroot | Sim | Não |
| Cross-platform | Sim | Sim |
| Ideal para | Diagnóstico profundo | Comparação rápida, produção |
Instalação das Ferramentas
As ferramentas são distribuídas como global tools do .NET:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# Instalar dotnet-dump dotnet tool install --global dotnet-dump # Instalar dotnet-gcdump dotnet tool install --global dotnet-gcdump # Instalar dotnet-counters (monitoramento) dotnet tool install --global dotnet-counters # Verificar instalação dotnet-dump --version dotnet-gcdump --version dotnet-counters --version |
Atualização (se já instalado):
|
1 2 3 |
dotnet tool update --global dotnet-dump dotnet tool update --global dotnet-gcdump dotnet tool update --global dotnet-counters |
Aplicação de Exemplo — Simulando Memory Leaks
Vamos criar uma aplicação completa que simula os tipos mais comuns de memory leaks. Este código é 100% funcional — copie e execute para praticar o diagnóstico.
Estrutura do Projeto
|
1 2 3 4 |
mkdir MemoryLeakDemo cd MemoryLeakDemo dotnet new webapi -n MemoryLeakDemo cd MemoryLeakDemo |
Código Completo da Aplicação
Substitua o conteúdo de Program.cs:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
// Program.cs - Aplicação com múltiplos tipos de memory leak para diagnóstico using System.Collections.Concurrent; using System.Timers; using Timer = System.Timers.Timer; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<LeakDemoService>(); builder.Services.AddSingleton<EventPublisher>(); var app = builder.Build(); // Endpoints para demonstrar diferentes tipos de leak var leakService = app.Services.GetRequiredService<LeakDemoService>(); var eventPublisher = app.Services.GetRequiredService<EventPublisher>(); // Endpoint 1: Leak por evento estático não removido app.MapGet("/leak/event/{count:int}", (int count) => { for (int i = 0; i < count; i++) { var subscriber = new EventSubscriber($"Subscriber-{Guid.NewGuid()}"); eventPublisher.Subscribe(subscriber); // PROBLEMA: subscriber nunca é removido do evento! // Cada chamada adiciona mais subscribers que nunca morrem } return Results.Ok($"Criados {count} subscribers (vazando memória)"); }); // Endpoint 2: Leak por cache sem expiração app.MapGet("/leak/cache/{count:int}", (int count, LeakDemoService service) => { for (int i = 0; i < count; i++) { var customer = new Customer { Id = Guid.NewGuid(), Name = $"Customer {i}", Data = new byte[10_000] // 10KB por customer }; service.AddToCache(customer); // PROBLEMA: cache cresce infinitamente! } return Results.Ok($"Adicionados {count} customers ao cache (vazando memória)"); }); // Endpoint 3: Leak por closure capturando this app.MapGet("/leak/closure/{count:int}", (int count, LeakDemoService service) => { for (int i = 0; i < count; i++) { var processor = new DataProcessor(); processor.ProcessWithLeak(); // PROBLEMA: lambda captura 'this', Timer mantém referência } return Results.Ok($"Criados {count} processors com closure leak"); }); // Endpoint 4: Leak por CancellationTokenSource não disposed app.MapGet("/leak/cts/{count:int}", async (int count) => { var tasks = new List<Task>(); for (int i = 0; i < count; i++) { // PROBLEMA: CancellationTokenSource criado mas nunca disposed var cts = new CancellationTokenSource(TimeSpan.FromHours(1)); var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); tasks.Add(Task.Run(async () => { await Task.Delay(10, linkedCts.Token); }, linkedCts.Token)); } await Task.WhenAll(tasks); return Results.Ok($"Executadas {count} tasks com CTS leak"); }); // Endpoint 5: Leak por Timer não parado app.MapGet("/leak/timer/{count:int}", (int count) => { for (int i = 0; i < count; i++) { var holder = new TimerHolder(); holder.Start(); // PROBLEMA: Timer não é parado, mantém referência ao holder } return Results.Ok($"Criados {count} timers (vazando memória)"); }); // Endpoint de verificação - não causa leak app.MapGet("/status", (LeakDemoService service) => { var info = GC.GetGCMemoryInfo(); return Results.Ok(new { HeapSizeMB = info.HeapSizeBytes / 1024 / 1024, FragmentedMB = info.FragmentedBytes / 1024 / 1024, CacheCount = service.CacheCount, Gen0Collections = GC.CollectionCount(0), Gen1Collections = GC.CollectionCount(1), Gen2Collections = GC.CollectionCount(2) }); }); // Endpoint para forçar GC (diagnóstico) app.MapGet("/gc", () => { var before = GC.GetTotalMemory(false); GC.Collect(2, GCCollectionMode.Forced, true, true); GC.WaitForPendingFinalizers(); GC.Collect(2, GCCollectionMode.Forced, true, true); var after = GC.GetTotalMemory(true); return Results.Ok(new { BeforeMB = before / 1024 / 1024, AfterMB = after / 1024 / 1024, FreedMB = (before - after) / 1024 / 1024 }); }); app.Run(); // ============================================================================ // CLASSES DE DEMONSTRAÇÃO DE MEMORY LEAK // ============================================================================ /// <summary> /// Publicador de eventos - demonstra leak por eventos estáticos /// </summary> public class EventPublisher { // PROBLEMA: Evento mantém referência forte a todos os subscribers public event EventHandler<string>? OnDataReceived; public void Subscribe(EventSubscriber subscriber) { OnDataReceived += subscriber.HandleEvent; } public void Unsubscribe(EventSubscriber subscriber) { OnDataReceived -= subscriber.HandleEvent; } public void Publish(string data) { OnDataReceived?.Invoke(this, data); } } /// <summary> /// Subscriber que nunca é removido do evento /// </summary> public class EventSubscriber { public string Name { get; } public byte[] Payload { get; } // Dados para simular uso de memória public EventSubscriber(string name) { Name = name; Payload = new byte[50_000]; // 50KB por subscriber } public void HandleEvent(object? sender, string data) { // Processa evento } } /// <summary> /// Serviço com cache que cresce infinitamente /// </summary> public class LeakDemoService { // PROBLEMA: Dictionary cresce sem limite, nunca remove itens private readonly ConcurrentDictionary<Guid, Customer> _cache = new(); public int CacheCount => _cache.Count; public void AddToCache(Customer customer) { _cache[customer.Id] = customer; } public Customer? GetFromCache(Guid id) { _cache.TryGetValue(id, out var customer); return customer; } } /// <summary> /// Entidade com dados para simular consumo de memória /// </summary> public class Customer { public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public byte[] Data { get; set; } = Array.Empty<byte>(); } /// <summary> /// Demonstra leak por closure capturando this /// </summary> public class DataProcessor { private readonly byte[] _largeData = new byte[100_000]; // 100KB private Timer? _timer; public void ProcessWithLeak() { _timer = new Timer(60_000); // 1 minuto // PROBLEMA: Lambda captura 'this' implicitamente // O Timer mantém referência ao delegate, que mantém referência a 'this' _timer.Elapsed += (sender, e) => { // Usa _largeData, capturando 'this' var length = _largeData.Length; Console.WriteLine($"Timer tick, data length: {length}"); }; _timer.Start(); // Timer nunca é parado ou disposed! } } /// <summary> /// Demonstra leak por Timer não parado /// </summary> public class TimerHolder { private readonly Timer _timer; private readonly byte[] _data = new byte[50_000]; // 50KB public TimerHolder() { _timer = new Timer(30_000); _timer.Elapsed += OnTimerElapsed; } public void Start() { _timer.Start(); } private void OnTimerElapsed(object? sender, ElapsedEventArgs e) { // Usa _data, mantendo referência a this var _ = _data.Length; } // PROBLEMA: Stop() e Dispose() nunca são chamados public void Stop() { _timer.Stop(); _timer.Dispose(); } } |
Executando a Aplicação
|
1 2 3 4 5 |
# Compilar e executar dotnet run --configuration Release # A aplicação estará disponível em: # http://localhost:5000 (ou porta configurada) |
Etapa 1 — Detectando o Problema com dotnet-counters
Antes de capturar dumps, confirme que há um problema de memória usando dotnet-counters.
Encontrar o PID do Processo
|
1 2 |
# Listar processos .NET dotnet-counters ps |
Saída esperada:
|
1 2 |
12345 MemoryLeakDemo C:\Users\dev\MemoryLeakDemo\bin\Release\net10.0\MemoryLeakDemo.exe 67890 dotnet C:\Program Files\dotnet\dotnet.exe |
Monitorar Métricas em Tempo Real
|
1 2 |
# Monitorar processo específico dotnet-counters monitor --process-id 12345 --refresh-interval 1 --counters System.Runtime |
Saída inicial (aplicação saudável):
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
Press p to pause, r to resume, q to quit. Status: Running [System.Runtime] dotnet.assembly.count 45 dotnet.gc.collections[gc.heap.generation=gen0] 12 dotnet.gc.collections[gc.heap.generation=gen1] 3 dotnet.gc.collections[gc.heap.generation=gen2] 1 dotnet.gc.collections[gc.heap.generation=loh] 1 dotnet.gc.heap.total_allocated (By) 134,217,728 dotnet.gc.last_collection.heap.size (By) 15,204,352 dotnet.gc.last_collection.memory.committed_size (By) 25,165,824 dotnet.gc.pause.time (%) 0.02 dotnet.monitor.lock_contentions 0 dotnet.process.cpu.time (%) 1.2 dotnet.threadpool.queue.length 0 dotnet.threadpool.thread.count 4 |
Simular o Leak
Em outro terminal, cause o leak:
|
1 2 3 4 5 6 7 8 |
# Causar leak por cache (adiciona 10.000 customers de 10KB cada = ~100MB) curl "http://localhost:5000/leak/cache/10000" # Causar leak por eventos (adiciona 5.000 subscribers de 50KB cada = ~250MB) curl "http://localhost:5000/leak/event/5000" # Tentar forçar GC para confirmar que é leak (não vai liberar) curl "http://localhost:5000/gc" |
Saída do dotnet-counters APÓS os leaks:
|
1 2 3 4 5 6 7 |
[System.Runtime] dotnet.gc.collections[gc.heap.generation=gen0] 156 dotnet.gc.collections[gc.heap.generation=gen1] 45 dotnet.gc.collections[gc.heap.generation=gen2] 12 dotnet.gc.heap.total_allocated (By) 1,073,741,824 dotnet.gc.last_collection.heap.size (By) 384,520,192 ← Era 15MB! dotnet.gc.last_collection.memory.committed_size (By) 402,653,184 |
Análise:
heap.sizesubiu de 15MB para 384MB- GC rodou várias vezes (
gen2foi de 1 para 12) - Memória NÃO foi liberada mesmo após GC forçado
Conclusão: há um memory leak. Hora de investigar com dumps.
Etapa 2 — Captura Rápida com dotnet-gcdump
O dotnet-gcdump é ideal para uma primeira análise porque é leve e rápido.
Capturar o GC Dump
|
1 2 3 4 5 6 7 8 |
# Capturar GC dump dotnet-gcdump collect --process-id 12345 # Com output personalizado dotnet-gcdump collect --process-id 12345 --output ./memoryleak.gcdump # Com verbose para ver progresso dotnet-gcdump collect --process-id 12345 --verbose |
Saída:
|
1 2 |
Writing gcdump to './20260210_143052_12345.gcdump'... Finished writing 4,521,984 bytes. |
Gerar Relatório de Heap
|
1 2 |
# Relatório direto do arquivo dotnet-gcdump report ./20260210_143052_12345.gcdump |
Saída (ordenada por tamanho total):
|
1 2 3 4 5 6 7 8 9 |
Size (Bytes) Count Type ============ ===== ==== 250,000,000 5,000 EventSubscriber 100,000,000 10,000 Customer 50,000,000 5,000 System.Byte[] 10,000,000 10,000 System.Byte[] 5,000,000 5,000 System.String 1,234,567 123 System.Collections.Generic.Dictionary`2+Entry[[System.Guid],[Customer]][] 500,000 500 System.Timers.Timer |
Análise:
| Tipo | Contagem | Tamanho | Problema |
|---|---|---|---|
EventSubscriber | 5.000 | 250MB | Cada um tem 50KB de Payload |
Customer | 10.000 | 100MB | Cache sem expiração |
System.Byte[] | 15.000 | 60MB | Arrays dentro dos objetos acima |
O dotnet-gcdump respondeu O QUE está vazando. Mas não mostra POR QUE. Para isso, precisamos do dotnet-dump.
Visualização no Visual Studio (Windows)
No Windows, você pode abrir o arquivo .gcdump diretamente no Visual Studio:
- File → Open → File
- Selecione o arquivo
.gcdump - Visual Studio mostra o Memory Analysis Report
O relatório visual inclui:
- Gráfico de tipos por tamanho
- Árvore de retenção (quem mantém quem)
- Comparação entre dumps (diff)
Etapa 3 — Análise Profunda com dotnet-dump
O dotnet-dump captura um dump completo do processo, permitindo análise detalhada com comandos SOS.
Capturar o Dump Completo
|
1 2 3 4 5 6 7 8 9 10 11 |
# Captura padrão (recomendado) dotnet-dump collect --process-id 12345 # Com tipo específico dotnet-dump collect --process-id 12345 --type Full # Tipos disponíveis: # - Full: Dump completo (maior, mais informação) # - Heap: Apenas heap gerenciado # - Mini: Mínimo (threads e stacks) # - Triage: Informações de triagem (menor) |
Saída:
|
1 2 |
Writing full dump to ./core_20260210_144532 Complete |
Iniciar a Análise Interativa
|
1 |
dotnet-dump analyze ./core_20260210_144532 |
Prompt interativo:
|
1 2 3 |
Loading core dump: ./core_20260210_144532 Ready to process analysis commands. Type 'help' to list available commands or 'exit' to quit. > |
Comandos SOS Essenciais para Diagnóstico de Memory Leak
O comando dumpheap -stat
Mostra estatísticas de todos os objetos no heap, agrupados por tipo.
|
1 |
> dumpheap -stat |
Saída:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
Statistics: MT Count TotalSize Class Name 00007f8a1c0056a0 1 24 System.Collections.Generic.NonRandomizedStringEqualityComparer 00007f8a1c005760 12 384 System.Int32 00007f8a1c005820 156 3,744 System.RuntimeType 00007f8a1c005940 5,000 250,000 EventSubscriber 00007f8a1c0059a0 5,000 250,000,000 System.Byte[] (dentro de EventSubscriber) 00007f8a1c005a60 10,000 100,000 Customer 00007f8a1c005b20 10,000 100,000,000 System.Byte[] (dentro de Customer) 00007f8a1c005bc0 500 24,000 System.Timers.Timer ... Total 45,234 objects, 702,456,128 bytes |
Interpretação da saída:
| Coluna | Significado |
|---|---|
MT | Method Table – identificador único do tipo |
Count | Quantidade de objetos desse tipo |
TotalSize | Tamanho total ocupado por todos os objetos desse tipo |
Class Name | Nome completo do tipo |
O comando dumpheap -mt <MethodTable>
Lista todos os objetos de um tipo específico.
|
1 |
> dumpheap -mt 00007f8a1c005940 |
Saída:
|
1 2 3 4 5 6 7 8 9 10 11 |
Address MT Size 00007f8a2d012340 00007f8a1c005940 50,024 00007f8a2d01c5a0 00007f8a1c005940 50,024 00007f8a2d026800 00007f8a1c005940 50,024 00007f8a2d030a60 00007f8a1c005940 50,024 ... Statistics: MT Count TotalSize Class Name 00007f8a1c005940 5,000 250,120,000 EventSubscriber Total 5,000 objects, 250,120,000 bytes |
O comando dumpheap -type <TypeName>
Filtra por substring do nome do tipo (mais conveniente que -mt).
|
1 |
> dumpheap -type EventSubscriber -stat |
Saída:
|
1 2 3 4 |
Statistics: MT Count TotalSize Class Name 00007f8a1c005940 5,000 250,120,000 EventSubscriber Total 5,000 objects, 250,120,000 bytes |
Parâmetros Úteis do dumpheap
| Parâmetro | Descrição | Exemplo |
|---|---|---|
-stat | Apenas estatísticas, não lista objetos | dumpheap -stat |
-mt <addr> | Filtra por Method Table | dumpheap -mt 00007f8a1c005940 |
-type <name> | Filtra por nome do tipo (substring) | dumpheap -type Customer |
-min <bytes> | Objetos maiores que N bytes | dumpheap -min 85000 |
-max <bytes> | Objetos menores que N bytes | dumpheap -max 1000 |
-gen <0/1/2> | Objetos em geração específica | dumpheap -gen 2 -stat |
-short | Apenas endereços (para scripts) | dumpheap -type Customer -short |
O comando dumpobj <address>
Examina um objeto específico em detalhes.
|
1 |
> dumpobj 00007f8a2d012340 |
Saída:
|
1 2 3 4 5 6 7 8 9 10 |
Name: EventSubscriber MethodTable: 00007f8a1c005940 EEClass: 00007f8a1c123450 Tracked Type: false Size: 50024(0xc388) bytes File: /app/MemoryLeakDemo.dll Fields: MT Field Offset Type VT Attr Value Name 00007f8a1c005640 4000001 8 System.String 0 instance 00007f8a2d012360 <Name>k__BackingField 00007f8a1c005700 4000002 10 System.Byte[] 0 instance 00007f8a2d012380 <Payload>k__BackingField |
Interpretação:
| Campo | Significado |
|---|---|
Name | Nome do tipo |
Size | Tamanho em bytes |
Fields | Lista de campos com seus valores/endereços |
Value | Para tipos referência, é o endereço do objeto referenciado |
Para ver o valor do campo Name:
|
1 |
> dumpobj 00007f8a2d012360 |
|
1 2 3 |
Name: System.String MethodTable: 00007f8a1c005640 String: Subscriber-a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
O comando gcroot – O Mais Importante
O gcroot mostra a cadeia completa de referências que mantém um objeto vivo. Este é o comando mais importante para diagnóstico de memory leak.
|
1 |
> gcroot 00007f8a2d012340 |
Saída:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
HandleTable: 00007f8a10001020 (strong handle) -> 00007f8a2d000100 EventPublisher -> 00007f8a2d000200 System.EventHandler`1[[System.String]] -> 00007f8a2d000300 System.Object[] -> 00007f8a2d012340 EventSubscriber Thread 1 (ID: 0x1234): rbp-38: 00007f8a2d012340 -> 00007f8a2d012340 EventSubscriber Found 2 unique roots: 1 strong handle(s) 1 stack reference(s) |
Interpretação do resultado:
flowchart TB
H["Strong Handle (GC Root)"] -->|referência| P["EventPublisher (Singleton)"]
P -->|campo OnDataReceived| E["EventHandler"]
E -->|invocationList| O["System.Object[]"]
O -->|elemento| S["EventSubscriber — objeto vazando (50KB)"]Conclusão do diagnóstico:
EventPublisheré um Singleton (vive para sempre)- O campo
OnDataReceived(evento) contém um delegate - O delegate referencia nosso
EventSubscriber - Solução: chamar
Unsubscribe()quando o subscriber não for mais necessário
O comando objsize <address>
Calcula o tamanho total de um objeto incluindo todos os objetos que ele referencia (tamanho transitivo).
|
1 |
> objsize 00007f8a2d012340 |
Saída:
|
1 |
sizeof(00007f8a2d012340) = 50,024 (0xc388) bytes (EventSubscriber) |
Para ver a árvore completa:
|
1 |
> objsize -detail 00007f8a2d012340 |
|
1 2 3 |
sizeof(00007f8a2d012340) = 50,024 bytes (EventSubscriber) sizeof(00007f8a2d012360) = 94 bytes (System.String) [Name] sizeof(00007f8a2d012380) = 50,000 bytes (System.Byte[]) [Payload] |
O comando gchandles
Lista todos os GC Handles (referências que o GC não pode coletar).
|
1 |
> gchandles |
Saída:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Handle Type Object Size Data Type 00007f8a10001008 WeakShort 00007f8a2d100000 120 System.RuntimeType 00007f8a10001010 WeakShort 00007f8a2d100100 120 System.RuntimeType 00007f8a10001018 Strong 00007f8a2d000100 48 EventPublisher 00007f8a10001020 Strong 00007f8a2d200000 10,024 System.Byte[] 00007f8a10001028 Pinned 00007f8a2d300000 4,096 System.Object[] ... Statistics: Strong Handles: 1,234 Pinned Handles: 45 Weak Long Handles: 567 Weak Short Handles: 890 |
Tipos de handles:
| Tipo | Descrição | Impacto em GC |
|---|---|---|
Strong | Referência forte normal | Mantém objeto vivo |
Pinned | Objeto fixo na memória | Mantém vivo + impede movimento |
WeakShort | Referência fraca (curta) | Não mantém vivo |
WeakLong | Referência fraca (longa) | Não mantém vivo, sobrevive a finalização |
O comando dumpasync
Lista todas as state machines de métodos async, útil para diagnosticar tasks pendentes.
|
1 |
> dumpasync |
Saída:
|
1 2 3 4 5 6 7 8 |
Statistics: MT Count TotalSize Class Name 00007f8a1c009a00 123 9,840 System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1+AsyncStateMachineBox`1[[System.Boolean],[Program+<>c__DisplayClass0_0+<<Main>$>d__0]] 00007f8a1c009b20 45 1,800 System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1+AsyncStateMachineBox`1[[System.Int32],[DataProcessor+<ProcessAsync>d__5]] Address MT Size State Description 00007f8a2d400100 00007f8a1c009a00 80 2 Program+<>c__DisplayClass0_0+<<Main>$>d__0 00007f8a2d400200 00007f8a1c009a00 80 1 Program+<>c__DisplayClass0_0+<<Main>$>d__0 |
Estados comuns:
| State | Significado |
|---|---|
-2 | Task criada mas não iniciada |
-1 | Task executando |
≥ 0 | Task em await no ponto N |
O comando threadpool
Informações sobre o ThreadPool, útil para diagnosticar starvation.
|
1 |
> threadpool |
Saída:
|
1 2 3 4 5 6 7 |
CPU utilization: 23% Worker Thread: Total: 8 Running: 2 Idle: 6 MaxLimit: 32767 MinLimit: 4 Work Request in Queue: 0 -------------------------------------- Number of Timers: 500 ← ALERTA: muitos timers! -------------------------------------- Completion Port Thread: Total: 2 Running: 0 Idle: 2 MaxLimit: 1000 MinLimit: 4 |
Análise: 500 Timers ativos indica um possível leak de System.Timers.Timer.
O comando timerinfo
Detalhes sobre todos os timers ativos.
|
1 |
> timerinfo |
Saída:
|
1 2 3 4 5 6 7 |
Address DueTime Period State Callback 00007f8a2d500100 3600000 3600000 0x0 System.Timers.Timer+TimerCallback 00007f8a2d500200 3600000 3600000 0x0 System.Timers.Timer+TimerCallback 00007f8a2d500300 3600000 3600000 0x0 System.Timers.Timer+TimerCallback ... Total: 500 timers |
Análise: 500 timers com período de 3.600.000ms (1 hora) que nunca foram parados.
Diagnóstico Completo — Passo a Passo
Vamos fazer um diagnóstico completo do nosso aplicativo de exemplo.
Passo 1 — Identificar os Tipos Suspeitos
|
1 |
> dumpheap -stat |
|
1 2 3 4 5 |
Statistics: MT Count TotalSize Class Name 00007f8a1c005940 5,000 250,120,000 EventSubscriber ← Suspeito! 00007f8a1c005a60 10,000 100,240,000 Customer ← Suspeito! 00007f8a1c005bc0 500 24,000 System.Timers.Timer ← Suspeito! |
Passo 2 — Examinar Objetos de Cada Tipo
|
1 |
> dumpheap -type EventSubscriber -short |
|
1 2 3 4 |
00007f8a2d012340 00007f8a2d01c5a0 00007f8a2d026800 ... (4997 mais) |
Passo 3 — Rastrear a Raiz de um Objeto
|
1 |
> gcroot 00007f8a2d012340 |
|
1 2 3 4 5 6 7 |
HandleTable: 00007f8a10001020 (strong handle) -> 00007f8a2d000100 EventPublisher -> 00007f8a2d000200 System.EventHandler`1[[System.String]] -> 00007f8a2d012340 EventSubscriber Found 1 unique roots. |
Diagnóstico: EventSubscriber está sendo referenciado pelo evento OnDataReceived do EventPublisher.
Passo 4 — Investigar o Publicador
|
1 |
> dumpobj 00007f8a2d000100 |
|
1 2 3 4 5 |
Name: EventPublisher MethodTable: 00007f8a1c005860 Fields: MT Field Offset Type VT Attr Value Name 00007f8a1c005700 4000010 8 ...EventHandler`1[[System.String]] 0 instance 00007f8a2d000200 OnDataReceived |
|
1 |
> dumpobj 00007f8a2d000200 |
|
1 2 3 4 5 |
Name: System.EventHandler`1[[System.String]] Fields: MT Field Offset Type VT Attr Value Name 00007f8a1c005640 4000001 8 System.Object 0 instance 00007f8a2d000300 _invocationList 00007f8a1c005650 4000002 10 System.IntPtr 1 instance 5000 _invocationCount |
Análise: _invocationCount = 5000 confirma que há 5.000 subscribers registrados no evento!
Passo 5 — Rastrear os Customers
|
1 |
> gcroot 00007f8a2d100000 |
|
1 2 3 4 5 6 7 8 |
HandleTable: 00007f8a10001030 (strong handle) -> 00007f8a2d050000 LeakDemoService -> 00007f8a2d050100 ConcurrentDictionary`2[[Guid],[Customer]] -> 00007f8a2d050200 ConcurrentDictionary`2+Node[[Guid],[Customer]][] -> 00007f8a2d100000 Customer Found 1 unique roots. |
Diagnóstico: Customer está no ConcurrentDictionary do LeakDemoService, que nunca remove itens.
Passo 6 — Verificar Timers
|
1 |
> threadpool |
|
1 |
Number of Timers: 500 |
|
1 |
> dumpheap -type System.Timers.Timer |
|
1 2 3 4 5 6 |
Address MT Size 00007f8a2d600100 00007f8a1c005bc0 48 00007f8a2d600200 00007f8a1c005bc0 48 ... (498 mais) Total 500 objects |
|
1 |
> gcroot 00007f8a2d600100 |
|
1 2 3 4 5 6 7 8 |
Ephemeral Segment: -> 00007f8a2d700100 TimerHolder -> 00007f8a2d600100 System.Timers.Timer Timer queue: -> 00007f8a2d600100 System.Timers.Timer Found 2 unique roots. |
Diagnóstico: Os Timer estão vivos porque:
TimerHolderreferencia o Timer- O Timer está na fila de timers do runtime (porque está rodando)
Soluções para Cada Tipo de Leak
Solução 1 — Event Handlers
Problema: subscriber adicionado a evento e nunca removido.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// ❌ CÓDIGO QUE CAUSA LEAK public void Subscribe(EventSubscriber subscriber) { eventPublisher.OnDataReceived += subscriber.HandleEvent; // Nunca remove! } // ✅ CÓDIGO CORRETO public class ManagedSubscriber : IDisposable { private readonly EventPublisher _publisher; private readonly EventHandler<string> _handler; public ManagedSubscriber(EventPublisher publisher) { _publisher = publisher; _handler = HandleEvent; _publisher.OnDataReceived += _handler; } private void HandleEvent(object? sender, string data) { // Processa evento } public void Dispose() { _publisher.OnDataReceived -= _handler; } } // Uso com using using var subscriber = new ManagedSubscriber(publisher); // Ao sair do escopo, Dispose remove o handler |
Alternativa: Weak Event Pattern
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// Para cenários onde você não controla o ciclo de vida public class WeakEventSubscriber { private readonly WeakReference<EventSubscriber> _weakSubscriber; public WeakEventSubscriber(EventPublisher publisher, EventSubscriber subscriber) { _weakSubscriber = new WeakReference<EventSubscriber>(subscriber); publisher.OnDataReceived += OnEventReceived; } private void OnEventReceived(object? sender, string data) { if (_weakSubscriber.TryGetTarget(out var subscriber)) { subscriber.HandleEvent(sender, data); } else { // Subscriber foi coletado, pode remover este handler if (sender is EventPublisher publisher) { publisher.OnDataReceived -= OnEventReceived; } } } } |
Solução 2 — Cache Sem Expiração
Problema: itens adicionados ao cache nunca expiram.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
// ❌ CÓDIGO QUE CAUSA LEAK private readonly ConcurrentDictionary<Guid, Customer> _cache = new(); public void AddToCache(Customer customer) { _cache[customer.Id] = customer; // Nunca remove! } // ✅ CÓDIGO CORRETO: Usar MemoryCache com expiração using Microsoft.Extensions.Caching.Memory; public class CustomerCacheService { private readonly IMemoryCache _cache; public CustomerCacheService(IMemoryCache cache) { _cache = cache; } public void AddToCache(Customer customer) { var options = new MemoryCacheEntryOptions() .SetSlidingExpiration(TimeSpan.FromMinutes(30)) // Expira se não for acessado .SetAbsoluteExpiration(TimeSpan.FromHours(2)) // Expira no máximo em 2h .SetSize(1) // Para limite de tamanho .RegisterPostEvictionCallback((key, value, reason, state) => { // Log ou limpeza adicional }); _cache.Set(customer.Id, customer, options); } public Customer? GetFromCache(Guid id) { return _cache.Get<Customer>(id); } } // Configuração no DI services.AddMemoryCache(options => { options.SizeLimit = 10_000; // Limite de itens }); |
Alternativa: LRU Cache Manual
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
public class LruCache<TKey, TValue> where TKey : notnull { private readonly int _capacity; private readonly Dictionary<TKey, LinkedListNode<(TKey Key, TValue Value)>> _cache; private readonly LinkedList<(TKey Key, TValue Value)> _lruList; private readonly object _lock = new(); public LruCache(int capacity) { _capacity = capacity; _cache = new Dictionary<TKey, LinkedListNode<(TKey, TValue)>>(capacity); _lruList = new LinkedList<(TKey, TValue)>(); } public void Add(TKey key, TValue value) { lock (_lock) { if (_cache.TryGetValue(key, out var node)) { // Atualiza e move para o início _lruList.Remove(node); node.Value = (key, value); _lruList.AddFirst(node); } else { // Verifica capacidade if (_cache.Count >= _capacity) { // Remove o menos recentemente usado (último) var lru = _lruList.Last!; _cache.Remove(lru.Value.Key); _lruList.RemoveLast(); } // Adiciona no início var newNode = _lruList.AddFirst((key, value)); _cache[key] = newNode; } } } public bool TryGet(TKey key, out TValue? value) { lock (_lock) { if (_cache.TryGetValue(key, out var node)) { // Move para o início (mais recentemente usado) _lruList.Remove(node); _lruList.AddFirst(node); value = node.Value.Value; return true; } value = default; return false; } } } |
Solução 3 — Closures Capturando this
Problema: lambda captura this e Timer mantém referência.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
// ❌ CÓDIGO QUE CAUSA LEAK public class DataProcessor { private readonly byte[] _largeData = new byte[100_000]; private Timer? _timer; public void ProcessWithLeak() { _timer = new Timer(60_000); // Lambda captura 'this' implicitamente por usar _largeData _timer.Elapsed += (sender, e) => { var length = _largeData.Length; // Captura 'this'! Console.WriteLine($"Length: {length}"); }; _timer.Start(); } } // ✅ CÓDIGO CORRETO: Implementar IDisposable public class DataProcessor : IDisposable { private readonly byte[] _largeData = new byte[100_000]; private Timer? _timer; private bool _disposed; public void Process() { _timer = new Timer(60_000); _timer.Elapsed += OnTimerElapsed; _timer.Start(); } private void OnTimerElapsed(object? sender, ElapsedEventArgs e) { if (_disposed) return; var length = _largeData.Length; Console.WriteLine($"Length: {length}"); } public void Dispose() { if (_disposed) return; _disposed = true; if (_timer != null) { _timer.Stop(); _timer.Elapsed -= OnTimerElapsed; _timer.Dispose(); _timer = null; } } } // Uso correto using var processor = new DataProcessor(); processor.Process(); // Timer é parado e liberado quando processor sai do escopo |
Alternativa: Capturar apenas o necessário
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// Se você não pode implementar IDisposable, capture apenas valores public void ProcessWithoutLeak() { // Captura apenas o valor, não o array inteiro int dataLength = _largeData.Length; _timer = new Timer(60_000); _timer.Elapsed += (sender, e) => { // Usa dataLength (int), não _largeData (array) Console.WriteLine($"Length: {dataLength}"); }; _timer.Start(); } |
Solução 4 — CancellationTokenSource
Problema: CTS criado mas nunca disposed.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
// ❌ CÓDIGO QUE CAUSA LEAK public async Task ProcessAsync() { // CTS nunca é disposed var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); await DoWorkAsync(linkedCts.Token); // cts e linkedCts vazam! } // ✅ CÓDIGO CORRETO public async Task ProcessAsync() { using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); await DoWorkAsync(linkedCts.Token); // Ambos são disposed automaticamente } // Para cenários mais complexos public async Task ProcessWithTimeoutAsync(CancellationToken externalToken) { // Cria CTS que cancela após timeout OU quando externalToken cancela using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( externalToken, timeoutCts.Token); try { await DoWorkAsync(linkedCts.Token); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { // Timeout throw new TimeoutException("Operation timed out"); } } |
Solução 5 — Timers
Problema: Timer criado e nunca parado/disposed.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
// ❌ CÓDIGO QUE CAUSA LEAK public class TimerHolder { private readonly Timer _timer; public TimerHolder() { _timer = new Timer(30_000); _timer.Elapsed += OnElapsed; _timer.Start(); // Timer nunca é parado! } private void OnElapsed(object? sender, ElapsedEventArgs e) { } } // ✅ CÓDIGO CORRETO public class TimerHolder : IDisposable { private readonly Timer _timer; private bool _disposed; public TimerHolder() { _timer = new Timer(30_000); _timer.Elapsed += OnElapsed; } public void Start() => _timer.Start(); public void Stop() => _timer.Stop(); private void OnElapsed(object? sender, ElapsedEventArgs e) { if (_disposed) return; // Trabalho do timer } public void Dispose() { if (_disposed) return; _disposed = true; _timer.Stop(); _timer.Elapsed -= OnElapsed; _timer.Dispose(); } } // Alternativa: usar PeriodicTimer (.NET 6+) public class ModernTimerHolder : IAsyncDisposable { private readonly PeriodicTimer _timer; private readonly CancellationTokenSource _cts; private Task? _backgroundTask; public ModernTimerHolder(TimeSpan interval) { _timer = new PeriodicTimer(interval); _cts = new CancellationTokenSource(); } public void Start() { _backgroundTask = RunAsync(_cts.Token); } private async Task RunAsync(CancellationToken cancellationToken) { try { while (await _timer.WaitForNextTickAsync(cancellationToken)) { // Trabalho do timer } } catch (OperationCanceledException) { // Cancelamento esperado } } public async ValueTask DisposeAsync() { _cts.Cancel(); if (_backgroundTask != null) { await _backgroundTask; } _timer.Dispose(); _cts.Dispose(); } } |
Fluxo Completo de Diagnóstico
flowchart TB
A["Suspeita de leak (memória crescendo)"] --> B["dotnet-counters — confirmar crescimento do heap"]
B --> C{"Comportamento"}
C -->|"Heap cresce, GC não ajuda"| LEAK["LEAK"]
C -->|"Heap estável após GC"| NORMAL["Normal"]
C -->|"Heap cresce + OOM com RAM livre"| FRAG["Fragmentação"]
LEAK --> Q["dotnet-gcdump collect + report (QUAIS tipos)"]
Q --> R["dotnet-dump analyze"]
R --> R1["dumpheap -stat (O QUE vaza)"]
R --> R2["gcroot address (POR QUE vivo)"]
R --> R3["threadpool / timerinfo (timers ativos)"]
R1 --> SOL["Implementar solução"]
R2 --> SOL
R3 --> SOL
SOL --> FIX["handlers IDisposable+unsubscribe / caches expiração+limite / closures capturar valores / CTS using / timers Stop+Dispose"]Comparação de Dumps — Técnica Diff
Uma técnica poderosa é comparar dois dumps capturados em momentos diferentes.
Capturar Dump “Antes” (Baseline)
|
1 2 |
# Logo após o startup, quando a aplicação está "limpa" dotnet-gcdump collect -p 12345 -o baseline.gcdump |
Capturar Dump “Depois” (Com Leak)
|
1 2 |
# Depois de usar a aplicação por um tempo dotnet-gcdump collect -p 12345 -o afterleak.gcdump |
Comparar no Visual Studio (Windows)
- Abra ambos os arquivos
.gcdumpno Visual Studio - Na janela do segundo dump, clique em “Compare to baseline”
- Selecione o primeiro dump
O Visual Studio mostra:
- Tipos que cresceram
- Diferença de contagem e tamanho
- Novos tipos que apareceram
Comparar via Script (Cross-Platform)
|
1 2 3 4 5 6 7 8 9 |
# Gerar relatórios de ambos dotnet-gcdump report baseline.gcdump > baseline.txt dotnet-gcdump report afterleak.gcdump > afterleak.txt # Comparar (Linux/macOS) diff baseline.txt afterleak.txt # Comparar (Windows PowerShell) Compare-Object (Get-Content baseline.txt) (Get-Content afterleak.txt) |
Diagnóstico em Containers e Kubernetes
Capturar Dump em Container Docker
|
1 2 3 4 5 6 7 8 |
# Encontrar o container docker ps # Executar dotnet-dump dentro do container docker exec -it <container_id> dotnet-dump collect -p 1 # Copiar o dump para fora docker cp <container_id>:/app/core_20260210_150000 ./dump.dmp |
Dockerfile com Ferramentas de Diagnóstico
|
1 2 3 4 5 6 7 8 9 10 11 12 |
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base # Instalar ferramentas de diagnóstico RUN dotnet tool install --tool-path /tools dotnet-dump RUN dotnet tool install --tool-path /tools dotnet-gcdump RUN dotnet tool install --tool-path /tools dotnet-counters ENV PATH="/tools:${PATH}" WORKDIR /app COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "MyApp.dll"] |
Kubernetes — Capturar Dump de Pod
|
1 2 3 4 5 6 7 8 |
# Encontrar o pod kubectl get pods # Executar dotnet-dump no pod kubectl exec -it <pod-name> -- dotnet-dump collect -p 1 # Copiar o dump kubectl cp <pod-name>:/app/core_20260210_150000 ./dump.dmp |
Sidecar para Diagnóstico Contínuo
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
apiVersion: v1 kind: Pod metadata: name: myapp-with-diagnostics spec: shareProcessNamespace: true # Importante! containers: - name: myapp image: myapp:latest ports: - containerPort: 80 - name: diagnostics image: mcr.microsoft.com/dotnet/sdk:10.0 command: ["sleep", "infinity"] securityContext: capabilities: add: ["SYS_PTRACE"] # Necessário para attach |
Automação — Script de Diagnóstico
Script completo para automatizar a coleta e análise inicial:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
#!/bin/bash # diagnose-memory.sh - Script de diagnóstico de memória para .NET set -e # Configurações PROCESS_NAME="${1:-dotnet}" OUTPUT_DIR="${2:-./diagnostics}" TIMESTAMP=$(date +%Y%m%d_%H%M%S) echo "=== Diagnóstico de Memória .NET ===" echo "Processo: $PROCESS_NAME" echo "Output: $OUTPUT_DIR" echo "" # Criar diretório de saída mkdir -p "$OUTPUT_DIR" # Encontrar PID PID=$(pgrep -f "$PROCESS_NAME" | head -1) if [ -z "$PID" ]; then echo "❌ Processo '$PROCESS_NAME' não encontrado" exit 1 fi echo "✓ PID encontrado: $PID" # Coletar métricas atuais echo "" echo "=== Métricas Atuais ===" dotnet-counters collect -p "$PID" --duration 00:00:05 \ --output "$OUTPUT_DIR/counters_$TIMESTAMP.csv" \ --format csv \ --counters System.Runtime # Mostrar métricas relevantes echo "" cat "$OUTPUT_DIR/counters_$TIMESTAMP.csv" | grep -E "(gc-heap-size|gen-0-gc-count|gen-1-gc-count|gen-2-gc-count)" | tail -1 # Coletar GC dump (leve) echo "" echo "=== Coletando GC Dump ===" dotnet-gcdump collect -p "$PID" -o "$OUTPUT_DIR/gcdump_$TIMESTAMP.gcdump" # Gerar relatório echo "" echo "=== Relatório de Heap ===" dotnet-gcdump report "$OUTPUT_DIR/gcdump_$TIMESTAMP.gcdump" | head -20 # Coletar dump completo (opcional, comentado por padrão) # echo "" # echo "=== Coletando Dump Completo ===" # dotnet-dump collect -p "$PID" -o "$OUTPUT_DIR/dump_$TIMESTAMP" echo "" echo "=== Diagnóstico Concluído ===" echo "Arquivos gerados em: $OUTPUT_DIR" ls -la "$OUTPUT_DIR" |
Uso:
|
1 2 |
chmod +x diagnose-memory.sh ./diagnose-memory.sh MemoryLeakDemo ./diagnostics |
Tabela de Referência — Comandos SOS
| Comando | Descrição | Uso Comum |
|---|---|---|
dumpheap -stat | Estatísticas de heap | Identificar tipos que ocupam mais memória |
dumpheap -mt <MT> | Listar objetos por MethodTable | Ver instâncias de um tipo |
dumpheap -type <name> | Listar objetos por nome | Filtrar por substring do tipo |
dumpheap -min <bytes> | Objetos maiores que N | Encontrar objetos grandes |
dumpheap -gen <0/1/2> | Objetos por geração | Analisar objetos de longa vida (Gen2) |
dumpobj <addr> | Examinar objeto | Ver campos e valores |
gcroot <addr> | Cadeia de referências | Descobrir por que objeto está vivo |
objsize <addr> | Tamanho transitivo | Tamanho total incluindo referências |
gchandles | Listar GC handles | Encontrar strong/pinned handles |
threadpool | Info do ThreadPool | Verificar timers ativos |
timerinfo | Listar timers | Identificar timers não parados |
dumpasync | State machines async | Analisar tasks pendentes |
finalizequeue | Fila de finalização | Objetos aguardando finalização |
eeheap -gc | Estrutura do heap GC | Segmentos e gerações |
clrstack | Stack da thread atual | Ver onde o código está |
clrthreads | Listar threads | Visão geral de threads |
pe / printexception | Última exceção | Diagnóstico de crashes |
FAQ – Perguntas Frequentes
1. Quando devo usar dotnet-dump vs dotnet-gcdump?
dotnet-gcdump é ideal para:
- Diagnóstico inicial e rápido
- Produção com baixo impacto
- Comparação antes/depois
- Identificar quais tipos estão crescendo
dotnet-dump é necessário quando:
- Você precisa do comando
gcrootpara entender por que objetos estão vivos - Precisa analisar threads, stacks, handles
- Diagnóstico profundo após identificar suspeitos com gcdump
Fluxo recomendado: comece com dotnet-gcdump para triagem, depois use dotnet-dump para investigação detalhada.
2. O dump captura o estado exato ou pode ter inconsistências?
O dotnet-dump captura um snapshot consistente do processo. Durante a captura:
- O processo é brevemente pausado (fração de segundo)
- Todas as threads são suspensas
- O estado é capturado atomicamente
O dotnet-gcdump usa EventPipe e pode ter pequenas inconsistências porque não pausa completamente o processo, mas é suficientemente preciso para diagnóstico de leaks.
3. Como diferenciar um leak real de uso legítimo de memória?
Técnica do Diff: capture dois dumps em momentos diferentes e compare:
- Se os mesmos tipos crescem proporcionalmente ao uso → provavelmente legítimo
- Se tipos crescem mesmo sem uso correspondente → provavelmente leak
Análise de gcroot: se o objeto está referenciado por:
- Caches, pools, ou estruturas de negócio → pode ser legítimo
- Eventos estáticos, delegates órfãos, timers esquecidos → provavelmente leak
Forçar GC: execute GC.Collect() e observe se a memória diminui:
- Diminuiu significativamente → não era leak, eram objetos elegíveis
- Não diminuiu → leak confirmado
4. Posso analisar dumps de Linux no Windows (e vice-versa)?
dotnet-dump: Sim, dumps são portáveis entre plataformas desde que você use a mesma versão do .NET (ou compatível).
|
1 2 3 4 5 |
# Capturar no Linux dotnet-dump collect -p 12345 # Analisar no Windows dotnet-dump analyze ./core_20260210_150000 |
dotnet-gcdump: Arquivos .gcdump podem ser gerados em qualquer plataforma, mas a análise visual (Visual Studio, PerfView) está disponível apenas no Windows.
5. Como automatizar a detecção de leaks em CI/CD?
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// Teste de integração que verifica leak [Fact] public async Task Should_Not_Leak_Memory_Under_Load() { // Arrange var initialMemory = GC.GetTotalMemory(true); // Act - Simular carga for (int i = 0; i < 1000; i++) { using var scope = _serviceProvider.CreateScope(); var service = scope.ServiceProvider.GetRequiredService<MyService>(); await service.ProcessAsync(); } // Forçar coleta GC.Collect(2, GCCollectionMode.Forced, true, true); GC.WaitForPendingFinalizers(); GC.Collect(2, GCCollectionMode.Forced, true, true); var finalMemory = GC.GetTotalMemory(true); // Assert - Memória não deve crescer mais que 10% var growth = (double)(finalMemory - initialMemory) / initialMemory; Assert.True(growth < 0.10, $"Memory grew by {growth:P2}"); } |