Cómo distinguir la espera de GC de una fuga de memoria en .NET — Procedimiento práctico para observar, comparar y demostrar el crecimiento de memoria
· Actualizado el: · Go Komura · .NET, CSharp, GC, MemoryLeak, Diagnostics, dotnet-counters, dotnet-dump, Operaciones, Aprovechamiento de activos existentes
1. Lo primero que hay que tener claro
Cuando se opera una aplicación .NET, hay situaciones en las que el uso de memoria aumenta poco a poco.
Al mirar el Administrador de tareas o top, la memoria del proceso está aumentando.
El uso de memoria del contenedor también aumenta.
En el monitoreo, las gráficas de Working Set o RSS muestran una tendencia ascendente.
Al ver este estado, es natural pensar de inmediato «¿no será una fuga de memoria?». Sin embargo, en .NET, que la memoria del proceso esté aumentando y que exista una fuga de memoria no son lo mismo.
.NET cuenta con recolección de basura (garbage collection). En el instante en que un objeto deja de ser necesario, la memoria no vuelve de inmediato al sistema operativo. El GC actúa observando la situación de las asignaciones, los umbrales del heap, la presión de memoria, las generaciones y la carga de trabajo.
Por eso pueden darse situaciones como estas:
- Un objeto que ya no es necesario todavía no ha sido recolectado por el GC.
- El GC ya se ejecutó, pero el Working Set del proceso no baja de inmediato.
- La memoria aumenta una sola vez por el primer acceso, el JIT, la caché o el connection pool, y después se estabiliza.
- El heap administrado (managed heap) está estable, pero la memoria nativa, los subprocesos, los sockets o las bibliotecas de procesamiento de imágenes están aumentando.
- Un objeto que realmente ya no debería ser necesario sigue siendo referenciado desde algún lugar.
Este artículo trata sobre cómo distinguir ese último caso: cuándo se trata de una fuga real. Lo que hay que observar no es simplemente el uso de memoria, sino estos tres puntos:
- Si la memoria que sobrevive después del GC está aumentando.
- Qué tipo es el que está aumentando.
- Quién hace referencia a ese objeto.
Investigar una fuga de memoria en .NET no consiste en quedarse en «la memoria está aumentando», sino en llegar hasta «los objetos de este tipo están aumentando y siguen siendo referenciados desde esta ruta».
Además, el código que aparece en este artículo se publica en GitHub como un conjunto de muestras que se pueden compilar y ejecutar (una biblioteca con los patrones típicos de fuga, una demostración para observar la diferencia entre la espera del GC y la supervivencia de objetos, y pruebas unitarias con WeakReference para verificar la retención y la recolección).
dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)
2. Primero, unifiquemos qué significa «fuga de memoria»
En .NET, una fuga de memoria no se limita a la forma clásica de C o C++, es decir, «olvidarse de liberar la memoria reservada».
En código administrado, es el GC quien recolecta los objetos. Que el GC pueda recolectar un objeto o no depende de si todavía queda alguna referencia alcanzable hacia ese objeto.
Es decir, la fuga de memoria típica en .NET es así:
Un estado en el que, aunque el objeto ya no es necesario desde el punto de vista del negocio, sigue siendo referenciado desde un campo static, una caché, un evento, un Timer, una colección, el tiempo de vida de la inyección de dependencias o un contexto asíncrono, de modo que, desde la perspectiva del GC, todavía parece estar en uso.
El GC es inteligente, pero no sabe si algo es innecesario desde el punto de vista del negocio. Si está siendo referenciado, lo considera vivo.
Por eso, en .NET resulta más útil pensar en «retención no intencionada» que en «fuga», en sentido estricto.
Por otro lado, los siguientes estados no se pueden calificar de inmediato como fuga de memoria:
| Estado | Por qué no necesariamente es una fuga |
|---|---|
| El Working Set / RSS está aumentando | Es memoria que el sistema operativo asignó al proceso, y no coincide necesariamente con la cantidad de objetos vivos en el heap administrado |
| El Total Allocated está aumentando | Es la cantidad acumulada asignada desde el arranque, así que, mientras la aplicación funcione, básicamente siempre aumenta |
| El GC Heap aumenta momentáneamente | Puede que solo sean objetos aún no recolectados que quedan hasta el próximo GC |
| Aumenta justo después del arranque | Es habitual por el JIT, la carga de tipos, la caché inicial, el connection pool o la expansión de plantillas |
| El LOH es grande | Puede deberse a la reutilización de arreglos o búferes grandes, a la fragmentación o a la estrategia de pooling |
| La memoria no baja | Aunque el GC recolecte, no siempre el proceso devuelve la memoria al sistema operativo de inmediato |
En cambio, cuantos más de los siguientes estados se den a la vez, más fuerte es la sospecha de una fuga de memoria:
| Observación | Significado |
|---|---|
| Cada vez que se repite la misma operación, el heap después del GC aumenta | Está aumentando la cantidad de objetos que sobreviven |
| El tamaño de Gen 2 o del LOH sigue aumentando | Hay objetos de vida larga, o bien objetos grandes, que permanecen |
| En varios volcados (dumps), el Count / Size del mismo tipo sigue aumentando | Se puede identificar el tipo que está creciendo |
Con gcroot se ven referencias desde un static, un evento, una caché o un servicio de larga vida |
Se puede explicar por qué el GC no puede recolectar |
| Aunque se detenga la carga, no vuelve al valor anterior tras esperar lo suficiente o tras un GC de verificación | Es probable que no se trate de una simple asignación temporal |
3. Separar «de qué memoria» se está hablando
Lo primero que confunde en una investigación de memoria es que se mezclan indicadores de memoria distintos. Aunque todos se llamen «memoria», su significado es diferente.
| Indicador | Qué observa | Cómo interpretarlo |
|---|---|---|
| Working Set / RSS | Las páginas del proceso que están cargadas en memoria física | Es una medida desde el punto de vista del sistema operativo; no es el GC heap en sí |
| Private Bytes / Commit | La memoria confirmada (committed) que el proceso posee de forma privada | Incluye memoria nativa, pilas de subprocesos, código JIT, segmentos del GC, etc. |
| GC Heap Size | La cantidad de objetos en el heap administrado | Es el punto de partida para ver la memoria que gestiona el GC de .NET |
| Total Allocated | La cantidad acumulada asignada desde el arranque | Básicamente siempre aumenta; no se usa solo por sí mismo para determinar una fuga |
| Gen 0 / Gen 1 / Gen 2 | El heap dividido por generación | Lo que permanece en Gen 2 tiene vida larga |
| LOH | El heap donde se alojan los objetos grandes de 85,000 bytes o más | Tiende a crecer con arreglos, cadenas o búferes grandes |
| POH | El heap para objetos fijados (pinned) | Es una pista para ver el efecto de la interoperabilidad nativa o de la fijación de memoria |
| Finalization Queue | Objetos que esperan ser finalizados | Es una pista para detectar fugas de Dispose o cuellos de botella en el finalizador |
Si se representa en un diagrama qué observa cada indicador, queda así:
flowchart TB
accTitle: Relación entre los indicadores de memoria de un proceso .NET
accDescr: Diagrama que muestra cómo la memoria de un proceso se divide en el lado administrado (Gen 0/1, Gen 2, LOH, POH) y el lado nativo (pila de hilos, código JIT, buffers de interoperabilidad), y cómo ambos se contabilizan en Private Bytes/Commit y, según lo que esté cargado en memoria física, en Working Set/RSS
PROC["Memoria que usa el proceso"] --> MANAGED["Lado administrado<br/>Rango visible en GC Heap Size"]
PROC --> NATIVE["Lado nativo<br/>Rango que no aparece en GC Heap Size"]
MANAGED --> G01["Gen 0 / Gen 1<br/>Asignaciones de vida corta"]
MANAGED --> G2["Gen 2<br/>Objetos de vida larga que sobrevivieron"]
MANAGED --> LOH["LOH<br/>Objetos grandes de 85,000 bytes o más"]
MANAGED --> POH["POH<br/>Objetos fijados (pinned)"]
NATIVE --> STK["Pila de subprocesos"]
NATIVE --> JITC["Código JIT, ensamblados cargados"]
NATIVE --> INTEROP["Buffers de P/Invoke, COM y bibliotecas externas"]
MANAGED -.se contabiliza.-> COMMIT["Private Bytes / Commit<br/>Memoria confirmada que el proceso posee de forma privada"]
NATIVE -.se contabiliza.-> COMMIT
COMMIT -.solo la parte cargada en memoria física.-> WS["Working Set / RSS"]
Figura: relación entre los distintos indicadores de memoria de un proceso .NET.
En este diagrama conviene retener dos puntos:
- Con
dumpheapsolo se ve el lado administrado. Si lo que está aumentando es el lado nativo, por mucho que se examine el heap, el responsable no aparecerá. - El Working Set y el Commit no tienen una relación de anidamiento. Aunque algo esté confirmado (committed), si no está cargado en memoria física no aparece en el Working Set; y a la inversa, cosas que no son privadas, como las páginas de una biblioteca compartida, pueden contarse en el Working Set. Por eso no se puede afirmar que «el GC no ha recolectado» solo porque el Working Set no baje.
No hace falta examinarlo todo en detalle desde el principio. Primero se descompone en las siguientes preguntas:
La memoria del proceso está aumentando
↓
¿El heap administrado también está aumentando?
↓
¿Está aumentando la cantidad que sobrevive después del GC?
↓
¿Qué tipo está aumentando?
↓
¿Quién lo está referenciando?
Si se sigue este orden, resulta más difícil confundir «un aumento aparente de memoria» con «una fuga real».
4. Flujo de decisión
En la práctica, resulta más fácil avanzar si se separa el problema con el siguiente flujo.
1. Fijar las condiciones de reproducción
- En qué API, pantalla, job o batch aumenta
- Cuántas ejecuciones hacen falta para que aumente
- Qué ocurre al detener la carga
2. Ver la tendencia con dotnet-counters
- Working Set
- GC Heap
- Gen 2 / LOH
- Total Allocated
- Número de GC
3. Comparar en el tiempo
- Justo después del arranque
- Después del warm-up
- Durante la carga
- Después de detener la carga
- Después de repetir la misma operación N veces
4. Tomar dos o más volcados
- before
- after
- Si es posible, también después de detener la carga
5. Buscar el tipo que aumenta
- dumpheap -stat
- gcdump report
- Visual Studio / PerfView
6. Confirmar el origen de las referencias
- gcroot
- gchandles
- finalizequeue
7. Emitir un juicio
- Espera de GC
- Aumento normal de la caché
- Fuga de memoria administrada
- Problema de memoria nativa
- Fragmentación del LOH o asignación grande temporal
Lo importante es no juzgar con un solo valor. Una fuga de memoria es «una tendencia que sigue creciendo», así que no se compara un único punto, sino una serie en el tiempo bajo las mismas condiciones.
5. Herramientas que se usan
En este artículo se usan principalmente las siguientes herramientas.
| Herramienta | Dónde se usa |
|---|---|
dotnet-counters |
Para ver la tendencia del GC y del Working Set de un proceso en ejecución |
dotnet-gcdump |
Para obtener, de forma ligera, estadísticas de los objetos administrados que están vivos |
dotnet-dump |
Para examinar el heap en detalle y, con dumpheap y gcroot, llegar hasta el origen de las referencias |
| Visual Studio Memory Usage | Para comparar con una interfaz gráfica en Windows |
| PerfView | Para examinar en profundidad el GC, el heap y las trazas en Windows |
dotnet-trace |
Para seguir en el tiempo las asignaciones y los eventos del GC |
Primero se instalan las herramientas de línea de comandos.
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace
Si ya están instaladas, se actualizan.
dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace
Se busca el proceso que se va a investigar.
dotnet-counters ps
En los ejemplos siguientes, el ID del proceso objetivo se escribe como <PID>.
En Linux, macOS o entornos en contenedores, la herramienta de diagnóstico y el proceso objetivo deben ejecutarse con el mismo usuario. Además, según el entorno, puede verse afectado por TMPDIR, el puerto de diagnóstico o el espacio de nombres de PID del contenedor.
Si se va a ejecutar en producción, no se debe tomar un volcado directamente: primero se debe comprobar la carga y el impacto en un entorno de pruebas.
5.1 Cuando el sistema investigado es .NET Framework 4.x
dotnet-counters, dotnet-dump y dotnet-gcdump son herramientas que usan las funciones de diagnóstico presentes en el runtime desde .NET Core 3.0 en adelante. Si la aplicación que se investiga es de .NET Framework 4.x, no se pueden usar. Este es el caso cuando lo que se mantiene es una aplicación existente de Windows Forms, WPF o ASP.NET.
La sustitución se plantea con esta correspondencia.
| Herramienta de este artículo | Alternativa en .NET Framework 4.x |
|---|---|
Ver la tendencia con dotnet-counters |
El Monitor de rendimiento, o Get-Counter sobre los contadores de la categoría .NET CLR Memory |
Comparar estadísticas de tipos con dotnet-gcdump |
El volcado del GC heap de PerfView, o comparar instantáneas con «Uso de memoria» de Visual Studio |
Tomar un volcado con dotnet-dump collect |
ProcDump, la opción «Crear archivo de volcado» del Administrador de tareas, o la configuración de informe de errores de Windows |
dumpheap / gcroot con dotnet-dump analyze |
Ejecutar .loadby sos clr en WinDbg y luego usar !dumpheap -stat o !gcroot |
Seguir asignaciones con dotnet-trace |
La recolección de asignaciones del GC heap de PerfView |
El enfoque es exactamente el mismo: los tres pasos de «ver la tendencia», «comparar dos veces las estadísticas por tipo» y «rastrear el origen de las referencias». Solo cambian las herramientas.
Si se observa con el Monitor de rendimiento, dentro de la categoría .NET CLR Memory, los contadores que conviene mirar primero son estos.
| Contador | Qué observa |
|---|---|
# Bytes in all Heaps |
La suma de Gen 1, Gen 2 y LOH. Es cercano a lo que este artículo llama GC Heap Size |
Gen 2 heap size |
El tamaño actual en bytes de Gen 2. Si sigue aumentando, hay que sospechar de una fuga |
Large Object Heap size |
El tamaño actual del LOH |
# Gen 2 Collections |
El número de GC completos. Si aumenta bruscamente, hay que sospechar de un exceso de asignaciones |
% Time in GC |
El porcentaje de tiempo dedicado al GC en los ciclos recientes |
Finalization Survivors |
El número de objetos que sobrevivieron en espera de finalización. Es una pista de fugas por Dispose no llamado |
# Total committed Bytes |
La cantidad de memoria virtual confirmada por el GC |
Los nombres de los contadores pueden mostrarse en japonés en entornos localizados en japonés. Si no los encuentra, en lugar de buscar solo por el nombre en inglés, busque también nombres de categoría localizados, como .NET CLR メモリ.
Cuando se analiza con WinDbg, el punto de entrada es cargar SOS.
0:000> .loadby sos clr
0:000> !dumpheap -stat
0:000> !gcroot <OBJECT_ADDRESS>
En .NET Framework, los comandos de SOS llevan el prefijo !. Los comandos dumpheap -stat y gcroot que aparecen a partir del capítulo 9 de este artículo se pueden usar tal cual siguiendo el mismo procedimiento, sustituyéndolos por !dumpheap -stat y !gcroot.
6. Primero, observar la tendencia con dotnet-counters
Lo primero que hay que observar no es un volcado detallado, sino la tendencia.
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime
La salida varía un poco según la versión de .NET.
En .NET 9 y versiones posteriores puede mostrarse con los nombres de Meter de System.Runtime, y en .NET 8 y versiones anteriores, con los nombres tradicionales de EventCounter.
Lo que se observa principalmente son estos elementos.
| Elemento a observar | Qué se mide |
|---|---|
dotnet.process.memory.working_set |
La memoria residente del proceso desde el punto de vista del sistema operativo |
dotnet.gc.last_collection.heap.size |
El tamaño del heap por generación después del último GC |
dotnet.gc.last_collection.memory.committed_size |
La cantidad de memoria confirmada (committed) por el GC |
dotnet.gc.heap.total_allocated |
La cantidad acumulada asignada desde el arranque |
dotnet.gc.collections |
El número de GC por generación |
dotnet.gc.pause.time |
El tiempo acumulado de pausa del GC |
También se puede monitorear filtrando solo los elementos de interés.
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]
Si se quiere revisar más adelante, se guarda en CSV.
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output counters.csv \
--counters System.Runtime
En este punto, lo que interesa observar son las siguientes diferencias.
6.1 Solo aumenta el Total Allocated
dotnet.gc.heap.total_allocated es un valor acumulado. Si la aplicación procesa solicitudes, asigna objetos, y aunque esos objetos dejen de ser necesarios enseguida y el GC los recolecte, la cantidad acumulada de asignación sigue aumentando.
Por eso, que el Total Allocated aumente por sí solo no significa que haya una fuga de memoria. Lo que hay que observar es si algo permanece después de haber sido asignado.
Total Allocated: aumenta
GC Heap Size: se estabiliza subiendo y bajando dentro de un rango
Gen 2 / LOH: no sigue aumentando
En este caso, más que una fuga, se trata de una aplicación con un volumen de asignación alto.
La solución no es corregir una fuga, sino reducir las asignaciones, reutilizar búferes, revisar el uso excesivo de LINQ, reducir la generación de cadenas o revisar el procesamiento de serialización.
6.2 El Working Set aumenta pero el GC Heap se mantiene estable
Puede ocurrir que el Working Set o el RSS aumenten mientras el GC Heap permanece estable. En ese caso, no necesariamente se trata de una fuga de objetos administrados.
Los factores posibles son estos:
- Código generado por el JIT
- Ensamblados cargados
- Pilas de subprocesos
- Memoria de bibliotecas nativas
- Memoria no administrada, como la creada con
Marshal.AllocHGlobal - Búferes del lado nativo de imágenes, compresión, cifrado o drivers de bases de datos
- Búferes internos de sockets, manejadores de archivo, SSL, HTTP/2 o gRPC
- Que el sistema operativo simplemente no haya liberado todavía las páginas físicas del proceso
En este estado, por mucho que se examine dumpheap, a veces no se encuentra al responsable.
El criterio de referencia es este:
Working Set / RSS: aumenta
GC Heap Size: estable
Gen 2 / LOH: estable
En este caso, se debe sospechar no de una fuga en el managed heap de .NET, sino de memoria nativa, manejadores, número de subprocesos, sockets o bibliotecas externas.
No basta con quedarse en dotnet-counters: también hay que revisar herramientas del sistema operativo, métricas del contenedor, número de manejadores, número de subprocesos, el heap nativo y las métricas de las bibliotecas externas.
6.3 El GC Heap aumenta, pero vuelve al detener la carga
Es normal que el GC Heap aumente durante la carga.
Hay muchas solicitudes. Hay muchos objetos temporales. Se maneja JSON grande. Se crean listas o arreglos de forma temporal.
En estos casos, el heap aumenta hasta el siguiente GC. Al detener la carga, el GC se ejecuta y el heap puede volver a su valor anterior.
Durante la carga: el GC Heap aumenta
Tras detener la carga: el GC Heap baja, o vuelve a un valor estable
Tras repetir: la línea base no sigue aumentando
En este caso, se puede concluir que «simplemente no se ha recolectado todavía» o que «hay muchas asignaciones temporales».
Sin embargo, si las asignaciones temporales durante la carga son excesivas, el número de GC y el tiempo de pausa aumentan, y eso se convierte en un problema de rendimiento. Aunque no sea una fuga, puede ser objeto de una mejora de rendimiento.
6.4 El Gen 2 / LOH después del GC sigue aumentando
El patrón al que hay que prestar atención es este:
Se repite la misma operación
↓
Gen 2 aumenta
↓
El LOH aumenta
↓
No vuelve aunque se detenga la carga
↓
En la siguiente medición, sigue aumentando aún más
Gen 2 es la generación donde se alojan los objetos de vida larga. El LOH es un heap donde suelen alojarse arreglos o cadenas grandes.
Si esto sigue aumentando, hay que sospechar de una fuga, una caché sin límite, la retención de búferes enormes, la falta de cancelación de suscripción a eventos, colecciones static o la retención por parte de un servicio de vida larga.
En esta etapa, se avanza al siguiente paso.
7. Cómo comprobar si es «espera de GC»
Para ver si «todavía no se ha recolectado, simplemente», hay que observar el estado después de haber dado suficientes oportunidades de GC.
Sin embargo, no se debe introducir GC.Collect() a la ligera en código de producción.
GC.Collect() fuerza una recolección. En particular, una GC bloqueante de todas las generaciones genera tiempo de pausa en la aplicación. En una operación normal, lo básico es dejarle el trabajo al GC.
Aun así, en una investigación, a veces se usa en un entorno de pruebas controlado para ver «si algo permanece incluso después de una GC forzada».
En una aplicación de consola de pruebas o en un entorno de reproducción, se puede comprobar el estado tras una GC completa con un código como este.
static void ForceFullGcForDiagnosticsOnly()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
El punto clave es no usar esto como solución. Es únicamente para investigación.
Lo que se quiere comprobar es este flujo:
Antes de la operación
↓
Repetir la operación N veces
↓
Detener la carga
↓
Esperar lo suficiente, o forzar una GC completa en el entorno de pruebas
↓
¿El heap después del GC vuelve a un valor cercano al de antes de la operación?
Si vuelve, es probable que se trate de espera del GC o de asignaciones temporales. Si no vuelve, y la línea base sube cada vez que se repite la misma operación, algo está sobreviviendo. Ese «algo» se busca con un volcado.
8. Comparar de forma ligera con dotnet-gcdump
Para la primera comparación, dotnet-gcdump resulta práctico.
dotnet-gcdump sirve para obtener un GC dump de un proceso .NET en ejecución y ver estadísticas por tipo dentro del heap.
dotnet-gcdump collect --process-id <PID> --output before.gcdump
Después de aplicar carga, se toma otro.
dotnet-gcdump collect --process-id <PID> --output after.gcdump
También se puede ver un informe sencillo desde la CLI.
dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump > after-heap.txt
Lo que hay que observar es el Count y el Size de cada tipo.
Por ejemplo, si en after los siguientes tipos han aumentado mucho, se convierten en objeto de investigación.
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[]
Lo importante no es el «tipo grande», sino el «tipo que aumentó».
System.String o System.Byte[] aparecen en los primeros puestos en muchas aplicaciones.
Que estén en los primeros puestos no significa necesariamente que sean los responsables.
Los criterios de comparación son estos:
| before → after | Cómo interpretarlo |
|---|---|
| El Count es casi igual | Es probable que ese tipo no sea el responsable principal |
| Tanto el Count como el Size aumentan | Se convierte en candidato |
Aumentan los tipos de MyApp.* |
Es fácil sospechar de una retención en la lógica de negocio |
Aumenta System.Byte[] |
Hay que sospechar de búferes, serialización, imágenes, compresión, HTTP o bases de datos |
Aumenta System.String |
Hay que sospechar de caché, logs, JSON, claves de diccionario o cadenas duplicadas |
Aumentan Task, Timer o CancellationTokenSource |
Hay que sospechar de procesamiento asíncrono, timers o falta de cancelación |
dotnet-gcdump es fácil de usar como punto de entrada para la comparación, pero al obtenerlo induce un GC de Gen 2. En entornos con un heap grande o con requisitos estrictos de latencia, hay que prestar atención al tiempo de pausa y al consumo adicional de memoria.
En Windows, se puede abrir el .gcdump con Visual Studio o PerfView para compararlo.
En entornos que no son Windows, lo práctico es ver las estadísticas por tipo con el report de la CLI y avanzar a dotnet-dump para profundizar en el origen de las referencias.
9. Ver el heap y el origen de las referencias con dotnet-dump
Una vez identificado el «tipo que aumenta», el siguiente paso es ver «por qué no se recolecta».
Para eso, se toma un volcado con dotnet-dump y se analiza con comandos de SOS.
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-1.dmp
Después de un tiempo, se toma otro.
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-2.dmp
Tomar un volcado es una operación pesada. En particular, los volcados Full o Heap son grandes y suponen una carga para el proceso o el contenedor. Si se toma en producción, hay que prestar atención a la franja horaria, al espacio en disco, al límite de memoria del contenedor y a la posible presencia de información personal o confidencial.
Se analiza el volcado obtenido.
dotnet-dump analyze myapp-2.dmp
Primero se ven las estadísticas de todo el heap.
> dumpheap -stat
La salida muestra el número de instancias y el tamaño por tipo.
MT Count TotalSize Class Name
00007f... 120000 3840000 MyApp.Models.Order
00007f... 250000 8000000 System.String
00007f... 10000 40000000 System.Byte[]
Se filtra por un tipo concreto.
> dumpheap -stat -type MyApp.Models.Order
O bien se filtra por una MethodTable concreta.
> dumpheap -mt <MT>
Una vez conocida la dirección de una instancia, se investiga el origen de las referencias.
> gcroot <OBJECT_ADDRESS>
Este es el punto más importante. Con gcroot se confirma por qué ese objeto sigue vivo.
Por ejemplo, supongamos que aparece la siguiente ruta de referencias:
static MyApp.CustomerCache._items
-> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
-> MyApp.Models.Customer
-> System.String
En este caso, la razón por la que el GC no recolecta es clara. Customer está siendo referenciado desde una caché static, por lo que, desde el punto de vista del GC, todavía está en uso.
Recién en este punto se pueden hacer las siguientes evaluaciones:
- Si esa caché es realmente necesaria.
- Si tiene un límite superior.
- Si tiene un tiempo de expiración.
- Si el diseño hace que las claves sigan aumentando sin control.
- Si no está creciendo sin límite usando como clave el inquilino (tenant), el usuario, la fecha o el ID de solicitud, entre otros.
Lo importante en una investigación de fuga de memoria es no quedarse en dumpheap -stat.
dumpheap -stat indica «qué hay en mayor cantidad», y gcroot indica «por qué permanece». Lo segundo es lo que conduce a la corrección.
10. Tabla de referencia rápida para distinguir los casos
A continuación se organizan los patrones habituales en la práctica.
| Observación | Posibilidad | Qué ver a continuación |
|---|---|---|
| Solo aumenta el Total Allocated | Asignación normal, o asignación excesiva | Allocation Rate, número de GC, CPU, dotnet-trace |
| El Working Set aumenta pero el GC Heap está estable | Memoria nativa, JIT, pila, retención por parte del SO | Número de subprocesos, número de manejadores, herramientas nativas, bibliotecas externas |
| El GC Heap solo aumenta durante la carga y vuelve al detenerla | Espera de GC, asignación temporal | Gen 2 / LOH tras detener la carga, número de GC |
| El Gen 2 después del GC sigue aumentando | Retención de objetos de vida larga | dumpheap -stat, gcroot |
| El LOH sigue aumentando | Arreglos grandes, búferes, fragmentación, cadenas enormes | System.Byte[], System.Char[], LOH, espacio Free |
System.String es grande |
Caché de cadenas, JSON, logs, claves de diccionario | Buscar un tipo propio que retenga cadenas |
System.Byte[] es grande |
Búferes, serialización, imágenes, compresión, comunicación | Tipo propietario, devoluciones pendientes a ArrayPool, interoperabilidad nativa |
Aumenta Task |
Procesamiento asíncrono que no termina, cola de espera | Espera de async, cancelación, canales, colas |
Aumenta Timer |
Timer no liberado | Dispose, cancelación de registro, servicio de vida larga |
Aumenta CancellationTokenSource |
CTS no liberado, exceso de tokens enlazados | Dispose, desenlace, puntos de generación de timeout |
Quedan EventHandler o delegados |
Falta de cancelación de suscripción a eventos | Diferencia de vida entre publisher y subscriber |
| Aumenta la Finalization Queue | Dispose no llamado, cuello de botella en el finalizador |
finalizequeue, hilo del finalizador |
| Hay muchos pinned handles | Búferes fijados, interoperabilidad nativa | gchandles, POH, puntos de fijación |
11. Formas habituales de fuga de memoria
Se presentan siete patrones, pero no hace falta leerlos en orden desde el principio. Empiece por la fila que más se parezca al síntoma que está observando.
| Sección | Patrón | Síntoma típico | Primer indicador a observar |
|---|---|---|---|
| 11.1 | Colecciones static | Aumenta en proporción al número de operaciones y no vuelve aunque se detenga la carga | Gen 2. Si en gcroot aparece un static field |
| 11.2 | Caché sin límite | Aumenta en proporción al tiempo de actividad. Vuelve al reiniciar | Gen 2. El número de entradas de la caché y el crecimiento de System.String |
| 11.3 | Falta de cancelación de suscripción a eventos | Aumenta cada vez que se abre y se cierra una pantalla o un scope | El Count del tipo del ViewModel o del handler correspondiente. gcroot a través de un delegado |
| 11.4 | Timers que no se liberan | Un objeto que se suponía de vida corta no desaparece, y el callback sigue funcionando | El Count de System.Threading.Timer o TimerQueueTimer |
| 11.5 | Instancias de IDisposable que no se liberan |
El GC Heap está estable, pero aumentan el número de manejadores o la memoria del proceso | Número de manejadores, Finalization Queue, Working Set |
| 11.6 | AsyncLocal o retención de contexto |
El DTO permanece aunque el procesamiento de la solicitud haya terminado | gcroot a través de la máquina de estados async |
| 11.7 | Errores en el tiempo de vida de la inyección de dependencias | Aumenta en proporción al número de solicitudes | gcroot desde el tipo del singleton |
La columna «Primer indicador a observar» debe usarse junto con la tabla de referencia rápida del capítulo 10. El capítulo 10 es «la tabla que acota posibilidades a partir de una observación»; esta tabla es «la que va del patrón al indicador que hay que confirmar».
11.1 Colecciones static
Es la forma más fácil de entender.
public static class CustomerStore
{
private static readonly List<Customer> Customers = new();
public static void Add(Customer customer)
{
Customers.Add(customer);
}
}
En este código, el Customer añadido a Customers permanece mientras el proceso siga vivo. Aunque la intención sea un almacenamiento temporal, mientras siga siendo referenciado desde un static, el GC no lo recolecta.
La dirección de la corrección varía según el uso:
- Establecer un límite superior.
- Establecer un tiempo de expiración.
- Usar un mecanismo de caché como
MemoryCache. - Eliminarlo explícitamente.
- Dejar de usar static y trasladarlo a un servicio con el tiempo de vida adecuado.
- Si el objetivo es la persistencia, trasladarlo a una base de datos o a un almacenamiento externo.
Lo importante no es que «static sea malo», sino entender que lo que se coloca en un static se vuelve de vida larga, y usarlo con esa propiedad en mente.
11.2 Caché sin límite
Una caché usa memoria de forma intencionada, así que, si el crecimiento es el previsto en el diseño, no es una fuga. Sin embargo, una caché sin límite superior ni expiración se convierte, en la práctica, en una fuga de memoria.
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;
}
}
En este ejemplo, si las combinaciones de userId y date siguen aumentando, la caché también sigue creciendo.
Es especialmente peligroso incluir en la clave valores como estos:
- El ID de la solicitud.
- La hora actual.
- Un GUID.
- El ID de sesión.
- Una cadena construida a partir de la entrada del usuario sin normalizar.
- Una consulta SQL o unas condiciones de búsqueda convertidas directamente en cadena.
Para una caché, conviene decidir de antemano estas condiciones:
| Condición | Ejemplo |
|---|---|
| Número máximo de entradas | Hasta 10,000 entradas |
| Tamaño máximo | Hasta 256 MB |
| Tiempo de expiración | 30 minutos desde el último acceso |
| Tiempo de expiración absoluto | 6 horas desde la creación |
| Condición de descarte | Eliminación del inquilino, eliminación del usuario, cambio de configuración |
| Elementos a monitorear | Número de entradas, tamaño estimado, tasa de aciertos, número de desalojos |
No basta con decir «como es una caché, puede crecer»: hay que decidir hasta dónde puede crecer.
11.3 Falta de cancelación de suscripción a eventos
Un evento se convierte en una fuga cuando un publisher de vida larga sigue haciendo referencia a un subscriber de vida 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
}
}
Si OrderService es un singleton y OrderViewModel se crea por cada pantalla, el evento de OrderService sigue haciendo referencia a OrderViewModel. Aunque se cierre la pantalla, si no se cancela la suscripción, el ViewModel permanece.
Un ejemplo de corrección:
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
}
}
En gcroot, esto puede aparecer como una referencia a través de un delegado o de un event handler.
Este patrón es habitual en WPF, WinForms, servicios de vida larga, message brokers y event aggregators.
11.4 Timers que no se liberan
System.Threading.Timer, PeriodicTimer o las suscripciones de Reactive Extensions también permanecen si no se liberan.
public sealed class PollingWorker
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
private void Poll()
{
// polling
}
}
Si este PollingWorker está pensado como un objeto temporal, hace falta un diseño que libere el 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
}
}
El Timer tiene un delegado de callback, y desde ahí puede haber una cadena de referencias hasta el objeto en cuestión.
11.5 Instancias de IDisposable que no se liberan
La falta de liberación de IDisposable no siempre se manifiesta como una fuga en el heap administrado.
Puede aparecer como un problema de recursos: archivos, sockets, conexiones a bases de datos, manejadores nativos, búferes, etc.
public async Task<string> ReadAsync(string path)
{
var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
En este ejemplo, como StreamReader cierra stream, a menudo no supone un gran problema, pero en código donde la propiedad es ambigua sí se producen fugas.
Lo básico es dejar clara la propiedad 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();
}
La falta de Dispose se manifiesta con síntomas como estos:
- Aumenta el número de manejadores.
- Aumentan los sockets.
- No se cierran los archivos.
- Aumenta la memoria nativa.
- Aumenta la Finalization Queue.
- El GC Heap está estable, pero la memoria del proceso aumenta.
En este caso, dumpheap no basta.
También hay que observar los manejadores y sockets del sistema operativo, y el estado de las bibliotecas externas.
11.6 AsyncLocal y retención de contexto
AsyncLocal<T> es útil, pero si lo que se guarda en él es grande, puede permanecer durante mucho tiempo.
Un valor pequeño, como un ID de correlación para logs, difícilmente da problemas. Sin embargo, si se guardan cosas como información del usuario, el cuerpo de la solicitud, un DTO grande o un contexto de base de datos, se produce una retención no intencionada.
public static class RequestContext
{
public static readonly AsyncLocal<RequestInfo?> Current = new();
}
Como AsyncLocal viaja con el flujo asíncrono, a veces es más difícil de detectar que un simple campo static.
Conviene considerar un diseño en el que lo que se guarde sea pequeño y explícito, y que vuelva a null cuando ya no sea necesario.
11.7 Errores en el tiempo de vida de la inyección de dependencias
En la inyección de dependencias de ASP.NET Core y similares, los tiempos de vida de singleton, scoped y transient son distintos.
Si un singleton de vida larga retiene datos propios de cada solicitud, el objeto puede permanecer aunque la solicitud haya terminado.
public sealed class AuditBuffer
{
private readonly List<RequestAudit> _items = new();
public void Add(RequestAudit item)
{
_items.Add(item);
}
}
Si esto es un singleton, _items tiene el mismo tiempo de vida que la aplicación.
Si el diseño requiere almacenar en búfer, hacen falta un límite, un mecanismo de envío, de eliminación y de contrapresión (backpressure). Si la intención es solo «tal vez se revise más adelante», debería enviarse a logs o a un almacenamiento externo.
12. El LOH es especialmente propenso a malentendidos
LOH es la abreviatura de Large Object Heap. En .NET, los objetos grandes se colocan en un heap distinto al de los objetos pequeños habituales. El ejemplo representativo son los arreglos grandes.
var buffer = new byte[1024 * 1024 * 10]; // 10MB
Los problemas habituales con el LOH son estos tres:
- Crear objetos grandes con frecuencia.
- Mantener objetos grandes durante mucho tiempo.
- Fragmentación por la creación y destrucción de objetos grandes.
Que el LOH aumente no significa necesariamente que haya una fuga de inmediato. Si el diseño reutiliza búferes grandes, puede estabilizarse tras crecer hasta un tamaño determinado, y aunque el GC lo recolecte, el Working Set no siempre baja de inmediato.
Sin embargo, hay que sospechar en los siguientes casos:
System.Byte[]aumenta con cada operación.- Aumentan
System.Char[]oStringenormes. - No vuelve a su valor anterior tras procesar imágenes, PDF, Excel, ZIP, cifrado o compresión.
- No se devuelven los arreglos obtenidos con
ArrayPool<T>.Rent. - Se carga en memoria una respuesta grande completa.
- Se usa en exceso
MemoryStream.ToArray().
Si se usa ArrayPool<T>, siempre hay que devolverlo.
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);
try
{
// use buffer
}
finally
{
pool.Return(buffer);
}
Sin embargo, devolverlo al pool no significa que la memoria del proceso baje de inmediato. El pool puede retener memoria para su reutilización.
También aquí lo que hay que observar es si sigue aumentando, si tiene un límite superior y si se está reutilizando.
13. Cómo leer gcroot
gcroot muestra desde dónde se hace referencia a un objeto determinado.
Se resumen en una tabla las rutas típicas.
| Ruta | Significado |
|---|---|
| static field | Se referencia desde un campo static de un tipo |
| local variable / stack | Se referencia desde la pila de un subproceso en ejecución |
| GC handle | Se referencia desde un GCHandle, una fijación (pin), un delegado o interoperabilidad |
| finalization queue | Se retiene en espera de finalización |
| thread / async state machine | Lo retiene un procesamiento asíncrono en ejecución o en espera |
Un punto que conviene observar a menudo en una investigación es la diferencia de vida útil.
Objeto de vida larga
-> Objeto que debería ser de vida corta
Si aparece esta forma, es un candidato a fuga.
Por ejemplo, lo siguiente es sospechoso:
SingletonService
-> List<RequestContext>
-> RequestContext
-> LargeDto
SingletonService vive durante toda la aplicación.
Si dentro de él se acumulan RequestContext propios de cada solicitud, hace falta revisar el diseño.
Por otro lado, según el momento, una ruta como esta puede ser normal:
Thread stack
-> Controller action local variable
-> RequestDto
Si se está procesando una solicitud, es normal que queden variables locales.
Por eso, el momento en que se toma el volcado es importante.
Si además de durante la carga se toman volcados después de detenerla, cuando la cola esté vacía y tras dejarla inactiva un tiempo, resulta más fácil llegar a un juicio.
14. «Bajó con una GC forzada» no significa que esté resuelto
Durante la investigación, al llamar a GC.Collect() la memoria bajó. En este punto, pensar «entonces basta con llamar a GC.Collect() periódicamente» es peligroso.
Una GC forzada no elimina la causa raíz. Simplemente recolecta en ese momento los objetos que aún no se habían recolectado.
Si el problema es una tasa de asignación alta, la GC forzada aumenta el tiempo de pausa y empeora el rendimiento. Si se trata de una fuga real, los objetos con referencias activas tampoco se recolectan con una GC forzada.
Lo que hay que observar en la investigación es la siguiente diferencia:
| Después de una GC forzada | Interpretación |
|---|---|
| Baja mucho y después la línea base se estabiliza | La causa principal es la espera de GC o una asignación temporal |
| Baja un poco, pero el mínimo sube cada vez que se repite | Parte de lo asignado sobrevive. Candidato a fuga |
| Apenas baja | Sigue siendo referenciado, o la causa principal está fuera del GC heap |
| El GC Heap baja pero el Working Set no baja | Posible retención por parte del SO, de segmentos del GC o del lado nativo |
Antes de ejecutar GC.Collect() periódicamente en producción, siempre hay que identificar primero «qué es lo que está aumentando».
15. Procedimiento de investigación para uso práctico
A partir de aquí se resume como el procedimiento a seguir cuando se investiga en la práctica.
15.1 Fijar el escenario de reproducción
Primero se fijan las condiciones de la investigación.
Objetivo: /api/report/export
Operación: 100 ejecuciones bajo las mismas condiciones
Intervalo de medición: 5 segundos
Tiempo de observación: 5 min de warm-up + 10 min de carga + 5 min de inactividad
Entorno: staging / build Release / configuración equivalente a producción
En una investigación de memoria, si se observa mientras se hacen operaciones distintas cada vez, no se puede llegar a un juicio. Se fija «qué operación provocó el aumento».
15.2 Establecer la línea base
La línea base no se toma justo después del arranque, sino después del warm-up.
La razón es que, justo después del arranque, se dan aumentos de una sola vez como estos:
- JIT.
- Construcción del contenedor de DI.
- Carga de configuración.
- Primera conexión a la base de datos.
- Primera conexión TLS / HTTP.
- Generación de metadatos del serializador JSON.
- Inicialización de Razor / plantillas.
- Inicialización del logger o de las métricas.
El orden es el siguiente:
1. Arrancar la aplicación
2. Llamar varias veces al health check o a una API representativa
3. Esperar entre 1 y 5 minutos
4. Tomar counters y un dump como línea base
15.3 Tomar counters durante la carga
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output report-export-counters.csv \
--counters System.Runtime
En paralelo se realiza la operación de reproducción. Lo que interesa ver es la forma de la gráfica.
Forma cercana a lo normal:
Aumenta durante la carga
Sube y baja con el GC
Vuelve tras detener la carga
La línea base no sigue aumentando
Forma sospechosa:
Aumenta en proporción al número de operaciones
El mínimo de Gen 2 / LOH sube
No vuelve aunque se detenga la carga
En la siguiente carga, el mínimo sube todavía más
15.4 Tomar dos volcados (dumps)
Se toman antes y después de la carga.
dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# aplicar carga
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp
Si hay margen, también se toma uno después de detener la carga.
# después de detener la carga, cuando la cola esté vacía y tras esperar un tiempo
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp
Al comparar, no basta con before y after: idle-after es importante.
Aunque haya aumentado durante la carga, si vuelve tras la inactividad, es posible que no se trate de una fuga.
15.5 Ver los tipos que aumentaron
dotnet-dump analyze after.dmp
> dumpheap -stat
Se observa before de la misma manera.
Puede hacerse a mano, pero primero se comparan los tipos que aparecen en los primeros puestos.
Se enumeran los puntos a observar:
- Si están aumentando tipos del espacio de nombres propio.
- Si detrás de
System.Stringno hay un tipo propio. - Quién posee
System.Byte[]. - Si no están aumentando
List<T>oDictionary<TKey,TValue>. - Si no están aumentando
Tasko máquinas de estados async. - Si no están aumentando
TimeroCancellationTokenSource.
15.6 Ver el origen de las referencias
Se toma la dirección del objeto candidato y se ejecuta gcroot.
> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>
A partir del resultado de gcroot, se busca el padre que lo retiene.
MyApp.Services.ReportCache
-> Dictionary<string, ReportResult>
-> ReportResult
Llegados a este punto, empieza a verse el objeto de la revisión de código:
- Si
ReportCachees un singleton. - Si tiene un límite superior.
- Si se elimina.
- Si las claves siguen aumentando.
- Si
ReportResultno es demasiado grande. - Si debería trasladarse a una base de datos o a un archivo en lugar de una caché.
16. Cuándo usar dotnet-trace
dotnet-dump es adecuado para ver, mediante una instantánea de un momento concreto, «qué queda como resultado». Por otro lado, si se quiere ver «cuándo y dónde se asigna en grandes cantidades», se usa dotnet-trace.
Por ejemplo, se traza incluyendo eventos relacionados con el GC.
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:01:00 \
--clrevents gc+gchandle \
--clreventlevel informational \
--output gc-trace.nettrace
Si se quiere llegar hasta el muestreo de asignaciones, el volumen de eventos aumenta, así que conviene empezar con un tiempo corto en un entorno de pruebas.
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:00:30 \
--clrevents gc+gcsampledobjectallocationhigh \
--clreventlevel informational \
--output allocation-trace.nettrace
La traza es útil desde un ángulo distinto al del volcado.
| Lo que se quiere ver | Herramienta adecuada |
|---|---|
| Qué queda | dump / gcdump |
| Quién hace referencia | dump + gcroot |
| Cuándo se asignó en grandes cantidades | trace |
| Cuándo ocurrió el GC | counters / trace |
| Si el tiempo de pausa es el problema | counters / trace |
En una investigación de fugas, es eficiente ver primero con dump «qué queda» y, si hace falta, ver con trace «dónde se está creando».
17. Exponer métricas de verificación desde el código
El diagnóstico serio debería hacerse con herramientas externas, pero resulta útil tener en la propia aplicación un registro de diagnóstico sencillo.
Por ejemplo, una forma de exponer información del GC mediante un endpoint para administradores o un log periódico.
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 esta información sola no se puede determinar una fuga. Sin embargo, facilita las siguientes evaluaciones en caso de incidente:
- Si Gen 2 está aumentando bruscamente.
- Si HeapSize está aumentando.
- Si FragmentedBytes está aumentando.
- Si la diferencia entre TotalMemory y la memoria del proceso es grande.
- Si la tendencia cambió después de un despliegue.
Si se incluye en el log de la aplicación, hay que tener cuidado de no generar demasiado. Hacer un diagnóstico pesado con alta frecuencia se convierte, en sí mismo, en una carga.
18. Criterios para afirmar que hay una «fuga de memoria»
Al final de la investigación, hay que poder explicarlo de la siguiente manera. Como formato para volcarlo en un informe, primero se enumeran los campos que hay que completar.
| Campo | Qué escribir |
|---|---|
| Hecho observado | Quién tiene el problema. Con qué operación y cuánto aumenta |
| Condiciones de observación | Entorno, configuración de compilación, volumen de datos, número de ejecuciones, tiempo de warm-up, intervalo de observación, herramientas usadas y sus versiones |
| Observación | Cómo se movieron los valores de counters. Se anotan por separado el Working Set y el GC Heap |
| Comparación | Cuántas instancias de qué tipo aumentaron entre el volcado before y el after |
| Origen de la referencia | La ruta de retención que se vio con gcroot |
| Causa | Qué diseño del código está generando esa retención |
| Medida | Qué se va a cambiar. Cómo se va a medir el efecto |
| Cuestiones pendientes | Si hace falta. Lo que no se pudo explicar con esta observación y lo próximo a revisar |
Es especialmente importante no omitir las condiciones de observación. Si no quedan registradas, no se puede comparar con la nueva medición tras la corrección, y la verificación del capítulo 20 pierde sentido.
Si se completa siguiendo este formato, queda algo como esto, por ejemplo:
Hecho observado:
Al ejecutar /api/report/export 100 veces, el GC Heap queda 300MB por encima incluso después de detener la carga, y no vuelve.
Condiciones de observación:
Entorno staging / build Release / configuración y volumen de datos equivalentes a producción.
Tras 5 minutos de warm-up, 100 ejecuciones bajo las mismas condiciones, y después 5 minutos de inactividad.
Recolección con dotnet-counters a intervalos de 5 segundos.
Observación:
Con dotnet-counters, el heap size de Gen 2 aumentó en proporción al número de operaciones.
No solo aumentó el Working Set, también aumentó el GC Heap.
Comparación:
Al comparar before.dmp y after.dmp, MyApp.Models.ReportResult había aumentado en 12,000 instancias.
Origen de la referencia:
Con gcroot se vio que era referenciado desde MyApp.Services.ReportCache._items.
Causa:
ReportCache era un singleton, usaba como clave el ID de usuario + la hora actual, y no tenía eliminación, expiración ni límite superior.
Medida:
Se sustituyó por MemoryCache y se configuraron un límite de tamaño y un tiempo de expiración.
Se convirtió el número de entradas de la caché en una métrica.
Si se puede explicar hasta este punto, el informe deja de ser un simple «la memoria está aumentando» y pasa a conectar las condiciones de reproducción, los valores observados, el tipo que aumentó, el origen de la referencia, la causa y la medida.
19. Puntos de atención durante la investigación
19.1 Observar en compilación Release
Una compilación Debug puede verse distinta del uso real por efecto de la optimización, del tiempo de vida de las variables locales y de la información de depuración.
En una investigación equivalente a producción, se confirma con una compilación Release, una configuración cercana a la operativa real y un volumen de datos similar.
19.2 No juzgar solo con lo que ocurre justo tras el arranque
Justo después del arranque, la memoria aumenta por diversas inicializaciones.
Se toma la línea base después del warm-up y se observa si aumenta a partir de ahí.
19.3 No señalar culpable con un solo volcado
Un tipo que ocupa los primeros puestos del heap no es necesariamente el responsable.
System.String o System.Byte[] aparecen grandes en muchas aplicaciones.
Lo importante es si aumentó con el tiempo y quién lo posee.
19.4 Los volcados contienen información confidencial
Un volcado de memoria puede contener solicitudes, credenciales de autenticación, cadenas de conexión, información personal y datos del negocio.
Se deben decidir las reglas de dónde guardarlo, cómo sacarlo, cómo compartirlo y cuándo eliminarlo.
19.5 En contenedores, tomar un volcado es un riesgo
Si el límite de memoria del contenedor es estricto, la memoria adicional o el page-in provocados por la toma del volcado pueden hacer que el contenedor sea terminado por un OOM Kill.
Antes de tomarlo en un contenedor de producción, se prueba en staging y se confirman el valor límite, el espacio en disco, los permisos y el espacio de nombres de PID.
19.6 También hay fugas fuera del GC Heap
Que sea una investigación de .NET no significa que todo se manifieste en el GC heap.
En problemas como los siguientes, la memoria del proceso puede aumentar aunque el GC Heap esté estable:
- Bibliotecas nativas
- P/Invoke
- COM
- Procesamiento de imágenes
- Bibliotecas de compresión
- Procesamiento criptográfico
- Drivers de bases de datos
- Sockets
Marshal.AllocHGlobalNativeMemory.Alloc- Exceso de subprocesos
En este caso, no basta con dumpheap de dotnet-dump.
Hace falta observar también el diagnóstico del sistema operativo, las métricas de las bibliotecas externas, los manejadores, los subprocesos y la memoria nativa.
19.7 Qué acordar con las partes interesadas antes de tomar un volcado en producción
Tomar un volcado en un entorno de producción es, antes que una operación técnica, un trabajo que requiere coordinación. Si se salta este paso y solo se dice «déjenme tomarlo porque es para la investigación», normalmente el proceso se detiene.
Primero se organiza lo que hay que explicar como impacto:
- Tomar un volcado es una operación pesada para el proceso objetivo; mientras se toma, puede parecer que las respuestas se detienen. Cuánto tiempo se detiene depende del tamaño del heap, de la velocidad del disco y del entorno, por lo que conviene medirlo de antemano ejecutando una vez el mismo procedimiento en staging.
- Si el tiempo sin respuesta es largo, puede provocar el timeout del health check, la desconexión del balanceador de carga o el failover del clúster.
- Los volcados
FulloHeapcrecen según el uso de memoria del proceso. Hay que confirmar de antemano el espacio libre en disco del destino de escritura. - En contenedores, el page-in asociado a la toma del volcado puede superar el límite de memoria y provocar que el contenedor se termine de forma forzada (19.5).
- Los volcados pueden contener información personal o credenciales (19.4).
Con eso claro, antes de tomar el volcado se decide lo siguiente:
| Qué decidir | Ejemplo concreto |
|---|---|
| Quién lo aprueba | El responsable del servicio y el departamento de sistemas. Se obtiene primero el acuerdo de ambos |
| Cuándo tomarlo | Fuera del horario laboral, o en una franja donde se tolere una demora temporal en las respuestas |
| Dónde escribirlo | Un disco local con espacio suficiente. No escribir directamente en una carpeta compartida |
| Quién puede acceder | Limitar el acceso al destino de almacenamiento solo al equipo de investigación |
| Cuándo eliminarlo | Decidir de antemano y registrar la fecha límite de eliminación tras completar la investigación |
| Qué hacer si falla | El procedimiento de reinicio si las respuestas no vuelven, y quién toma esa decisión |
| Verificación previa | Ejecutar una vez el mismo procedimiento en staging y anotar el tiempo requerido y el tamaño del archivo |
Al hacer la solicitud, lo más seguro es entregar este contenido resumido en una página, en lugar de comunicarlo de forma verbal. Un ejemplo de formato:
Objetivo: Identificar por qué la memoria no vuelve tras ejecutar /api/report/export
Método: Tomar dotnet-dump collect --type Heap dos veces, antes y después de la carga
Impacto: Durante la toma, las respuestas del proceso objetivo se retrasan.
Se adjuntan en un anexo los valores medidos en staging
Franja horaria: Fuera del horario laboral. Un momento en el que puedan estar presentes el responsable y sistemas
Destino: Disco local del servidor objetivo. Confirmar el espacio libre de antemano
Información que puede incluir: Datos de solicitudes en memoria, cadenas de conexión, etc.
Manejo: El acceso al destino de almacenamiento queda limitado al equipo de investigación. No se debe sacar del lugar
Eliminación: Eliminar en un plazo máximo de un mes tras completar la investigación, y dejar constancia de la eliminación
Reversión: Si tras la toma las respuestas no vuelven, reiniciar el proceso
Si están claros «para qué», «cuánto tiempo se detiene» y «dónde se guarda y cuándo se elimina», a quien debe decidir le resulta más fácil dar una respuesta. Si, en cambio, esto queda ambiguo, la propia investigación se detiene.
20. Verificación después de la corrección
Después de corregir el punto que parecía una fuga, se vuelve a medir con el mismo procedimiento.
Antes de la corrección:
Tras 100 ejecuciones, Gen 2 +300MB
ReportResult +12,000 instancias
Después de la corrección:
Tras 100 ejecuciones, Gen 2 se estabiliza dentro de +20MB
ReportResult vuelve a la línea base tras detener la carga
El número de entradas de la caché se estabiliza en un límite de 1,000
La verificación de la corrección siempre se compara bajo las mismas condiciones.
- El mismo volumen de datos.
- El mismo número de ejecuciones.
- El mismo tiempo de carga.
- El mismo warm-up.
- El mismo intervalo de observación.
- Las mismas herramientas.
En una investigación de memoria, si la comparación antes/después de la corrección es débil, no resulta convincente.
21. Resumen
Cuando la memoria de .NET aumenta, no hay que dar por hecho de inmediato que es una fuga: se separa en el siguiente orden.
- No juzgar solo con el Working Set / RSS.
- Ver con
dotnet-countersel GC Heap, Gen 2, el LOH y el número de GC. - Comparar en el tiempo: durante la carga y después de detenerla.
- Ver con
dotnet-gcdumpodotnet-dumplos tipos que aumentaron. - Ver con
gcrootel origen de las referencias. - Revisar static, cachés, eventos, Timers, el tiempo de vida de la inyección de dependencias y los contextos asíncronos.
- Si el GC Heap está estable, sospechar también de la memoria nativa o de problemas del lado del sistema operativo.
La diferencia entre «simplemente no se ha recolectado con el GC» y «hay una fuga de memoria» se decide, en última instancia, por la referencia.
Si un objeto que ya no es necesario no está siendo referenciado, el GC lo recolecta en su momento. Si, debiendo ser innecesario, sigue siendo referenciado, el GC no puede recolectarlo.
Es decir, el objetivo de la investigación es este:
Qué está aumentando.
Qué permanece después de cada GC.
Quién lo está referenciando.
Si esa referencia es necesaria por diseño.
Si se llega a entender esto, ya no hay que dejarse arrastrar por la gráfica de memoria: se puede traducir en un punto concreto de corrección en el código.
Referencias
- El conjunto de código de muestra de este artículo (biblioteca, demo, pruebas unitarias) 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)
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
¿Qué es un PDB (Program Database)? — Cómo entender la información de depuración, los símbolos y Source Link
Qué es un PDB: qué contiene, qué no, y su relación con Debug/Release, Portable PDB, Source Link, servidores de símbolos y el análisis de ...
¿Qué es Roslyn? ── Leer, corregir y generar código C# desde la perspectiva del compilador
Resumen de Roslyn (.NET Compiler Platform): Syntax Tree, SemanticModel, Workspace, Analyzer, Source Generator, y sus usos y precauciones ...
Tipos de datos algebraicos en .NET Framework / .NET — Diseño que representa estados y resultados mediante tipos
Cómo usar los tipos de datos algebraicos, en especial los tipos suma y las uniones discriminadas, en .NET Framework y .NET: F#, jerarquía...
Cómo manejar correctamente los tokens de suplantación de Windows — préstamo de privilegios por hilo y una forma segura de revertirlos
Guía práctica sobre los tokens de suplantación de Windows: tokens de acceso, primarios y de hilo, niveles de suplantación, RevertToSelf y...
El malentendido de que TCP permite recibir por cada unidad enviada con Send ── diseño de recepción para tratarlo como flujo de bytes
En TCP, suponer que se recibe por cada unidad enviada con Send o Write provoca fragmentación, uniones, texto corrupto y protocolos rotos....
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿El aumento del uso de memoria en una aplicación .NET significa que hay una fuga de memoria?
- Que la memoria del proceso aumente y que exista una fuga de memoria no son lo mismo. En .NET, el GC actúa según la situación de las asignaciones y los umbrales del heap, por lo que puede tratarse simplemente de objetos ya innecesarios que aún no se han recolectado, o de casos en los que el sistema operativo no libera la memoria de inmediato después de la recolección. Lo que hay que observar son estos tres puntos: si la memoria que sobrevive después del GC está aumentando, qué tipo está creciendo y quién sigue haciendo referencia a esos objetos.
- ¿Qué herramientas se deben usar para investigar una fuga de memoria en .NET?
- Primero, con dotnet-counters se observa la tendencia de Working Set, GC Heap, Gen 2/LOH y el número de GC. Después, con dotnet-gcdump se toman GC dumps antes y después de la carga, y se comparan el Count y el Size de cada tipo para identificar los tipos que han aumentado. Por último, con dotnet-dump se obtiene un volcado del heap y, con dumpheap -stat y gcroot, se rastrea hasta el origen de la referencia para entender «por qué no se recolecta». Lo importante no es un único valor puntual, sino la comparación en el tiempo bajo las mismas condiciones.
- ¿Cuáles son los patrones habituales de fuga de memoria en .NET?
- Los casos típicos son: seguir añadiendo elementos a una colección static sin límite, una caché sin límite ni tiempo de expiración, la falta de cancelación de suscripción a eventos de un publisher de vida larga, timers que no se liberan, instancias de IDisposable que no se liberan, y errores en el tiempo de vida de la inyección de dependencias donde un singleton retiene datos propios de cada solicitud. Es más fácil de entender si se piensa que una fuga en .NET no es tanto «olvidarse de liberar» como una «retención no intencionada»: algo que ya no se necesita pero que sigue siendo referenciado.
- ¿Ejecutar GC.Collect() periódicamente resuelve los problemas de memoria?
- No los resuelve. Una recolección forzada solo recoge en ese momento los objetos que aún no se habían recolectado; no elimina la causa raíz. Si el problema es una tasa de asignación alta, la GC forzada aumentará el tiempo de pausa y empeorará el rendimiento; y si se trata de una fuga real, los objetos con referencias activas tampoco se recolectarán con una GC forzada. En un entorno de pruebas controlado sí puede usarse para comprobar «si algo sigue presente incluso después de una GC forzada», pero antes de aplicarla en producción como solución, siempre hay que identificar primero qué es lo que está aumentando.
Perfil del autor
Página de presentación del autor del artículo.
Go Komura
Representante de KomuraSoft LLC
Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.