WeakReference en C#

¡Hola!
Hoy vamos a ver cómo podemos utilizar la clase WeakReference para obtener referencias débiles de nuestros objetos. Vamos a ver qué son las referencias fuertes y débiles, algunas cosas sobre el recolector de basura y cómo afecta a nuestras referencias. Antes que nada quiero avisar que es posible que algún concepto no sea correcto del todo pero en conjunto creo que es una visión más o menos válida. Sin más preámbulos, ¡vamos allá!
¿Qué quiere decir que una referencia sea fuerte o débil? Nos vamos a imaginar que inicializamos una variable de tipos User. El tipo User tiene el siguiente aspecto:

1
2
3
4
5
6
7
8
9
10
    public class User
    {
        public string Name { get; }
        public byte[] Data { get; set; }
 
        public User(string name)
        {
            this.Name = name;
        }
    }

Muy simple como vemos. Como decíamos, vamos a pensar que creamos un nuevo usuario.

1
    var user = new User("Pepe");

Al hacer esto estamos creando una referencia fuerte. Como sabemos, el nuevo usuario será almacenado en el montón (heap) y nuestra variable user (que se almacena en la pila), tiene una referencia al usuario que se ha creado en el montón (aquí un excelente artículo que explica cómo funciona la pila y el montón, ¡y en castellano!). Vamos a crear ahora una nueva variable User y la igualamos a nuestro user actual:

1
2
    var user = new User("Pepe");
    var otherUser = user;

No es nada nuevo que otherUser no es una copia de la variable user, sino que la variable otherUser (que también se almacena en la pila) apunta al usuario que se ha creado en el montón. Por supuesto, todo esto es así porque User es una clase (tipo por referencia). ¿Y qué tiene que ver esto con las referencias fuertes/débiles y el recolector de basura? En este caso el recolector de basura eliminará el objeto user (el dato real almacenado en el montón), cuando nadie haga referencia a él. Ahora mismo user y otherUser hacen referencia al valor almacenado en el montón, por lo que digamos el contador está en +2. Hasta que ese contador no sea 0 el recolector de basura no va a poder eliminar el usuario, es decir, hasta que entienda que no hay referencias fuertes o sepa que no va a volver a usarse, no lo destruirá. Vamos a ver un ejemplo en una aplicación de consola:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    class Program
    {
        static void Main(string[] args)
        {
            WriteCurrentAllocatedMemory("Inicio de la aplicación");
 
            User user = new User("Pepito")
            {
                Data = new byte[1024 * 1024 * 1024]//1GB
            };
 
            WriteCurrentAllocatedMemory("Usuario creado");
            user = null;
            WriteCurrentAllocatedMemory("Usuario = null");
 
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
            WriteCurrentAllocatedMemory("Recolector de basura forzada");
 
            Console.ReadLine();
        }
 
        private static void WriteCurrentAllocatedMemory(string message)
        {
            var allocatedMemoryInMB = GC.GetTotalMemory(false) / (1024 * 1024);
            Console.WriteLine(string.Concat(allocatedMemoryInMB, "MB asignados - ", message));
        }
    }

Muy simple. Crea un usuario con 1GB de datos, lo establece a null y pasa el recolector de basura. Antes y después de cada etapa imprime los MB consumidos por la aplicación en ese momento. Vamos a ver el resultado:
Mmmmm, algo está pasando, no está liberando bien la memoria 🙁 Lo que ocurre es que estamos ejecutando en modo Debug, y debemos hacerlo en modo Release. El problema es que en modo Debug hay ciertas cosas que se omiten para facilitar la depuración, por lo que a partir de ahora todos los ejemplos que veamos están en modo Release. Lo vamos a ejecutar ahora en modo release:
Ahora los resultados sí que son correctos. Inicialmente no tenemos MB asignados (realmente sí hay, pero fíjate que en la cuenta que se hace en WriteCurrentAllocatedMemory los perdemos), tras crear el usuario tenemos 1GB ocupado, al hacerlo null seguimos con 1GB en memoria y cuando pasa el recolector de basura, como no hay nadie que haga referencia a los datos de user, se liberan y la memoria vuelve a 0. Mmmmm, espera un momento, vamos a eliminar del código la línea donde hacer user = null.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    WriteCurrentAllocatedMemory("Inicio de la aplicación");
 
    User user = new User("Pepito")
    {
        Data = new byte[1024 * 1024 * 1024]//1GB
    };
 
    WriteCurrentAllocatedMemory("Usuario creado");
    //user = null;    //WriteCurrentAllocatedMemory("Usuario = null");
 
    GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
    WriteCurrentAllocatedMemory("Recolector de basura forzada");
 
    Console.ReadLine();

Ahora lo ejecutamos y vemos el resultado:
Pues tenemos exactamente el mismo resultado que en el caso anterior. ¿Qué está pasando? Lo que ocurre es el optimizador sabe que, aunque user no se establezca a null, sí puede ser eliminado por el recolector de basura porque dentro de su ámbito (se declara en la propia función Main) no se va a volver a utilizar en ningún caso. Podemos hacer varias cosas para corregir esto. Una de ellas es utilizar la variable un poco más abajo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    WriteCurrentAllocatedMemory("Inicio de la aplicación");
 
    User user = new User("Pepito")
    {
        Data = new byte[1024 * 1024 * 1024]//1GB
    };
 
    WriteCurrentAllocatedMemory("Usuario creado");
    //user = null;    //WriteCurrentAllocatedMemory("Usuario = null");
 
    GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
    WriteCurrentAllocatedMemory("Recolector de basura forzada");
 
    Console.WriteLine(user.Name);    Console.ReadLine();

Lo ejecutamos:
Ahora sí que obtenemos el resultado correcto, es decir, como no ponemos a null el usuario y lo utilizamos más abajo no puede liberarlo. Realmente lo que se suele es utilizar un método que hace que el objeto se mantenga vivo desde el principio de la función hasta el momento en que se llame, y es GC.KeepAlive (en nuestro caso cambiaríamos la línea Console.WriteLine(user.Name) por GC.KeepAlive(user)). Realmente este método no hace nada, pero consigue que el recolector de basura no recolecte nuestro usuario si realmente no es null. Otra opción para evitar que el optimizador haga que el recolector de basura elimine nuestro usuario aunque no lo pongamos a null es sacar la variable fuera del ámbito de la función:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    private static User user;
    static void Main(string[] args)
    {
        WriteCurrentAllocatedMemory("Inicio de la aplicación");
 
        user = new User("Pepito")
        {
            Data = new byte[1024 * 1024 * 1024]//1GB
        }; 
        WriteCurrentAllocatedMemory("Usuario creado");
        //user = null;
        // WriteCurrentAllocatedMemory("Usuario = null");
 
        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
        WriteCurrentAllocatedMemory("Recolector de basura forzada");
 
        Console.ReadLine();
    }

El resultado también sería correcto así, es decir, el optimizador no podría optimizarlo 🙂
Ahora claro, nos puede surgir la duda si en el primer caso que vimos (cuando sí poníamos a null el usuario) no sería cosa del optimizador. Si ponemos la variable user fuera del ámbito de la función no lo podrá optimizar, pero por si no te fías, vamos a hacer que el usuario se establezca a null con una condición que el optimizador no pueda saber si se cumple:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    WriteCurrentAllocatedMemory("Inicio de la aplicación");
 
    var user = new User("Pepito")
    {
        Data = new byte[1024 * 1024 * 1024]//1GB
    };
 
    WriteCurrentAllocatedMemory("Usuario creado");
    if (DateTime.Now.Year > 1900)        user = null;    WriteCurrentAllocatedMemory("Usuario = null");
 
    GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
    WriteCurrentAllocatedMemory("Recolector de basura forzada");
 
    Console.ReadLine();

El usuario se establece a null si el año actual es mayor que 1900 que, a no ser que tengas la fecha un poco atrasada, se cumplirá siempre. Tú y yo sabemos que esa condición es true, pero el optimizador no, por lo tanto, veamos si estábamos en lo cierto:
El resultado es correcto, se libera correctamente la memoria y tenemos la certeza que no es cosa del optimizador.

WeakReference

Ahora que ya hemos visto algunas cosas sobre cómo y cuándo actúa el recolector de basura, vamos a ver qué son las referencias débiles (WeakReference). En primer lugar, vamos a ver que tenemos dos formas de crearlas, WeakReference y WeakReference<T>. Como te puedes imaginar, la segunda opción es la versión tipada de la primera. Cuando creamos una WeakReference le pasamos el objetivo del que queremos obtener una referencia débil, y nos va a permitir comprobar si la referencia todavía está viva y, obviamente, obtener el valor de la referencia. Vamos a ver un ejemplo en la versión no tipada:

1
2
3
4
5
6
    User user = new User("Pepito");
    System.WeakReference weakReference = new System.WeakReference(user);
    if (weakReference.IsAlive)
    {
        User userFromWeakReference = (User)weakReference.Target;
    }

Muy sencillo de utilizar. Pasamos nuestro usuario (target) por constructor al crear la WeakReference y simplemente tiene dos propiedades, una que nos indica si todavía sigue viva nuestra referencia (IsAlive) y otra para obtener el valor de la referencia (lo tenemos que castear ya que devuelve un object). Realmente tiene otra sobrecarga el constructor que, además de pasar el target, le podemos indicar si trackResurrection es true o false (por defecto es false), esto lo veremos en otro post (o no..). La versión con generics es muy similar:

1
2
3
4
5
6
    User user = new User("Pepito");
    WeakReference<User> weakReference = new WeakReference<User>(user);
    if (weakReference.TryGetTarget(out User userFromWeakReference))
    {
        //Aquí tendríamos la referencia a user con userFromWeakReference
    }

En esencia lo mismo que la versión no tipada.
¿Qué conseguimos cuando creamos una WeakReference asociada a un objeto? El tener una referencia débil permite al recolector de basura recolectar el objeto en cualquier momento y, hasta ese momento, nos permite obtener el valor del objeto. Vamos a ver código y seguramente nos será más fácil entenderlo. Antes veíamos que, hasta que todas las variables que apuntaban a nuestro usuario no las estableciésemos a null y pasásemos el recolector de basura, no se eliminaban los datos de nuestro usuario que estaban en el montón (no se liberaba ese GB de memoria):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    private static User user;
    private static User strongUserReference;
    static void Main(string[] args)
    {
        user = new User("Pepe")
        {
            Data = new byte[1024 * 1024 * 1024]
        };
        strongUserReference = user;
 
        //La aplición tiene 1GB de memoria reservada
 
        user = null;
 
        //Todavía hay 1GB de memoria asignado
        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
        //Todavía hay 1GB de memoria asignado ya que strongUserReference es una referencia fuerte
 
        strongUserReference = null;
        WriteCurrentAllocatedMemory("pur");
        //Ya no hay ninguna referencia fuerte a los datos del usuario pero todavía no se ha liberado el GB
        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
        //Aquí ya se ha liberado el GB de memoria
 
        Console.ReadLine();
    }

Aquí, como vemos, hasta que no hemos eliminado todas las referencias a los datos del usuario y pasamos el recolector no se elimina el GB de memoria que el user tiene reservado. Lo que nos va a permitir una WeakReference es que no cuente como referencia fuerte, es decir, en el momento que la única referencia que haya a los datos del usuario sea la propia WeakReference, el recolector de basura podrá recolectar el usuario (ese GB que tenía reservado). Vamos a cambiar el código anterior sustituyendo la referencia fuerte strongUserReference por una referencia débil:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    private static User user;
    private static WeakReference weakUserReference;
    static void Main(string[] args)
    {
        user = new User("Pepe")
        {
            Data = new byte[1024 * 1024 * 1024]
        };
        weakUserReference = new WeakReference(user);
 
        //La aplición tiene 1GB de memoria reservada
 
        user = null;
 
        //Todavía hay 1GB de memoria asignado, pero como ya no queda ninguna referencia fuerte
        //a la información del usuario, en cuanto actúe el recolector de basura se liberará la memoria
        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
        //Aquí ya se ha liberado el GB de memoria
 
        Console.ReadLine();
    }

Se ve bastante claro. En cuanto hacemos null a nuestra variable user, los datos que contiene ya son susceptibles de ser recolectados. En otras palabras, en el momento en que una referencia débil es lo único que hace referencia a un objeto, el GC es libre de recolectar ese objeto. Por si queda alguna duda, analicemos el siguiente código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    private static WeakReference weakUserReference;
    static void Main(string[] args)
    {
        weakUserReference = new WeakReference(new User("Pepe")
        {
            Data = new byte[1024 * 1024 * 1024]
        });
 
        //La aplicación tiene 1GB de memoria asignada, pero realmente ese GB es susceptible
        //de ser eliminado por el recolector de basura en cualquier momento
        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
        //Aquí ya se ha liberado el GB de memoria
        Console.ReadLine();
    }

Creamos directamente el usuario en el constructor del WeakReference y de esta manera no existe ninguna referencia fuerte a los datos. ¿Qué quiere decir esto? Pues que en cuanto el recolector de basura recolecte, el usuario será eliminado y no podrá ser accesible, es decir, sólo vamos a poder acceder al usuario entre el momento de creación y hasta el instante en que el recolector de basura recolecte. En los ejemplos que estamos viendo siempre forzamos la recolección de basura pero esto no es lo habitual, el recolector de basura será ejecutado automáticamente cuando sea necesario y no vamos a poder saber a ciencia cierta cuándo ocurrirá.
Realmente WeakReference no es algo que se utilice habitualmente, pero tiene sus usos como todo. En la creación del usuario que estamos haciendo (que no tiene demasiado sentido), si nos fijamos, la creación del usuario en sí es muy rápida (nuestro new User(){Data = new byte[1024 * 1024 * 1024]}), pero el usuario ocupa muchísima memoria (al menos 1GB). Esto caso es un ejemplo típico de WeakReference, ya que mantenemos una referencia débil que en cualquier momento podrá ser recopilada por el recolector de basura y, si eso ocurre, vamos a poder recrear ese usuario sin casi coste (en cuanto a tiempo). Un ejemplo típico es en Windows Forms. Nos imaginamos que estamos en cierta parte de la aplicación donde se hace un uso intensivo de imágenes y hace que se ocupe mucha memoria. En cuanto vamos a otra parte de la aplicación queremos liberar esas imágenes para que no ocupen tanto. Una opción sería que, cuando nos movemos a otra parte de la aplicación, guardemos esas imágenes como WeakReference y de esta manera hacemos que cuando el recolector de basura recolecte, sean destruidas; pero hasta ese momento si volvemos a la parte de la aplicación donde están las imágenes, no sea necesario volver obtenerlas. Seguramente esto lo podríamos hacer igualmente cacheando los datos y eliminándolos cuando queramos, es decir, de forma determinista (transcurrido cierto tiempo, por ejemplo), ya que hay que tener en cuenta que si lo hacemos con WeakReferences no podemos saber en qué momento serán eliminadas, no es predecible.
Como resumen final, es muy posible que nunca necesites utilizarlo, pero entender cómo funciona hace que entendamos un poco más cómo funciona el recolector de basura y cuándo se libera la memoria. Quedan bastantes cosas por hablar al respecto así que es posible que escriba otro artículo, pero no lo aseguro 🙂

¡Un saludo!

One thought on “WeakReference en C#”

  1. Muy buen artículo. Mi recomendación personal es que nunca se debe de llamar al recolector de basura salvo en escenarios muy concretos.

    Sobre WeakReference lo puedes utilizar para implementar una caché donde los elementos pueden ser eventualmente liberados por el recolector.

Deja un comentario

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