Usando referencias débiles (weak references)

Introducción

Una referencia débil (weak reference) es un concepto estrechamente unido al de recolector de basura, lo cual significa que solo esta presente en aquellos lenguajes que tienen un recolector de basura.

Para clarificar lo que es una referencia débil primero tenemos que explorar (en términos simples) que es el recolector de basura y los fundamentos en los que se basa para trabajar.

Fundamentos del recolector de basura

Los lenguajes imperativos tradicionales siempre han tenido que manejar manualmente la memoria de los tipos por referencia. De esta forma, cada vez que deseabamos obtener una nueva instancia de una clase o una estructura en memoria necesitabamos crearla reservando la memoria necesaria, memoria que quedaba reservada en el heap.

En ese momento la memoria quedaba marcada como usada, de forma que cuando pidieramos otro "trozo" de memoria no obtuvieramos la misma dirección.

Evidentemente, en algún momento, dejabamos de necesitar ese primer fragmento de memoria, de forma que necesitamos decirle al sistema que esa parte del heap ya no nos es necesaria y por lo tante esta disponible. Esta operación se conoce como "liberar la memoria". Y ese es más o menos el problema que ha estado introduciendo bugs en las aplicaciones durante más de tres decadas. Si se nos olvida liberar esa memoria, entonces nunca se queda libre y por tanto nunca la recuperamos ni podemos usarla de nuevo. Con el tiempo (o con un bucle muy rápido) terminamos consumiendo toda la memoria disponible, lo que nos lleva primero a un impacto en el rendimiento de la aplicación y finalmente a que la aplicación se cierre por falta de memoria.

El recolector de basura proporciona una solución a dicho problema haciendose cargo de la liberación de la memoria.

En términos sencillos, puede verse como una tarea en segundo plano que se encarga de recolectar la memoria a la que ya no se puede acceder. Para esto mantiene una lista interna de todos los objetos y todas las referencias a dichos objetos, de forma que, en cualquier momento, puede saber si hay alguna forma de acceder a un objeto o no, y de no haberla, liberar la memoria asociada a dicho objeto.

Referencias fuertes

Suficiente de la introducción al recolector de basura1. Para entender las referencias débiles es necesario introducir primero lo que entendemos por referencias fuertes.

Una referencia débil es otro tipo de referencia frente a las referencias normales, que son lo que llamaríamos referencias fuertes. Un objeto que tenga una referencia fuerte apuntando a él nunca es reclamado por el recolector de basura, así como tampoco lo son aquellos objetos a los que se pueda acceder mediante una cadena de referencias fuertes.

Esta es la forma normal de funcionamiento, que mantiene nuestro objeto vivo hasta que no pueda ser usado más (porque no se puede acceder a él desde el programa). De esta forma si tenemos:

MyClass classInstance = new MyClass();

tenemos una referencia fuerte a la memoria reservada para almacenar la instancia de la clase. En el momento en que esa referencia sale fuera de alcance el recolector de basura es libre de reclamar esa memoria2

Referencias débiles

¿Y qué pasa si queremos una referencia que no este tan ligada a la memoria? ¿Que pasa si queremos hacer una referencia a un objeto pero sin interferir en la capacidad del recolector de basura de liberar ese objeto. En ese caso utilizamos una referencia débil.

Las referencias débiles nos permiten establecer referencias a objetos, pero sin embargo no se consideran dentro de la cadena de referencias, de forma que si a un objeto solo se puede acceder a través de una referencia débil, el recolector de basura sigue siendo libre de reclamarlo aunque esa referencia exista.

Un ejemplo real

Supongamos que tenemos una clase que necesita supervisar información presente en otras clsases.

Por ejemplo, supongamos que tenemos clientes y productos (clases Client y Product) en memoria con algún tipo de mecanismo que los mantiene en una cache, limitando el número de elementos que hay en la caché a un determinado valor. Por ejemplo, mantenemos un máximo de 5000 clientes en memoria pero, si el sistema pide el cliente 5001, seleccionamos uno de los 5000 ya presentes y lo eliminamos, y así mantenemos los clientes en el límite de 5000.

Ahora supongamos que cada producto tiene una referencia al cliente que compró dicho producto de forma que, dado un producto, podamos acceder facilmente al cliente.

public class Client
{
  public UInt32 Id {get; set;}
  public string Name {get; set;}
  public string Surname {get; set;}
  public string Address {get; set;}
}

public class Product
{
  public UInt32 Id {get; set;}
  public string Name {get; set;}
  public double SellPrice {get; set;}
  public Client Owner {get; private set;}
}

public static class Clients
{
  private static Dictionary<string, Client> mClients;

  public static Client FindClient(string clientName)
  {
    // Search in the local list
    if (mClients.ContainsKey(clientName)
      return mClients[clientName];
    else
    {
      // If not found local, retrieve from db
      Client cli = GetClientFromDb(clientName);
     
      // Keep the size of our local data
      if (mClients.Count > MAX_CLIENTS)
        mClients.Remove(mClients.Keys[0]);

      mClients.Add(cli.Name, cli);      

      return cli;
    }
  }
}

De esta forma tenemos una clase (Clients) que da acceso a todos los clientes del sistema y los mantiene en una lista interna que actua como caché, sin permitir que dicha lista crezca más allá de un determinado tamaño. Cuando alcanzamos ese limite el primer elemento de la lista se elimina y, en teoria, se libera.

Sin embargo, con la situación definida arriba, si algún producto tiene un dueño definido, ese cliente nunca será liberado, no mientras el producto existe puesto que existe una referencia (una referencia fuerte) entre el producto y el cliente, de forma que aún puede accederse al cliente a través del producto y por tanto, aunque nuestra lista de clientes siempre tendrá un valor máximo (de 5000 por ejemplo) el número real de clientes en memoria puede ser mucho mayor.

De esta forma, para que el ejemplo funcione correctamente necesitamos transformar la referencia fuerte del producto al cliente en una referencia débil. En .NET esto sería algo como:

public class Product
{
  private WeakReference mClient;

  public UInt32 Id {get; set;}
  public string Name {get; set;}
  public double SellPrice {get; set;}
  public Client Owner
  {
    get
    {
      if (mClient.IsAlive)
        return (Client)mClient.Target;
      else
        return null;
    }
    private set
    {
      mClient = new WeakReference(value);
    }
}

Y ahora nuestra clase mantiene una referencia débil al cliente. En el get de la clase comprobamos si el objeto aún sigue vivo y tan solo si el cliente aún no ha sido recolectado lo devolvemos.

De esta forma, si el recolector de basura reclama el objeto, la propiedad devolverá null pero si el cliente aún no ha sido recolectado entonces aún podremos acceder a él. Esto significa que aún puede haber más de 5000 clientes en memoria pero en este caso, al menos, no estarán interponiendose en el funcionamiento del recolector de basura... siguen ahí sencillamente porque el recolector de basura no ha visto necesario liberarlos.

Rendimiento vs Memoria. Lo mejor de los dos mundos

Vamos a ver otra situación en la que las referencias débiles pueden ser muy útiles.

Supongamos que estamos recuperando cierta información de la base de datos que resulta una operación moderadamente costosa (quizá varios joins o una gran cantidad de objetos). Digamos que la operación devuelve información que se almacena en varios objetos que ocupan 1MB de memoria. Los usamos para lo que sea que los necesitemos y entonces nos encontramos con un dilema: ¿debemos mantenerlos en memoria y evitar volver a realizar esa operación que es tan costosa a costa de ocupar la memoria o debemos ahorrar memoria y sacrificar el rendimiento de tener que realizar la operación cada vez?

Una de las posible soluciones en este caso sería dejar que ess solición la tome la herramienta a cargo del manejo de la memoria, el recolector de basura. Podemos definir nuestros objetos como referencias débiles de forma que si el recolector de basura realmente necesita hacer uso de la memoria, y por tanto necesita hacer espacio, sea libre de usar esos objetos y liberarlos y por tanto perderemos parte o todos esos objetos pero si el recolector de basura no entra en acción, entonces aún los tendremos ahí. Una caché perfecta, que solo es liberada cuando la memoria realmente se necesita.

  1. 1. El recolector de basura es un tema muy complejo que daría para su propio artículo (quizá algún día), para algo de información un poco más detallada la wikipedia española tiene un artículo decente sobre el proceso de recolección (el artículo de la wikipedia inglesa es mucho mejor)
  2. 2. Que el recolector de basura sea libre de reclamar la memoria no significa que lo haga inmediatamente
8
Average: 8 (2 votes)
Your rating: None