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: · · .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:

  1. Si la memoria que sobrevive después del GC está aumentando.
  2. Qué tipo es el que está aumentando.
  3. 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í:

Relación entre los indicadores de memoria de un proceso .NETDiagrama 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/RSSse contabilizase contabilizasolo la parte cargada en memoria físicaMemoria que usa el procesoLado administradoRango visible en GC Heap SizeLado nativoRango que no aparece en GC Heap SizeGen 0 / Gen 1Asignaciones de vida cortaGen 2Objetos de vida larga que sobrevivieronLOHObjetos grandes de 85,000 bytes o másPOHObjetos fijados (pinned)Pila de subprocesosCódigo JIT, ensamblados cargadosBuffers de P/Invoke, COM y bibliotecas externasPrivate Bytes / CommitMemoria confirmada que el proceso posee de forma privadaWorking Set / RSS

Figura: relación entre los distintos indicadores de memoria de un proceso .NET.

En este diagrama conviene retener dos puntos:

  1. Con dumpheap solo 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á.
  2. 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:

  1. Crear objetos grandes con frecuencia.
  2. Mantener objetos grandes durante mucho tiempo.
  3. 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[] o String enormes.
  • 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.String no hay un tipo propio.
  • Quién posee System.Byte[].
  • Si no están aumentando List<T> o Dictionary<TKey,TValue>.
  • Si no están aumentando Task o máquinas de estados async.
  • Si no están aumentando Timer o CancellationTokenSource.

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 ReportCache es un singleton.
  • Si tiene un límite superior.
  • Si se elimina.
  • Si las claves siguen aumentando.
  • Si ReportResult no 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.AllocHGlobal
  • NativeMemory.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 Full o Heap crecen 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.

  1. No juzgar solo con el Working Set / RSS.
  2. Ver con dotnet-counters el GC Heap, Gen 2, el LOH y el número de GC.
  3. Comparar en el tiempo: durante la carga y después de detenerla.
  4. Ver con dotnet-gcdump o dotnet-dump los tipos que aumentaron.
  5. Ver con gcroot el origen de las referencias.
  6. Revisar static, cachés, eventos, Timers, el tiempo de vida de la inyección de dependencias y los contextos asíncronos.
  7. 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

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

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.

Volver al blog