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 un desafío. En este artículo:

  • Estaba motivada por muchas pérdidas de memoria y problemas de gc que no funcionaban. La mayoría de estos problemas se debe a que no se entiende cómo funciona el consumo de memoria en .NET Core o no se entiende 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 intervalo 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 la aplicación ya no hace referencia. Las generaciones numeradas más bajas son 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 se encuentran más tiempo, se mueven a una generación superior. Como se mencionó anteriormente, las generaciones más altas son 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. Por lo general, los singletons de nivel de aplicación se migran a la generación 2.

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

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

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

Llame a GC. Recoger

Llamada a GC. Recopilar explícitamente:

  • No se debe realizar mediante aplicaciones de ASP.NET Core producción.
  • Resulta útil al investigar pérdidas de memoria.
  • Al investigar, comprueba que el GC ha quitado todos los objetos de la memoria para que se pueda medir la memoria.

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 objetos
  • Medición del impacto que tiene la 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

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

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

Si el Administrador de tareas de memoria 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.

Aplicación de uso de memoria para mostrar de ejemplo

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.
  • No es una herramienta compatible; sin embargo, se puede usar para mostrar patrones de uso de memoria de ASP.NET Core aplicaciones.

Ejecute 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 MemoryLeak Index (Índice MemoryLeak) cuando se produce una GC de Gen 0. El gráfico muestra 0 RPS (solicitudes por segundo) porque no se ha llamado a ningún punto de conexión de API desde el controlador de API.

gráfico anterior

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

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

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 en para mostrar cómo la GC afecta a las asignaciones de memoria.

gráfico anterior

En el gráfico anterior se muestra:

  • 4K RPS (solicitudes por segundo).
  • Las colecciones de GC de generación 0 se producen aproximadamente cada dos segundos.
  • El espacio de trabajo es constante aproximadamente a 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 con el rendimiento máximo que puede controlar la máquina.

gráfico anterior

En el gráfico anterior se muestra:

  • 22 000 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 espacio de trabajo es constante aproximadamente a 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. Gc predeterminado para ASP.NET Core aplicaciones. Optimizado para el servidor.

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

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

Cambiar ServerGarbageCollection en el archivo de proyecto requiere que se recompile la aplicación.

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

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

gráfico anterior

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

  • El espacio de trabajo se reduce de 500 MB a 70 MB.
  • La recolección de elementos no utilizados realiza colecciones de generación 0 varias veces por segundo en lugar de cada dos segundos.
  • Gc quita 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 del 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 insuficiente.

GC mediante Docker y contenedores pequeños

Cuando se ejecutan varias aplicaciones en contenedores en un equipo, la GC de estación de trabajo podría ser más preformante que la GC del servidor. Para obtener más información, vea Running with Server GC in a Small Container and Running with Server GC in a Small Container Scenario Part 1 – Hard Limit for the GC Heap.

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, tienen como resultado una pérdida de memoria. Si la aplicación asigna objetos con frecuencia 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 las llamadas frecuentes, hace que la memoria de la aplicación aumente hasta que el proceso se bloquea con una OutOfMemory excepción.

gráfico anterior

En la imagen anterior:

  • La prueba de carga /api/staticstring del punto de conexión provoca un aumento lineal de la memoria.
  • La recolección de elementos no utilizados intenta liberar memoria a medida que aumenta 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 espacio de trabajo y asignado aumenta con el tiempo.

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

Memoria nativa

Algunos objetos de .NET Core se basan en la memoria nativa. La recolección de datos no puede recopilar memoria nativa. El objeto de .NET que usa memoria nativa debe liberarlo mediante código nativo.

.NET proporciona la interfaz IDisposable para permitir que los desarrolladores liberen memoria nativa. Aunque no Dispose se llame a , las clases implementadas correctamente llaman a cuando se ejecuta Dispose 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 mientras se invoca la fileprovider API continuamente.

gráfico anterior

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 del que se está haciendo un seguimiento en este problema.

La misma pérdida podría producirse en el código de usuario mediante una de las siguientes acciones:

  • No liberar la clase correctamente.
  • Se ha olvidado de invocar Dispose el método de los objetos dependientes que se deben eliminar.

Montón de objetos grandes

La asignación de memoria frecuente o los ciclos libres pueden fragmentar la memoria, especialmente al asignar fragmentos grandes 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 impone una penalización de rendimiento. Por esta razón, el GC crea una zona de memoria especial para objetos grandes, denominada montón de objetos grandes (LOH). Los objetos que tienen más de 85 000 bytes (aproximadamente 83 KB) son:

  • Se coloca en el LOH.
  • No compactado.
  • Recopilados durante los GCs de generación 2.

Cuando el LOH esté lleno, la recolección de elementos no utilizados desencadenará una colección de generación 2. Colecciones de generación 2:

  • Son intrínsecamente lentas.
  • Además, incurre en el costo de desencadenar una colecció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 los contenedores que usan .NET Core 3.0 y versiones posteriores, el LOH se compacta automáticamente.

La SIGUIENTE API que muestra este comportamiento:

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

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

gráfico anterior

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

gráfico anterior

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

Comparar los dos gráficos anteriores:

  • El espacio 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 loH a través de generan colecciones de generación constante 2. Las colecciones de generación 2 son costosas. Se requiere más CPU y el rendimiento disminuye casi un 50 %.

Los objetos grandes temporales son especialmente problemáticos porque provocan GCs gen2.

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

En los vínculos siguientes se muestra ASP.NET Core enfoque para mantener los objetos por debajo del límite de LOH:

Para más información, consulte:

HttpClient

Si se usa HttpClient incorrectamente, se puede producir una pérdida de recursos. Recursos del sistema, como conexiones de base de datos, sockets, identificadores de archivos, etc.:

  • Son más difíciles que la memoria.
  • Son más problemáticos cuando se pierden que la memoria.

Los desarrolladores de .NET experimentados saben llamar Dispose a en objetos que implementan IDisposable . Si no se desecharán los objetos que implementan, normalmente se IDisposable pierde memoria o se pierden recursos del sistema.

HttpClient implementa IDisposable , pero no debe eliminarse en cada invocación. En su HttpClient lugar, se debe volver a usar.

El punto de conexión siguiente crea y elimina una nueva HttpClient instancia 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 se desechan, el sistema operativo tarda algún tiempo en liberar HttpClient la conexión de red real. Al crear continuamente nuevas conexiones, se produce el agotamiento de los puertos. Cada conexión de cliente requiere su propio puerto de cliente.

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

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 HttpClient instancia se libera cuando se detiene la aplicación. En este ejemplo se muestra que no todos los recursos descartables deben eliminarse después de cada uso.

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

Agrupación de objetos

En el ejemplo anterior se mostró cómo todas las solicitudes pueden convertir la instancia en HttpClient estática y reutilizarla. La reutilización evita que se quedándose sin 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 NuGet paquete Microsoft.Extensions.ObjectPool contiene clases que ayudan a administrar dichos grupos.

El siguiente punto de conexión de API crea una instancia de byte un búfer 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:

gráfico anterior

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

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

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

  • El objeto está fuera de su control en cuanto vuelve del método .
  • No se puede liberar el objeto .

Para configurar la eliminación del objeto:

RegisterForDispose se encarga de llamar a en el objeto de destino para que solo se libera cuando se completa Dispose 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:

gráfico anterior

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

Recursos adicionales