Etiqueta: contenedores .NET

  • GC en .NET 10: La Evolución Silenciosa que Optimiza Memoria y Rendimiento

    GC en .NET 10: La Evolución Silenciosa que Optimiza Memoria y Rendimiento

    GC en .NET 10: La Evolución Silenciosa que Optimiza Memoria y Rendimiento

    ¿Y si te digo que muchas de las ideas fundamentales sobre el garbage collector en .NET han quedado obsoletas con .NET 10?

    En este artículo, desglosaremos las novedades del «Recolector de Basura» (GC, por Garbage Collector) en .NET 10. No solo veremos la teoría, sino que encaramos patrones prácticos, código y herramientas para medir el impacto en tus propias aplicaciones.

    La Base: ¿Cómo Funciona el Recolector de Basura de .NET?

    Desde los inicios del CLR (Common Language Runtime), .NET ha utilizado un recolector de basura generacional y de rastreo. Este modelo gestiona toda la memoria de los objetos en un montículo administrado (managed heap). El GC rastrea qué objetos siguen «en uso» (alcanzables desde las raíces de la aplicación) y cuáles pueden ser eliminados para liberar memoria.

    Para optimizar este proceso, el GC divide el heap en generaciones:

    • Generación 0 (Gen 0): Aquí viven los objetos más nuevos. Es la generación que se recolecta con mayor frecuencia.
    • Generación 1 (Gen 1): Contiene objetos que sobrevivieron a una recolección en Gen 0. Actúa como un búfer intermedio.
    • Generación 2 (Gen 2): Hogar de los objetos de larga duración, como cachés, objetos estáticos o servicios singleton.
    • Large Object Heap (LOH): Un área especial para objetos grandes (mayores de 85 KB) que se gestiona de forma diferente para evitar el alto costo de moverlos en memoria.

    La lógica es simple: la mayoría de los objetos mueren jóvenes. Al centrar el trabajo en la Generación 0, el GC minimiza la sobrecarga y las pausas en la aplicación.

    Modos de GC: Workstation vs. Server

    .NET ofrece dos perfiles principales para el GC, y elegir el correcto es crucial:

    • Workstation GC: El modo por defecto en aplicaciones de escritorio. Está diseñado para optimizar la capacidad de respuesta de la interfaz de usuario, utilizando menos hilos y favoreciendo la recolección en segundo plano.
    • Server GC: Optimizado para servicios de backend y aplicaciones web. Paraleliza la recolección a través de múltiples heaps (uno por cada núcleo de CPU), maximizando el rendimiento (throughput) general a costa de un mayor uso de memoria.

    Puedes configurar el modo Server en tu archivo runtimeconfig.json:

    {
      "runtimeOptions": {
        "configProperties": {
          // Habilita el modo de recolección de basura optimizado para servidores
          "System.GC.Server": true
        }
      }
    }
    

    ¡Advertencia! Siempre hacer benchmarks con el modo de GC que vas a poner en producción. Usar el modo incorrecto puede causar latencias inesperadas y degradar el rendimiento. No suena gravísimo hasta que te toca aplicarlo en un ambiente de muy alta demanda.


    La Evolución del GC hasta .NET 10

    Antes de seguir para las novedades, recordemos el camino recorrido. Cada versión de .NET ha refinado el GC:

    • .NET Framework: Introdujo el modelo generacional, los modos Workstation/Server y la recolección en segundo plano.
    • .NET Core (1-3): Hizo el GC verdaderamente multiplataforma y mejoró la escalabilidad en servidores con muchos núcleos.
    • .NET 5-6: Introdujo la compactación bajo demanda del LOH y el GCHeapHardLimit para controlar la memoria en contenedores.
    • .NET 7-9: Marcó el inicio de la gestión del heap basada en regiones y la introducción de DATAS (Dynamic Adaptation To Application Sizes), un sistema que autoajusta el uso del heap según el comportamiento de la aplicación.
    • .NET 10: Da un salto cuántico con un análisis de escape mucho más agresivo para asignaciones en la pila (stack), optimizaciones de delegados, y DATAS activado por defecto.

    Novedades del GC en .NET 10: ¿Qué Cambia Realmente?

    Más allá de los titulares, estas son las mejoras que tendrán un impacto real en el perfil de memoria de tus aplicaciones.

    1. Análisis de Escape y Asignación en Pila: Un Cambio Radical

    Tradicionalmente, casi cualquier objeto creado con la palabra clave new terminaba en el heap, obligando al GC a rastrearlo. El análisis de escape es un proceso donde el compilador JIT (Just-In-Time) detecta si un objeto «escapa» del método donde fue creado (es decir, si se devuelve o se almacena en un campo).

    En .NET 10, este análisis es mucho más inteligente. Si se puede demostrar que un objeto nunca sale de su método, se asigna directamente en la pila (stack), no en el heap. Esto significa que el GC ni siquiera se entera de su existencia.

    Veamos un ejemplo práctico en C#:

    public int SumaDeArray()
    {
        // En .NET 9, este array siempre se asignaría en el heap.
        // En .NET 10, el JIT puede detectar que 'numeros' no "escapa"
        // del método y lo asignará en la pila (stack).
        int[] numeros = [1, 2, 3, 4, 5, 6, 7];
        var suma = 0;
    
        for (var i = 0; i < numeros.Length; i++)
        {
            suma += numeros[i];
        }
    
        return suma;
    }
    

    El resultado que se ve en esta comparativa es impresionante (fuente: mi PC 🙂 ):

    MétodoMedia (ns)Memoria AsignadaGen 0
    SumaDeArray (.NET 9)7.7 ns72 B0.0086
    SumaDeArray (.NET 10)3.9 ns0 B0

    El impacto es claro: cero memoria asignada en el heap y el doble de velocidad. Este cambio reduce drásticamente la presión sobre el GC, especialmente en código con muchos objetos pequeños y de corta duración.

    2. DATAS: Adaptación Dinámica al Tamaño de la Aplicación

    DATAS es una funcionalidad que ajusta automáticamente los umbrales del GC para adaptarse mejor a los requisitos de memoria reales de la aplicación. Esto es fundamental en el mundo de los microservicios y contenedores con límites de memoria estrictos.

    • Modelo antiguo: El heap crecía y se aferraba a la memoria en anticipación de picos de uso, lo que llevaba a un sobreaprovisionamiento.
    • Con DATAS: Cuando la carga de trabajo es baja, el GC es más agresivo y devuelve memoria al sistema operativo. Cuando la demanda aumenta, el heap se expande para satisfacerla.

    En .NET 10, DATAS está activado por defecto. Si necesitas desactivarlo (por ejemplo, en sistemas donde la latencia p99 es más crítica que el uso de memoria), puedes usar una variable de entorno:

    # Desactivar el modo de adaptación dinámica
    DOTNET_GCDynamicAdaptationMode=0
    

    O configurarlo en runtimeconfig.json:

    {
      "runtimeOptions": {
        "configProperties": {
          // 0 deshabilita DATAS
          "System.GC.DynamicAdaptationMode": 0
        }
      }
    }
    

    Nota: Si tu aplicación tiene picos de asignación muy impredecibles, DATAS podría introducir una pequeña latencia adicional en los peores casos (percentil 99). ¡Mide siempre el impacto!

    3. Optimización de Delegados y Closures

    Los delegados y las expresiones lambda a menudo crean asignaciones ocultas, especialmente cuando capturan variables locales (lo que se conoce como closure). .NET 10 mejora el análisis de escape para delegados.

    Si una lambda se usa solo localmente y no «escapa», su objeto de closure ahora puede ser asignado en la pila.

    C#

    public int AnalisisEscapeDelegado()
    {
        var suma = 0;
    
        // En .NET 9, esta acción creaba una asignación en el heap para la closure (que captura 'suma').
        // En .NET 10, si 'action' no escapa, la closure se puede asignar en la pila.
        Action<int> accion = i => suma += i;
    
        foreach (var numero in MisNumeros)
        {
            accion(numero);
        }
    
        return suma;
    }
    

    Los benchmarks hablan por sí solos, con una reducción de memoria asignada de casi el 75%:

    MétodoMedia (ns)Memoria Asignada
    .NET 918,983 ns88 B
    .NET 106,292 ns24 B

    4. Otras Mejoras Clave

    • Optimización de Barreras de Escritura: El GC usa «barreras de escritura» para rastrear referencias entre generaciones. .NET 10 elimina más barreras innecesarias, reduciendo el uso de CPU en aplicaciones con alta modificación de objetos.
    • Mejoras en Devirtualización: El JIT es ahora más agresivo al optimizar llamadas a través de interfaces (como IEnumerable<T>), convirtiendo llamadas virtuales en llamadas directas y permitiendo un mayor inlining (inserción de código de un método en otro).
    • Controles para Contenedores: Se consolidan y mejoran las opciones como GCHeapHardLimit (límite duro de memoria para el heap) y LOHThreshold (umbral para considerar un objeto como grande), esenciales para microservicios.

    Midiendo el Impacto: Herramientas y Métricas Clave

    Para verificar estas mejoras en tu propia aplicación, necesitas las herramientas adecuadas. La herramienta de línea de comandos dotnet-counters es tu mejor aliada.

    # Monitorear las métricas del GC para un proceso específico
    dotnet-counters monitor -p <ID_DEL_PROCESO> System.Runtime
    
    MétricaPropósito
    gc-heap-sizeTamaño total actual del heap administrado.
    gen-0-gc-countNúmero de recolecciones de Gen 0 (indica presión de memoria).
    gen-1-gc-countNúmero de recolecciones de Gen 1.
    gen-2-gc-countNúmero de recolecciones de Gen 2 (recolecciones completas, las más costosas).
    time-in-gcPorcentaje de tiempo que la aplicación pasa pausada por el GC.
    alloc-rateTasa de asignación de memoria por segundo.

    Analizar estas métricas ayuda a distinguir entre presión sobre el GC, fugas de memoria (memory leaks) o fragmentación.


    Cuándo Ajustar los Valores por Defecto

    A pesar de los avances, no existe una configuración única para todos. Considera modificar los valores por defecto en estos casos:

    1. Priorizas Rendimiento sobre Memoria: En trabajos de procesamiento por lotes (batch) o APIs de latencia ultra baja, DATAS podría no ser ideal. Desactivarlo puede darte un rendimiento más predecible en los picos de carga.
    2. Sistemas de Tiempo Real: Si tu aplicación no puede tolerar pausas inesperadas, podrías necesitar una configuración más manual, ajustando el tamaño de las regiones o desactivando la adaptación dinámica.
    3. Perfiles de Memoria Atípicos: Si ejecutas tu aplicación en hardware muy limitado (IoT) o tienes un patrón de uso de memoria muy específico, la sintonización manual puede ser necesaria.

    Conclusión

    Durante años, el recolector de basura de .NET fue una caja negra para muchos de nosotros (devs, gente superior, semi-dioses): algo que simplemente «funcionaba» en segundo plano. .NET 10 cambia esta perspectiva y convierte al GC en un componente de rendimiento activo, transparente y altamente configurable.

    Las mejoras en asignación en pila (stack), la adaptación dinámica y las optimizaciones de bajo nivel responden directamente a las necesidades del desarrollo moderno: microservicios eficientes, aplicaciones nativas en la nube y un rendimiento que compite con lenguajes de bajo nivel.

    Al comprender y aprovechar estos cambios, dejas de ver al GC como una carga incontrolable y lo conviertes en un aliado personalizable. La telemetría, eficiencia y predictibilidad del GC son ahora tan vitales para la salud de tu aplicación como el rendimiento de la base de datos o la latencia de tus APIs.

    Para profundizar