Cosas que deberías saber sobre el Garbage Collector de .NET

La mayoría de los desarrolladores de .NET tenemos algunas nociones básicas sobre que es el Garbage Collector (en adelante GC) y para qué sirve. No obstante, con frecuencia pasamos por alto algunos detalles de su funcionamiento interno que provocan que nuestro código no sea todo lo eficiente y escalable que debería.

 

El objetivo del GC es proporcionar una capa de abstracción para los desarrolladores en cuestiones de manejo de memoria. Esto introduce una gran ventaja sobre otros lenguajes de programación en los que el desarrollador se tiene ocupar por completo de esta tarea. Escribir código que maneje correctamente su memoria en todas las situaciones no es ni mucho menos trivial, y las posibilidades de introducir bugs en la aplicación son múltiples: corrupción del heap, corrupción del stack, pérdida de memoria, fragmentación de memoria etc.

Si bien el GC simplifica la tarea de manejo de memoria para los desarrolladores, no les exime por completo. Para hacer buen uso de la memoria en .NET es importante conocer como la maneja el GC internamente. Vayamos por partes.

El GC de .NET es un colector de basura generacional. Esto significa que clasifica los objetos en distintas generaciones, lo cual le permite realizar colecciones de basura parciales (de una o varias generaciones) y así evitar hacer siempre colecciones de basura completas de todo el heap de .NET. Esta característica es una de las más importantes en cuanto al rendimiento del GC, y permiten que el GC de .NET sea escalable para aplicaciones de alta concurrencia como por ejemplo aplicaciones ASP.NET.

En el GC de .NET tiene tres generaciones (0, 1 y 2), y todos los objetos se crean en la generación 0 siempre y cuando no superen el tamaño de 85.000 bytes (enseguida veremos qué pasa con estos objetos). Las colecciones de basura se desencadenan cuando se intenta reservar memoria para un nuevo objeto y se sobrepasa el límite de memoria designado a la generación en cuestión. Los límites de memoria asignados a cada generación se modifican dinámicamente durante la vida del proceso para adaptarse a los patrones de reserva de memoria de la aplicación.

Cuando el GC realiza una colección de basura, revisa todos los objetos de la generación o generaciones afectadas, y comprueba si estan referenciados. Para que un objeto se consideré referenciado, tiene que estar referenciado por un objeto raíz. Los objetos raíz son (simplificando un poco):

· Threads – Todos los objetos referenciados en la pila: variables locales, parámetros, etc.

· Strong Reference – Objetos estáticos, objetos de caché y variables globales.

· Weak Reference – Aunque los objetos WeakReference no evitan que sus objetos referenciados sean “colectados”, se consideran objetos raíz.

· Pinned Objects – Los objetos marcados como Pinned no pueden ser “colectados” ni movidos por el GC, y por tanto los objetos a los que estos referencian tampoco pueden ser colectados. Esta técnica se suele utilizar para pasar un objeto .NET como referencia a una API nativa (no .NET), de forma que la dirección de memoria del objeto .NET no cambie hasta que no finalice la llamada a la API. Los objetos deben permanecer Pinned lo mínimo indispensable dado que pueden causar fragmentación del heap de .NET.

· Objetos que implementan destructor o Finalize() – Esta categoría la trato en un post separado: Cosas que deberías saber sobre los destructores en .NET

Los objetos que no están referenciados serán eliminados por el GC y su espacio en memoria será liberado, y los objetos supervivientes a la colección serán promocionados a la siguiente generación con la excepción de los objetos en la generación 2 que ya no pueden promocionar más. Por último, los "huecos" de espacio libre de los objetos eliminados es consolidado de forma que los objetos supervivientes son reubicados en direcciones de memoria contiguas. Una aplicación con una ratio de colecciones saludable, suele tener 10 veces más colecciones de la generación 0 que de la generación 1, y 10 veces más colecciones de la generación 1 que de la generación 2, es decir un ratio de 100:10:1 para GEN 0:GEN 1:GEN 2.

El colector de basura generacional es indispensable para alcanzar el nivel de rendimiento necesario en una aplicación de alta concurrencia, y se basa en la siguiente regla heurística: los objetos que han existido mucho tiempo, van a seguir existiendo durante mucho tiempo más. Es decir que si un objeto ha sobrevivido a dos colecciones y ha promocionado hasta la generación 2, lo más probable es que vaya a seguir sobreviviendo a colecciones venideras. Por lo tanto no tiene sentido colectar basura con la misma frecuencia en la generación 2 que en la 0. Tras haber realizado miles de pruebas de carga con distintos tipos de aplicaciones, esta presunción ha resultado ser cierta (casi siempre).

Cómo hacía referencia antes, los objetos cuyo tamaño es superior a los 85.000 bytes reciben un trato distinto. Estos objetos se crean en el Large Object Heap (en adelante LOH), también conocido a veces como generación 3. ¿Porqué necesitamos una generación o un heap especial para objetos grandes? Básicamente por dos motivos.

1) Se asume que los objetos grandes generalmente tienen una vida larga (misma regla heurística que para la generación 2).

2) Los objetos grandes son “caros” de mover y por este motivo el espacio libre en el LOH no se consolida y por tanto favorece la fragmentación de memoria. Por esto los objetos grandes se crean en un heap específico.

Cuando se desencadena una colección en la generación 2 o en el LOH, se realiza una colección completa (es decir de las generaciones 0, 1, 2 y LOH). Las colecciones de basura completas son costosas, sobre todo en cuanto a consumo de CPU, dado que potencialmente hay muchos objetos que revisar y mover una vez se ha liberado el espacio.

Dicho esto, ¿qué consideraciones debemos tener en cuanto al uso de memoria cuando desarrollamos aplicaciones .NET? Estas son algunas, pero no dudéis en aportar vuestros propios comentarios:

· Cuidado con los objetos alojados en el LOH. Siempre que tenga sentido, es deseable reutilizarlos y mantenerlos referenciados durante toda la vida del proceso.

· Cuidado con la concatenación de cadenas (String) en un bucle, por ejemplo generando dinámicamente un XML o un fragmento de HTML. Estas prácticas, si no se implementan correctamente, suelen terminar en objetos String de gran tamaño en el LOH que provocan constantes colecciones de basura completas y el correspondiente consumo de 100% CPU. Utilizad la clase StringBuilder para esto.

· Cuidado con los objetos que cacheamos, y las referencias a otros objetos que estos pueden mantener. Cachear objetos indirectamente de forma “involuntaria” es la forma más frecuente de provocar un memory leak en aplicaciones .NET.

Si queréis seguir profundizando en el funcionamiento del GC, os recomiendo los siguientes recursos:

Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework

https://msdn.microsoft.com/en-us/magazine/bb985010.aspx

Garbage Collection Part 2: Automatic Memory Management in the Microsoft .NET Framework

https://msdn.microsoft.com/en-us/magazine/bb985011.aspx

Garbage Collector Basics and Performance Hints

https://msdn.microsoft.com/en-us/library/ms973837.aspx

Maoni's WebLog - CLR Garbage Collector

https://blogs.msdn.com/maoni/

Hasta la próxima,

- Daniel Mossberg