Administración de memoria y recolección de elementos no utilizados (GC) en ASP.NET Core

Por Sébastien Ros y Rick Anderson

La administración de memoria es compleja, incluso en un marco administrado como .NET. Analizar y comprender los problemas de memoria puede ser difícil. Este artículo:

  • Fue motivado por muchas fugas de memoria y GC no funciona. La mayoría de estos problemas fueron causados por no comprender cómo funciona el consumo de memoria en .NET Core o no comprender cómo se mide.
  • Muestra el uso problemático de la memoria y sugiere enfoques alternativos.

Funcionamiento de la recolección de elementos no utilizados (GC) en .NET Core

El GC asigna segmentos de montón donde cada segmento es un rango contiguo de memoria. Los objetos colocados en el montón se clasifican en una de tres generaciones: 0, 1 o 2. La generación determina la frecuencia con la que el GC intenta liberar memoria en objetos administrados a los que ya no hace referencia la aplicación. Las generaciones numeradas más bajas tienen GC con más frecuencia.

Los objetos se mueven de una generación a otra en función de su duración. A medida que los objetos residen más tiempo, se mueven a una generación superior. Como se mencionó anteriormente, las generaciones más altas tienen GC con menos frecuencia. Los objetos de corta duración siempre permanecen en la generación 0. Por ejemplo, los objetos a los que se hace referencia durante la vida de una solicitud web son de corta duración. Los singleton de nivel de aplicación suelen migrar a la generación 2.

Cuando se inicia una aplicación ASP.NET Core, el GC:

  • Reserva memoria para los segmentos iniciales del montón.
  • Confirma una pequeña parte de memoria cuando se carga el tiempo de ejecución.

Las asignaciones de memoria anteriores se realizan por motivos de rendimiento. La ventaja de rendimiento proviene de segmentos de montón en memoria contigua.

GC. Recopilación de advertencias

En general, las aplicaciones de ASP.NET Core en producción no deben usar GC.Collect explícitamente. La inducción de recolecciones de elementos no utilizados en momentos poco óptimos puede reducir significativamente el rendimiento.

GC.Collect resulta útil al investigar fugas de memoria. La llamada a GC.Collect() desencadena un ciclo de recolección de elementos no utilizados bloqueante que intenta reclamar todos los objetos inaccesibles del código administrado. Es una manera útil de comprender el tamaño de los objetos activos accesibles en el montón y realizar un seguimiento del crecimiento del tamaño de la memoria a lo largo del tiempo.

Análisis del uso de memoria de una aplicación

Las herramientas dedicadas pueden ayudar a analizar el uso de memoria:

  • Recuento de referencias de objeto
  • Medición del impacto que tiene el GC en el uso de CPU
  • Medición del espacio de memoria usado para cada generación

Use las siguientes herramientas para analizar el uso de memoria:

Detección de problemas de memoria

El Administrador de tareas se puede usar para hacerse una idea de la cantidad de memoria que usa una aplicación de ASP.NET. Valor de memoria del Administrador de tareas:

  • Representa la cantidad de memoria que usa el proceso de ASP.NET.
  • Incluye los objetos vivos de la aplicación y otros consumidores de memoria, como el uso de memoria nativa.

Si el valor de memoria del Administrador de tareas aumenta indefinidamente y nunca se aplana, la aplicación tiene una pérdida de memoria. En las secciones siguientes se muestran y explican varios patrones de uso de memoria.

Ejemplo de aplicación de uso de memoria para mostrar

La aplicación de ejemplo MemoryLeak está disponible en GitHub. La aplicación MemoryLeak:

  • Incluye un controlador de diagnóstico que recopila datos de GC y memoria en tiempo real para la aplicación.
  • Tiene una página Índice que muestra la memoria y los datos de GC. La página Índice se actualiza cada segundo.
  • Contiene un controlador de API que proporciona varios patrones de carga de memoria.
  • Sin embargo, no es una herramienta compatible, se puede usar para mostrar patrones de uso de memoria de aplicaciones de ASP.NET Core.

Ejecutar MemoryLeak. La memoria asignada aumenta lentamente hasta que se produce una GC. La memoria aumenta porque la herramienta asigna un objeto personalizado para capturar datos. En la imagen siguiente se muestra la página Índice MemoryLeak cuando se produce una GC de generación 0. El gráfico muestra 0 RPS (solicitudes por segundo) porque no se ha llamado a ningún punto de conexión de API del controlador de API.

Chart showing 0 Requests Per Second (RPS)

El gráfico muestra dos valores para el uso de memoria:

  • Asignado: la cantidad de memoria ocupada por objetos administrados
  • Conjunto de trabajo: conjunto de páginas en el espacio de direcciones virtuales del proceso que actualmente residen en memoria física. El conjunto de trabajo que se muestra es el mismo valor que muestra el Administrador de tareas.

Objetos transitorios

La siguiente API crea una instancia de cadena de 10 KB y la devuelve al cliente. En cada solicitud, se asigna un nuevo objeto en memoria y se escribe en la respuesta. Las cadenas se almacenan como caracteres UTF-16 en .NET, por lo que cada carácter toma 2 bytes en memoria.

[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
    return new String('x', 10 * 1024);
}

El gráfico siguiente se genera con una carga relativamente pequeña para mostrar cómo las asignaciones de memoria se ven afectadas por el GC.

Graph showing memory allocations for a relatively small load

En el gráfico anterior se muestra lo siguiente:

  • 4K RPS (solicitudes por segundo).
  • Las colecciones de GC de generación 0 se producen aproximadamente cada dos segundos.
  • El conjunto de trabajo es constante en aproximadamente 500 MB.
  • La CPU es del 12 %.
  • El consumo y la liberación de memoria (a través de GC) son estables.

El siguiente gráfico se toma en el rendimiento máximo que puede controlar la máquina.

Chart showing max throughput

En el gráfico anterior se muestra lo siguiente:

  • 22K RPS
  • Las colecciones de GC de generación 0 se producen varias veces por segundo.
  • Las colecciones de generación 1 se desencadenan porque la aplicación asignó significativamente más memoria por segundo.
  • El conjunto de trabajo es constante en aproximadamente 500 MB.
  • La CPU es del 33 %.
  • El consumo y la liberación de memoria (a través de GC) son estables.
  • La CPU (33 %) no se utiliza en exceso, por lo que la recolección de elementos no utilizados puede mantenerse al día con un gran número de asignaciones.

GC de estación de trabajo frente a GC de servidor

El Recolector de elementos no utilizados de .NET tiene dos modos diferentes:

  • GC de estación de trabajo: optimizado para el escritorio.
  • GC de servidor. El GC predeterminado para aplicaciones ASP.NET Core. Optimizado para el servidor.

El modo GC se puede establecer explícitamente en el archivo del proyecto o en el archivo runtimeconfig.json de la aplicación publicada. El marcado siguiente muestra la configuración de ServerGarbageCollection en el archivo de proyecto:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

El cambio de ServerGarbageCollection en el archivo del proyecto requiere que se vuelva a compilar la aplicación.

Nota: La recolección de elementos no utilizados del servidor no está disponible en las máquinas con un único núcleo. Para obtener más información, vea IsServerGC.

En la imagen siguiente se muestra el perfil de memoria en un RPS de 5K mediante el GC de estación de trabajo.

Chart showing memory profile for a Workstation GC

Las diferencias entre este gráfico y la versión del servidor son significativas:

  • El conjunto de trabajo cae de 500 MB a 70 MB.
  • El GC genera colecciones de generación 0 varias veces por segundo en lugar de cada dos segundos.
  • El GC cae de 300 MB a 10 MB.

En un entorno de servidor web típico, el uso de CPU es más importante que la memoria, por lo que el GC de servidor es mejor. Si el uso de memoria es alto y el uso de CPU es relativamente bajo, el GC de estación de trabajo podría ser más eficaz. Por ejemplo, el hospedaje de alta densidad de varias aplicaciones web donde la memoria es escasa.

GC mediante Docker y contenedores pequeños

Cuando varias aplicaciones en contenedores se ejecutan en una máquina, el GC de estación de trabajo podría ser más eficaz que el GC de servidor. Para obtener más información, vea Ejecución con GC de servidor en un contenedor pequeño y Ejecución con GC de servidor en un escenario de contenedor pequeño Parte 1: límite máximo para el montón de GC.

Referencias de objetos persistentes

El GC no puede liberar objetos a los que se hace referencia. Los objetos a los que se hace referencia, pero que ya no son necesarios, generan una pérdida de memoria. Si la aplicación asigna con frecuencia objetos y no los libera después de que ya no sean necesarios, el uso de memoria aumentará con el tiempo.

La siguiente API crea una instancia de cadena de 10 KB y la devuelve al cliente. La diferencia con el ejemplo anterior es que un miembro estático hace referencia a esta instancia, lo que significa que nunca está disponible para la colección.

private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();

[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
    var bigString = new String('x', 10 * 1024);
    _staticStrings.Add(bigString);
    return bigString;
}

El código anterior:

  • Es un ejemplo de una pérdida de memoria típica.
  • Con llamadas frecuentes, hace que la memoria de la aplicación aumente hasta que el proceso se bloquee con una excepción OutOfMemory.

Chart showing a memory leak

En la imagen anterior:

  • La prueba de carga del punto de conexión /api/staticstring provoca un aumento lineal de la memoria.
  • El GC intenta liberar memoria a medida que crece la presión de memoria mediante una llamada a una colección de generación 2.
  • El GC no puede liberar la memoria filtrada. El conjunto asignado y el conjunto de trabajo aumentan con el tiempo.

Algunos escenarios, como el almacenamiento en caché, requieren que las referencias a objetos se mantengan hasta que la presión de memoria los obliga a liberarse. La clase WeakReference se puede usar para este tipo de código de almacenamiento en caché. Un objeto WeakReference se recopila bajo presión de memoria. La implementación predeterminada de IMemoryCache usa WeakReference.

Memoria nativa

Algunos objetos de .NET Core dependen de la memoria nativa. El GC no puede recopilar memoria nativa. El objeto .NET que usa memoria nativa debe liberarlo mediante código nativo.

.NET proporciona la interfaz IDisposable para permitir que los desarrolladores liberen memoria nativa. Incluso si no se llama a Dispose, las clases implementadas correctamente llaman a Dispose cuando se ejecuta el finalizador.

Observe el código siguiente:

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

PhysicalFileProvider es una clase administrada, por lo que cualquier instancia se recopilará al final de la solicitud.

En la imagen siguiente se muestra el perfil de memoria al invocar la API fileprovider continuamente.

Chart showing a native memory leak

En el gráfico anterior se muestra un problema obvio con la implementación de esta clase, ya que sigue aumentando el uso de memoria. Se trata de un problema conocido que está siendo objeto de seguimiento en este problema.

La misma fuga podría producirse en el código de usuario, mediante una de las siguientes acciones:

  • No se libera correctamente la clase.
  • Olvidando invocar el método Dispose de los objetos dependientes que se deben eliminar.

Montón de objetos grandes

Los ciclos frecuentes de asignación/liberación de memoria pueden fragmentar la memoria, especialmente cuando se asignan grandes fragmentos de memoria. Los objetos se asignan en bloques contiguos de memoria. Para mitigar la fragmentación, cuando el GC libera memoria, intenta desfragmentarla. Este proceso se denomina compactación. La compactación implica mover objetos. Mover objetos grandes supone una penalización de rendimiento. Por este motivo, el GC crea una zona de memoria especial para objetos grandes , denominado montón de objetos grandes (LOH). Los objetos que tienen más de 85 000 bytes (aproximadamente 83 KB) están:

  • Colocados en el LOH.
  • No compactados.
  • Recopilado durante los GC de generación 2.

Cuando el LOH esté lleno, el GC desencadenará una colección de generación 2. Colecciones de generación 2:

  • Son inherentemente lentos.
  • Además, se incurre en el costo de desencadenar una recopilación en todas las demás generaciones.

El código siguiente compacta el LOH inmediatamente:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

Consulte LargeObjectHeapCompactionMode para obtener información sobre cómo compactar el LOH.

En contenedores que usan .NET Core 3.0 y versiones posteriores, el LOH se compacta automáticamente.

La siguiente API muestra este comportamiento:

[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
   return new byte[size].Length;
}

En el gráfico siguiente se muestra el perfil de memoria de llamar al punto de conexión /api/loh/84975, bajo la carga máxima:

Chart showing memory profile of allocating bytes

En el gráfico siguiente se muestra el perfil de memoria de llamar al punto de conexión /api/loh/84976, asignando un solo byte más:

Chart showing memory profile of allocating one more byte

Nota: La estructura byte[] tiene bytes de sobrecarga. Por eso 84 976 bytes desencadena el límite de 85 000.

Comparación de los dos gráficos anteriores:

  • El conjunto de trabajo es similar para ambos escenarios, aproximadamente 450 MB.
  • En las solicitudes de LOH (84 975 bytes) se muestran principalmente colecciones de generación 0.
  • Las solicitudes de más de LOH generan colecciones constantes de generación 2. Las colecciones de generación 2 son costosas. Se requiere más CPU y el rendimiento disminuye casi el 50 %.

Los objetos grandes temporales son especialmente problemáticos porque provocan GC de generación 2.

Para obtener un rendimiento máximo, se debe minimizar el uso de objetos grandes. Si es posible, divida los objetos grandes. Por ejemplo, el middleware de almacenamiento en caché de respuesta en ASP.NET Core divide las entradas de caché en bloques inferiores a 85 000 bytes.

Los vínculos siguientes muestran el enfoque de ASP.NET Core para mantener los objetos bajo el límite de LOH:

Para más información, consulte:

HttpClient

El uso incorrecto de HttpClient puede dar lugar a una fuga de recursos. Recursos del sistema, como conexiones de base de datos, sockets, manipuladores de archivo, etc.:

  • Son más escasos que la memoria.
  • Son más problemáticos cuando se filtran que la memoria.

Los desarrolladores experimentados de .NET saben llamar a Dispose en objetos que implementan IDisposable. No eliminar objetos que implementan IDisposable normalmente da como resultado la pérdida de memoria o recursos del sistema filtrados.

HttpClient implementa IDisposable, pero no debe eliminarse en todas las invocaciones. En su lugar, debe reutilizarse HttpClient.

El siguiente punto de conexión crea y elimina una nueva instancia de HttpClient en cada solicitud:

[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
    using (var httpClient = new HttpClient())
    {
        var result = await httpClient.GetAsync(url);
        return (int)result.StatusCode;
    }
}

En carga, se registran los siguientes mensajes de error:

fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031":
      An unhandled exception was thrown by the application.
System.Net.Http.HttpRequestException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted --->
    System.Net.Sockets.SocketException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted
   at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port,
    CancellationToken cancellationToken)

Aunque las instancias de HttpClient se eliminan, el sistema operativo tarda algún tiempo en liberarse la conexión de red real. Al crear continuamente nuevas conexiones, se produce el agotamiento de puertos. Cada conexión de cliente requiere su propio puerto de cliente.

Una manera de evitar el agotamiento de puertos es reutilizar la misma instancia de HttpClient:

private static readonly HttpClient _httpClient = new HttpClient();

[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
    var result = await _httpClient.GetAsync(url);
    return (int)result.StatusCode;
}

La instancia de HttpClient se libera cuando la aplicación se detiene. En este ejemplo se muestra que no se deben eliminar todos los recursos descartables después de cada uso.

Consulte lo siguiente para obtener una mejor manera de controlar la duración de una instancia de HttpClient:

Agrupación de objetos

En el ejemplo anterior se mostró cómo la instancia de HttpClient puede hacerse estática y reutilizarla en todas las solicitudes. La reutilización evita la ejecución de recursos.

Agrupación de objetos:

  • Usa el patrón de reutilización.
  • Está diseñado para objetos que son costosos de crear.

Un grupo es una colección de objetos inicializados previamente que se pueden reservar y liberar entre subprocesos. Los grupos pueden definir reglas de asignación como límites, tamaños predefinidos o tasa de crecimiento.

El paquete NuGet Microsoft.Extensions.ObjectPool contiene clases que ayudan a administrar dichos grupos.

El siguiente punto de conexión de API crea una instancia de un búfer de byte que se rellena con números aleatorios en cada solicitud:

        [HttpGet("array/{size}")]
        public byte[] GetArray(int size)
        {
            var random = new Random();
            var array = new byte[size];
            random.NextBytes(array);

            return array;
        }

En el gráfico siguiente se muestra una llamada a la API anterior con carga moderada:

Chart showing calls to API with moderate load

En el gráfico anterior, las colecciones de generación 0 se producen aproximadamente una vez por segundo.

El código anterior se puede optimizar agrupando el búfer de byte mediante ArrayPool<T>. Una instancia estática se reutiliza en todas las solicitudes.

Lo que es diferente con este enfoque es que se devuelve un objeto agrupado de la API. Esto significa lo siguiente:

  • El objeto está fuera del control tan pronto como se devuelva desde el método .
  • No se puede liberar el objeto.

Para configurar la eliminación del objeto:

RegisterForDispose se encargará de llamar a Dispose en el objeto de destino para que solo se libere cuando se complete la solicitud HTTP.

private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();

private class PooledArray : IDisposable
{
    public byte[] Array { get; private set; }

    public PooledArray(int size)
    {
        Array = _arrayPool.Rent(size);
    }

    public void Dispose()
    {
        _arrayPool.Return(Array);
    }
}

[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
    var pooledArray = new PooledArray(size);

    var random = new Random();
    random.NextBytes(pooledArray.Array);

    HttpContext.Response.RegisterForDispose(pooledArray);

    return pooledArray.Array;
}

La aplicación de la misma carga que la versión no agrupada da como resultado el siguiente gráfico:

Chart showing fewer allocations

La principal diferencia son los bytes asignados y, como consecuencia, muchas menos colecciones de generación 0.

Recursos adicionales