[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.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *