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.

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

  1. hola muy interesante tu articulo, recien estoy aprendiendo vb.net…. tengo una consulta ……Limitar el uso de objetos estáticos…..
    a que te refieres con objetos estaticos … te refieres a la declaracion de una clases,metodo, function….tengo entendido que en C# es STATIC y en vb.net es SHARED… no se si te refieres a eso …
    gracias

  2. Sí, los objetos estáticos deben estar limitados porque son instancias que duran todo el tiempo en que el programa está en ejecución (por definición), de manera que el recolector de basura no los puede eliminar. Si abusas de este tipo de objetos, será como si no tuvieras recolector y tu aplicación malgastará recursos.

    Por otra parte, el abuso de objetos estáticos indica una mala comprensión del paradigma O-O, porque un código así se convierte al final en una especie de programación estructurada.

  3. Y por cierto, shared es lo mismo que static en C# y me refiero a objetos estáticos. Un método estático es un método de clase, que es algo totalmente diferente. Conviene limitar el uso de métodos estáticos por diseño, fundamentalmente: un abuso de métodos estáticos (algo que no se aplica a objetos concretos) indica un mal diseño y que se viene de programación estructurada y no se entienden bien los conceptos de O-O.

    En ningún caso digo que esté prohibido el uso de objetos estáticos, ojo; son muy útiles para implementar Singletons, por ejemplo (aunque hay otras formas de implementar un Singleton).

  4. Gracias por responder… ahora tengo mas claro … como dije soy novato .. un poco autodidacta y quiero aprender lo mejor posible por eso me ayuda saber los comentarios de personas con cierta experiencia….
    otra consulta,que no es del tema,pero apelo a tu experiencia …¿ cuando se debe aplicar la programacion en 3 capas ? … en que casos…no he encontrado un articulo que me despeje esa duda…

    saludos

  5. En general casi todas las aplicaciones de gestión, contabilidad, ERPs y demás al uso tendrán esas tres capas. Otra cosa es que se puedan subdividir en más capas. Las aplicaciones de sistemas (como por ejemplo un sistema de control de versiones como el que hacemos en mi empresa) es otra cosa.

    Busca libros de patrones arquitectónicos, te pueden parecer interesantes: Broker, Proxy, MVC, etc.

    Y si te gustan los patrones de diseño, te recomiendo el libro de Gamma et Al.:Patrones de Diseño.

  6. que buena informacion Sr. Ravelus, lo felicito por la ilustracion, pero tengo un problema; resulta que ejecuto una aplicacion vbnet cliente/servidor local, y al dejarla de operar por un rato (10 o 20 minutos…), esta se vuelve lenta…
    Datos: Instancio 3 arraylist shared en el comienzo de la aplicacion(en el form main), para ser compartidos en toda la aplicacion…
    Conexiones a base de datos las abro cierro en los metodos(a nivel local)…

    Que sera esta merma de desempeño?. la aplicacion sigue funcionando pero lenta…, y tengo que cerrarla para que vuelav a su desempeño normal…

    gracias por las apreciaciones….

  7. Necesitaría más información de lo que hace la aplicación.

    Para monitorizar el funcionamiento de una aplicación te recomiendo lo siguiente:

    – Utiliza log: log4net:
    http://logging.apache.org/log4net/

    – Loguea información del coste de las operaciones: Environment.TickCount

    – Utiliza herramientas como:
    ProcessExplorer: http://technet.microsoft.com/en-us/sysinternals/bb896653.aspx
    Profilers, como éste: http://www.jetbrains.com/profiler/

  8. En cuanto a la optimización de recursos, me gustaría saber si hay alguna forma de una aplicación alojada en un servidor que utiliza una serie de frameworks, cuando es ejecutada desde diferentes clientes que sólo se carguen esos frameworks una única vez…ya que sino cada cliente que la ejecute estará alojando en memoria dichas frameworks cuando con que estén una vez alojadas sería suficiente….

    Gracias

Replica a toni martin Cancelar la respuesta