Gestión de la memoria en .NET y garbage collector (II)

En este artículo terminaremos de revisar algunos de los conceptos más importantes que atañen al GC en .NET: Finalización, Generaciones y manipulación del GC.

Finalización

Nótese que decimos finalizadores y no destructores, como en terminología C++. Esto se debe a que no se corresponde al mismo concepto, ya que el montículo se gestiona de una forma muy diferente y la finalización no es determinística.

Existen objetos que requieren de ciertas tareas de limpieza previamente a la liberación por parte del GC. Esto es cierto en contadas y muy concretas ocasiones, pues en la mayoría de los casos la liberación de un objeto en memoria es perfectamente posible sin intervención explícita por parte del usuario.

Para esto existen métodos de finalización como Finalize() y Dispose() (o Close()). No explicaré en detalle lo que hacen estos métodos; simplemente apuntaré que la diferencia entre el primero y el segundo es que no sabemos con certeza en qué momento se invocará un Finalize() (cuando el GC libere memoria), mientras que un Dispose() fuerza a liberar el recurso referido. Además, el uso de Dispose implica que el objeto referido debe implementar la interfaz IDisposable.

La diferencia entre Dispose() y Close() es más conceptual que funcional. Se suele utilizar Dispose  cuando el objeto no se va a utilizar más definitivamente. Un ejemplo muy común: los elementos gráficos tales como controles y formularios WinForms); mientras que un Close indica que el objeto puede volver a utilizarse pero de momento se libera. Un ejemplo serían las conexiones a bases de datos o las conexiones de red.

Existe una sentencia en C# muy útil que permite utilizar un recurso en un ámbito muy concreto y liberarlo al final. Ésta es la sentencia using:

using (MyResource res = new MyResource())
{
//uso del recurso
}

Cuando el recurso sale del ámbito de la sentencia using, automáticamente se invoca su método Dispose que libera el recurso. Esto implica que el objeto debe implementar la interfaz IDisposable, y por tanto implementar un método Dispose. La sentencia using equivale al uso de un try-finally como el siguiente:

MyResource res;
try
{
   res = new MyResource();
   //uso del recurso
}
finally
{
  res.Dispose();
}

Sin embargo, hay que anotar que aunque se invoque explícitamente el método Dispose de un objeto, éste puede no ser liberado inmediatamente. Como dijimos en el artículo anterior, la liberación de memoria en .NET no es determinística.

También existe un fenómeno curioso denominado “resurrección” y que consiste en que un objeto puede volver a memoria después de haberse recolectado. Para más información al respecto, les remito a las referencias que incluyo al final de este artículo.

Generaciones

En la práctica, los objetos se almacenan en el montículo según ciertas “categorías”. Estas categorías se basan en las siguientes premisas, que en la mayoríade los programas se cumplen:

  1. Cuanto más nuevo es un objeto, más corta será su vida.
  2. Cuanto más viejo es un objeto, más larga será su vida.
  3. Recolectar una porción del montículo es más rápido que recolectar todo el montículo.

Cuando se inicializa el montículo, todos los objetos que se van añadiendo a dicha estructura conforman lo que se conoce como generación 0. El CLR inicializa dicha generación con un tamaño determinado; digamos que ese tamaño es 256Kb.

Cuando el montículo se queda sin espacio para albergar más objetos, se ejecuta el GC. Los objetos que superan la criba del GC pasan a formar parte de la generación 1. Este sector (por llamarlo de alguna manera) del montículo tiene un tamaño más grande; digamos que de 2Mb.

Los nuevos objetos que se crean a partir de este momento se almacenan en la generación 0. Cuando la generación 0 se vuelve a llenar, el GC vuelve a eliminar lo que no es necesario y los objetos que sobreviven pasan a formar parte de la generación 1, quedando nuevamente la generación 0 vacía.

Llegará un momento en el cual la generación 1 también se llene y el GC tendrá que actuar nuevamente. Es interesante fijarse en que, puesto que esta generación tiene un espacio más grande, tardará más en llenarse y el GC tardará más en actuar aquí. Además, si suponemos la premisa 1 como cierta entonces liberaremos aquellos objetos nuevos transitorios rápidamente y eficientemente recorriendo secciones bien pequeñas.

Como decíamos, llegará un momento en que la generación 1 se llene. En ese momento el GC recorrerá la generación 1 y todos aquellos objetos que sobrevivan a dicha recolección pasarán a la generación 2, que será aún más grande. Y vuelta a empezar. Normalmente las implementaciones de montículo de .NET soportan hasta 3 generaciones: 0, 1 y 2, con lo cual aquí hemos terminado.

Como vemos, se cumplen las tres premisas, porque:

  1. Los nuevos objetos se almacenan en la generación 0, que es la más pequeña.
  2. Los objetos más viejos se almacenan en generaciones más grandes, que tardan más en llenarse y por tanto en limpiarse.
  3. Casi nunca se recorre todo el montículo, sino solamente sectores (generaciones). Solamente se recorre todo el montículo cuando se intenta instanciar en memoria un nuevo objeto y tanto la generación 0 como la generación 1 y la 2 están llenas, lo cual es realmente muy poco frecuente.

Manipulación del GC

Cuando dije que la liberación de recursos en .NET es automática no fui totalmente sincero. Es posible forzar una pasada del GC, pero esto solamente es conveniente realizarlo cuando sabemos a ciencia cierta que un conjunto de recursos costosos que se han estado utilizando no se van a utilizar más y por tanto deberían ser liberados. El método a utilizar para forzar al GC a liberar recursos es System.GC.Collect().

La clase System.GC es la que implementa el recolector de basura. Algunos métodos útiles son:

GC.MaxGeneration(), que devuelve el numeral de la máxima generación en el GC soportada por el montículo.
GC.WaitForPendingFinalizers(): Este método suspende el hilo hasta que se procesen los finalizadores pendientes.
GC.GetGeneration(): Que permite manipular las distintas generaciones del GC.

Algunas sencillas prácticas que permiten sacar partido del GC

A pesar de que la gestión de la memoria es un proceso bastante automático en .NET, es muy conveniente entender cómo funciona entre bastidores porque sí que hay ciertas prácticas que permiten sacar provecho de su funcionamiento. Por ejemplo:

  • Limitar el uso de objetos estáticos, ya que constituyen raíces que no se pueden recolectar.
  • Utilizar la localidad de referencia en la medida de lo posible, es decir: declarar los objetos lo más cerca posible de su primer uso. Esto evitará la creación de objetos mucho antes de su uso.
  • Tratar de concentrar las operaciones sobre un objeto en una porción pequeña de código, pues el GC se basa en la premisa de que el uso de un objeto está concentrado en unas pocas líneas de código y después no se vuelve a utilizar (nuevamente, localidad de referencia).
  • Usar convenientemente Close() y Dispose() para liberar recursos como conexiones, buffers, streams, etc. que ya no se van a utilizar, invocándolos lo antes posible, una vez que los objetos no van a ser utilizados más.
  • A la hora de implementar un destructor, primero: plantéese si realmente es necesario hacerlo y segundo: no codifique pensando en el orden que van a seguir las operaciones a la hora de liberar recursos, recuerde: la función del GC es no determinística. Para los que vengan de un lenguaje C++, recuerden que el uso de destructores es mucho menos frecuente en .NET que en C++, sencillamente porque el manejo de la memoria es muy diferente. Básicamente es automático.

Esto es todo. Espero que esta serie de dos artículos les haya parecido útil o al menos interesante a muchos programadores de .NET.


Referencias:

Applied Microsoft .NET Framework programming, Jeffrey Richter
CLR via C#, Jeffrey Richter
Professional C#, Simon Robinson et al.

Gestión de la memoria en .NET y garbage collector

La tecnología .NET consta de una gestión automática de memoria que permite liberar segmentos de memoria que no se utilizan. Este sistema de liberación de memoria se conoce como recolector de basura o garbage collector (en adelante GC), y a excepción de Java (y algún otro lenguaje más) esta gestión automática no se encuentra en otros lenguajes de programación. Esta gestión automática permite un uso mucho más eficiente y cómodo de los recursos por parte del usuario (programador).

Aunque existe el concepto de destructor en .NET y es posible tocar ciertos parámetros de la gestión de la memoria, no es recomendable hacerlo si no se sabe bien lo que se está haciendo; los algoritmos de gestión de memoria y  recolección de basura están suficientemente optimizados como para tener que tocar nada al respecto. Sin embargo sí que existen algunas buenas prácticas de programación que permiten aumentar la eficiencia del recolector de basura.

En este artículo veremos cómo .NET organiza la memoria de un programa y cómo trabaja el GC. En el siguiente artículo veremos el concepto de “generación” del GC, que permite promocionar objetos que se usan con más frecuencia por parte de la aplicación.

Fundamentos y necesidad

Todos los programas utilizan recursos de la máquina, sean éstos ficheros, búffers de memoria, espacio en pantalla, conexiones de red o de base de datos. Una vez que ya no se usan estos recursos, es necesario liberarlos o la aplicación puede, en algún momento dado, dejar sin recursos a la máquina para otros programas. La cuestión es saber cuándo no se van a usar más dichos recursos. En algunas ocasiones es relativamente sencillo, como por ejemplo una conexión
de red, de base de datos o un fichero: se crea la conexión en un bloque try y se libera en un bloque finally. O mejor aún: si está escribiendo código en C# utilice la sentencia using (veremos el uso de esta sentencia en el siguiente artículo).

Sin embargo, en otras ocasiones no es tan sencillo, como por ejemplo la gestión de la memoria.  ¿Cuándo deja de ser útil un objeto en memoria?

Montículo y pila

Aquellos que conozcan mínimamente la tecnología .NET sabrán que las “variables” en .NET tienen dos tipos fundamentales: valores tipo y valores referencia.

Los valores tipo son los que comunmente conocemos como tipos simples (int, char, long, byte…) y en memoria se almacenan en una estructura de pila.

Los valores referencia son los que comunmente conocemos como objetos (además de strings y arrays), que tienen cierta complejidad y en memoria se almacenan en una estructura de montículo.

El GC actúa sobre el montículo y no sobre la pila porque los segmentos almacenados en la pila son pequeños y de un tamaño controlado, mientras que en el montículo se almacenan bloques de memoria de distinta índole y tamaño.

Gestión básica de la memoria

Si representamos el montículo como una lista, los objetos se van almacenando en dicho montículo de forma lineal, existiendo un puntero, al que denominaremos NextObjPtr que apunta a la primera dirección libre de memoria reservada para la aplicación en ejecución.

Cada vez que se crea un nuevo objeto, se calcula su tamaño, se añade al montículo y se desplaza el puntero tanto como tamaño tenga el nuevo objeto. Es decir, toda la gestión de memoria se realiza mediante un puntero. Sencillo y muy eficiente, ¿verdad? ¿Cómo es que a nadie se le ha ocurrido antes? Bueno, es que esta aproximación tiene una pega importante, y es que está presuponiendo una cantidad de memoria infinita…

El recolector de basura (GC)

¿Qué ocurre en un entorno real en el que la memoria es limitada? Existe un algoritmo que permite determinar cuándo es necesario liberar memoria y qué es lo que hay que liberar. Esto es el GC.

¿Cómo determina el GC qué se puede liberar? Aquí entra en juego el concepto de raíz o referencia fuerte. Una raíz es: una variable gobal, un objeto estático, una variable local o un registro de CPU (aquí estamos hablando desde el punto de vista del lenguaje intermedio (IL) de .NET, no de un lenguaje de alto nivel). Una raíz no se puede eliminar de memoria, porque se entiende que se puede utilizar en cualquier momento.

Inicialmente, el GC asume que todo es basura y susceptible de ser recolectado. Enconces recorre el montículo buscando raíces. Estas raíces no se eliminarán en la próxima pasada del GC.

Con ello, tendremos un grafo con elementos directamente accesibles desde el código y otros que no. Ahora, el GC introduce el concepto de “alcanzable”, que son aquellos objetos accesibles a través de una raíz. Dichos objetos tampoco son eliminables. Se trata de variables que son accesibles desde el ámbito de uso de las raíces. Puesto que las raíces no son eliminables, los alcanzables tampoco.

Una vez completado este grafo, existirán objetos que no están apuntados por nadie. Éstos objetos son candidatos a ser eliminados en la próxima pasada del GC.
Esto ocurrirá cuando se detecte que la memoria se está acabando. Cuando esto suceda, los objetos que no se usan serán eliminados, los bloques de memoria utilizada serán compactados y se actualizará el NextObjPtr para apuntar a la nueva primera dirección de memoria libre para asignar.
Un detalle importante a tener en cuenta es que la eliminación de objetos no es determinística: un usuario no sabe realmente cuándo un elemento se va a eliminar; un objeto se eliminará… cuando se determine que no se va a usar más y cuando sea necesario liberar memoria.

En el próximo artículo explicaré los destructores (más propiamente llamados en terminología .NET finalizadores) y las generaciones del GC y cómo controlarlas desde un programa. También apuntaré algunas prácticas que pueden ayudar a sacar provecho al GC. Nos leemos.


Applied Microsoft .NET Framework programming, Jeffrey Richter
CLR via C#, Jeffrey Richter