[C#] ¿Cómo funciona el Garbage Collector? ( II )

En el artículo anterior introduje brevemente qué mecanismos disponemos para liberar los recursos de los objetos y cómo actúa GC sobre ellos. Normalmente para un desarrollador GC es algo que está ahí pero no necesita trastear. Pero para aplicaciones más avanzadas, sistemas críticos o debug/testeo suele ser muy útil.

Liberando memoria

El GC tiene la función de liberar la memoria de los objetos. Esto puede ocurrir en los siguiente casos:

  • Limitaciones técnicas de la máquina: Tenemos demasiada memoria ocupada y alguien tiene que liberarla. Si es nuestra, el GC se encargará de ello.
  • Los objetos que tenemos en el Heap se acumulan y excede el máximo permitido. El GC se dispara y recolecta.
  • O directamente, porque lo invocamos explícitamente.

Veamos ejemplos de los dos últimos casos.

Para el primer ejemplo, simplemente tenemos el siguiente código:

   1: for (int i = 0; i < 100000; i++)

   2: {

   3:     var customObject = new CustomObject();

   4:  

   5:     System.Console

   6:         .Write(string.Format("Iteration {0}; Memory {1}n", i, GC.GetTotalMemory(false)));

   7: }

Donde tenemos un bucle que va instanciando los objetos. Al instanciar un objeto, se reserva la memoria necesaria para él y se crea la instancia. Ese objeto permanecerá en memoria hasta que el GC estime oportuno. Vamos a ver cómo a medida que se incrementa el número de iteraciones la memoria asignada por el GC irá aumentando hasta que decida liberarla.

En el segundo ejemplo, forzamos a través de GC.Collect() que cada vez que se crea el objeto, invocamos al GC para que recorra todo su grafo de objetos y elimine aquellos que no se van a usar:

   1: for (int i = 0; i < 100000; i++)

   2: {

   3:     var customObject = new CustomObject();

   4:     GC.Collect();

   5:     System.Console

   6:        .Write(string.Format("Iteration {0}; Memory {1}n", i, GC.GetTotalMemory(false)));

   7: }

Y como resultado para esta muestra obtenemos la siguiente gráfica:

image

 

Donde podemos observar como en el primer ejemplo el GC acumula memoria hasta que la libera según estima oportuno mientras que en el segundo caso la cantidad de memoria ocupada permanece estable. Evidentemente esta recolecta forzada no sale gratis, veamos ahora en escala de tiempo cuánto repercute. Como el coste por iteración es despreciable, a continuación muestro qué ocurre si invocamos el GC o no en bloques de iteraciones:

image

Podemos ver una diferencia de tiempo de orden exponencial. Es decir, debemos seguir las recomendaciones dadas de dejar que GC trabaje y nosotros no interactuar con él salvo que tengamos razones muy justificadas para ello.

Generaciones

Una vez que hemos visto cuándo se dispara y qué ocurre cuando se dispara, vamos a ver cómo esta distribuido. Aquí introduzco el tema de “Generaciones”, que es la estructura de distribución de objetos que dispone GC:

  • Generación 0: Contiene los objetos que menos duran, como variables temporales. Inicialmente todo objeto irá directamente a este nivel de generación.
  • Generación 1: Contiene objetos de corta duración y actúa de buffer entre objetos de corta y larga duración.
  • Generación 2: Contiene objetos de larga duración. Por ejemplo, variables estáticas a nivel de aplicación.

Todo objeto en función de su uso y otros factores, se van moviendo en las respectivas generaciones. Como ejemplo se puede ver cómo se obtiene la generación de un determinada instancia:

   1: GC.GetGeneration(customObject);

Esto devuelve 0,1 ó 2 en función de la generación de esa instancia. Además de el objeto en sí, podemos pasarle una WeakReference directamente.

También podemos conocer cuántas veces se ha invocado el GC en una generación concreta a través de este método:

   1: GC.CollectionCount(0);

Los objetos que no son reclamados al GC se llaman “supervivientes”. Si están en la generación 0 y no han sido reclamados, pasan a la 1. Si están en la 2, siguen en la 2. El GC además detecta estos casos como algo particular: si cada vez hay más supervivientes, balancea el algoritmo para lograr un equilibrio entre el consumo de memoria y el tiempo de ejecución del mismo.

El algoritmo sigue el siguiente proceso:

  • En una primera fase se busca y se crea una lista con todos los objetos vivos.
  • Después se actualizan las referencias de los objetos y se relocalizan.
  • Por último, una fase de compactación de memoria que agrupa el espacio disponible junto con el liberado por los objetos que ya no existen. La compactación siempre se hará salvo para aquellos casos en que los objetos sean demasiado grandes. Se puede recurrir a esta propiedad del GC para forzar que los objetos grandes siempre se compacten (sólo disponible a partir del 4.5.1).

Antes de que el GC se dispare, todos los hilos de ejecución de la aplicación se suspenden para poder activarse el hilo dedicado al GC. En ese momento el GC aplica el algoritmo y el proceso mencionado anteriormente. Cuando termina, el resto de hilos prosiguen en su ejecución:

When a thread triggers a Garbage Collection

[C#] ¿Cómo funciona el Garbage Collector? ( I )

Una de las principales diferencias respecto a C/C++ y similitudes con Java es la presencia del Garbage Collector (GC) que permite delegar en un proceso la gestión de los objetos que creamos. A priori nos olvidamos de tener que invocar de forma explícita el destructor de los objetos y liberar su memoria. Veamos qué ocurre realmente.

El GC aparece en la primera versión de .NET Framework. Su funcionamiento permite que hagamos cosas como estas:

   1: class CustomObject

   2: {

   3:     public byte[] ByteArray { get; set; }

   4:  

   5:     public CustomObject()

   6:     {

   7:         this.ByteArray = new byte[1024];

   8:     }

   9: }

Vemos que creamos un objeto array de una longitud determinada y al finalizar el programa podemos comprobar a través de herramientas que no tenemos fugas de memoria. Si analizamos el código CIL generado podemos ver que no aparece tampoco ninguna mención a destruir nada. El GC, a través del Framework se encarga todo. Todo totalmente transparente.

Para seguir hablando de GC es necesario introducir el concepto básico de memoria en Stack y en el Heap. Para tratar este tema aconsejo este artículo y su segunda parte que tratan sobre el diseño e implementación del stack. Como añadido, este no vendrá mal. El resumen sencillo y algo banal es que todo tipo que se instancia irá alojado en el Heap que es autogestionado. Es decir, hay alguien que se encarga de una vez instanciado un objeto que ya se ha usado, determinar qué hacer con él.

Finalizadores

La primera aproximación siguiendo un paradigma OO será que al igual que un objeto tiene constructor debe tener un destructor. Al igual que en C++, C# posee este método siguiendo la misma nomenclatura para cualquier tipo de objeto:

   1: class CustomObject

   2:         {

   3:             public byte[] ByteArray { get; set; }

   4:  

   5:             public CustomObject()

   6:             {

   7:                 System.Console.Write("Object created");

   8:             }

   9:  

  10:             ~CustomObject()

  11:             {

  12:                 System.Console.Write("Finalizer invoked");

  13:                 System.Console.Read();

  14:             }

  15:         }

Donde podremos definir qué ocurre una vez se “destruye” el objeto. Realmente no se destruye, puesto que en C# se llama “finalizador”. Se finaliza la vida del objeto pero este sigue estando vivo. Simplemente funciona así porque el GC tiene varias fases que veremos más adelante. Pero analicemos este hecho un poco más en profundidad. Si obtenemos el código IL generado observamos el siguiente método:

   1: .method family hidebysig virtual instance void 

   2:         Finalize() cil managed

   3: {

   4:   // Code size       31 (0x1f)

   5:   .maxstack  1

   6:   .try

   7:   {

   8:     IL_0000:  nop

   9:     IL_0001:  ldstr      "Finalizer invoked"

  10:     IL_0006:  call       void [mscorlib]System.Console::Write(string)

  11:     IL_000b:  nop

  12:     IL_000c:  call       int32 [mscorlib]System.Console::Read()

  13:     IL_0011:  pop

  14:     IL_0012:  nop

  15:     IL_0013:  leave.s    IL_001d

  16:   }  // end .try

  17:   finally

  18:   {

  19:     IL_0015:  ldarg.0

  20:     IL_0016:  call       instance void [mscorlib]System.Object::Finalize()

  21:     IL_001b:  nop

  22:     IL_001c:  endfinally

  23:   }  // end handler

  24:   IL_001d:  nop

  25:   IL_001e:  ret

  26: } // end of method CustomObject::Finalize

Para IL no existe el concepto de destructor. Existe y transforma lo que llamaríamos un destructor en un finalizador, en un método llamado Finalize() que alguien invocará. De hecho, si intentamos declarar lo siguiente:

   1: protected override void Finalize()

   2: {

   3:     //...

   4: }

Vemos como el propio compilador no te permite declarar ese método y te sugiere que declares un destructor.

¿Qué se debe escribir en un finalizador? Liberación de recursos (streams, conexiones, etc) y al ser crítico, un código que no deba fallar ni provocar excepciones. Y la siguiente pregunta es: ¿no estaba IDisposable para esto? En efecto, en la página de MSDN nos cuentan que IDisposable sirve para liberar los recursos que no se pueden gestionar porque GC, al no ser determinístico no sabemos cuándo podrá lanzarse. Y además como son recursos que no se pueden gestionar, GC los ignorará. Típicos ejemplos de esto son los streams. Y por eso se recomienda cada vez que abrimos un stream, insertarlo dentro de un using. Cuando finaliza el using se invoca de forma automática al método Dispose(). Algo se vio en un artículo anterior.

Un ejemplo sencillo de cómo funciona y cómo implementar correctamente Dispose lo tenemos en el siguiente fragmento de código:

   1: class DisposableObject : IDisposable

   2:         {

   3:             public void Dispose()

   4:             {

   5:                 this.Dispose(true);

   6:                 GC.SuppressFinalize(this);

   7:             }

   8:  

   9:             protected virtual void Dispose(bool disposing)

  10:             {

  11:                 if (disposing)

  12:                 {

  13:                     // Dispose on each object of this instance.

  14:                     // ...

  15:                 }

  16:             }

  17:  

  18:             ~DisposableObject()

  19:             {

  20:                 this.Dispose(false);

  21:             }

  22:         }

Al implementar la interfaz IDisposable tenemos que implementar el método Dispose(). El patrón ofrece una estructuración de las llamadas a Dispose() del objeto actual y los objetos que se integren, de modo que si invocamos Dispose() se invocará el Dispose() de cada uno de los objetos que hayamos ido añadiendo. Sin embargo si se invoca al finalizador a través del “destructor”, el GC seguirá su curso. Nótese la diferencia entre Dispose() y el finalizador:

– Dispose(): Primero invoca al método protegido para que se invoquen en cascada los Dispose de cada objeto y posteriormente, se indica al GC que no es necesario que invoque al finalizador en este instante puesto que ya hemos liberado los recursos. ¿Necesario? No. ¿Optimizado? Un poco, puesto que ahorramos llamadas al GC.

– ~(): Simplemente invoca al Dispose(false) que no debería invocar al Dispose de los objetos internos. Desde el punto de vista nuestro respecto al GC, no sabemos en qué estado están y ya es función del GC determinar si debe ir a por ellos o no.

Siguiendo con la pregunta de ¿qué diferencia hay entre un finalizador y IDisposable? La respuesta a nivel teórico es: ninguna. Ambos están pensados para liberar aquellos recursos que empleamos en los objetos. La única diferencia es que tenemos un mecanismo para controlar cuándo lo llamamos si empleamos IDisposable; porque el finalizador, al depender de GC no podemos esperar nada de él. No sabemos cuándo será llamado.

A modo de resumen tenemos lo siguiente:

  • En C# NO podemos destruir los objetos. Como mucho podemos indicar qué ocurre cuando tienen algún recurso asociado y liberarlo.
  • IDisposable no se invoca sólo. Hay que invocarlo. E invocarlo, no significa destruir el objeto.
  • ~() Es el método finalizador del objeto. Sólo es invocado a través del GC y cuando éste lo estima oportuno.