Object Pooling – C#

Introducción

Como norma general la instanciación de objetos puede ser una operación costosa en situaciones donde se requiera instanciar un gran número de clases y destruirlas en momentos posteriores del ciclo de vida (o ser recogidos por el Garbage Collector en lenguajes donde esta tarea se lleva a cabo de forma autónoma), pudiendo llegar a tener cierto impacto en el rendimiento de la aplicación en los momentos donde exista un gran volumen de peticiones de instanciación.

Una técnica extendida para evitar este proceso cíclico de instanciación y recolección de objetos desechados es la conocida como Object Pooling, que permite instanciar un número limitado de objetos, mantenerlos en memoria durante su uso y ser reciclados posteriormente en lugar de destruirlos y volver a instanciarlos.

Implementación de Pool Genérica

El esquema básico para una posible implementación de una pool genérica de objetos contendría:

  • Una estructura de datos tipo Queue<T> (First-In, First-Out) donde almacenamos los objetos disponibles para su uso.
  • Un contador implementado como una propiedad { get; set; } que permita llevar un registro de los objetos actualmente utilizados.
  • Una propiedad que represente el tamaño máximo de la pool de objetos y que limite la cantidad total de objetos contenidos.
  • Un método público para obtener de la pool el siguiente objeto que utilizaremos en nuestra aplicación.
  • Un método para añadir nuevos objetos a la Queue<T> de objetos disponibles para ser utilizados.
  • Un método para reciclar los objetos después de ser utilizados y devolverlos a la pool.
public class GenericPool<T> where T: class, new() {
    public int inUse { get; set; }
    private Queue<T> pool;
    private int maxSize {get; set; }


    public GenericPool(int maxSize){
        pool = new Queue<T>();
            inUse = 0;
            this.maxSize = maxSize;
        }

        public void AddToPool(T o){
            pool.Enqueue(o);
        }

        public T GetNext(){
            T obj;
            //Obtener nuevo desde la pool
            if(pool.Count > 0){
                obj = pool.Dequeue();
                inUse++;
                return obj;
            } 
            //Generar nuevo 
            //si no se ha llegado al límite maxSize
            else if(pool.Count == 0 && inUse < maxSize){
                obj = new T();
                inUse++;
                return obj;
            }
            return null;
        }

        public int Recycle(T o){          
            pool.Enqueue(o);
            inUse--;
            return inUse;
        }

        public int GetAvailableCount() => pool.Count;
        public int GetTotalPopulation() => pool.Count+inUse; 
    }

La clase está compuesta por un constructor público que se encarga de inicializar los campos y propiedades de la clase, especialmente la estructura Queue<T> que hace las veces de pool. El método AddToPool(T o) simplemente se encarga de llamar a Queue.Enqueue() para añadir objetos al final de la cola. El método GetNext() comprueba que la cola no está vacía if(pool.Count > 0) y trata de obtener el primer elemento de la cola mediante Queue.Dequeue() si ésta tiene elementos; en el caso de que esté vacía (todas las instancias están siendo utilizadas) pero el número de elementos en uso es menor al máximo de elementos posibles (inUse < maxSize) creará un nuevo objeto (T obj = new T()). Finalmente, para obtener la población total de instancias sumamos las que están almacenadas en la cola con las que están siendo utilizadas GetTotalPopulation() => pool.Count+inUse;


En determinadas aplicaciones nos interesa marcar los objetos utilizados al extraerlos de la pool mediante una ID que nos permita realizar un seguimiento y control de las instancias. Una forma de añadir esta funcionalidad es implementando una interfaz sencilla:

public interface IPoolableEntity{
    int InstanceID { get; set; }
}

La interfaz simplemente exige que los objetos almacenados implementen una propiedad que se encarga de registrar la ID que identifica a la instancia en todo momento desde su extracción de la pool hasta su devolución tras ser reciclada. En este caso, tendríamos que modificar la cabecera de la definición de la clase que implementa la pool:

public class GenericPool where T: class, IPoolableEntity, new() { ...

La nueva definición de GenericPool pide que el tipo genérico T sea una clase que implemente la interfaz IPoolableEntity y que tenga un constructor vacío new().

Por otro lado, tendríamos que modificar el método GetNext() para asignar una ID a la instancia en el momento de su extracción de la Queue<T> por medio de un método auxiliar llamado GetInstanceID().

public T GetNext(){
    T obj;
    //Obtener nuevo desde la pool
    if(pool.Count > 0){
        obj = pool.Dequeue();
        inUse++;

        //Marcar objeto con InstanceID 
        obj.InstanceID = GetInstanceID();

        return obj;
    } 
    //Generar nuevo 
    //si no se ha llegado al límite
    else if(pool.Count == 0 && inUse < maxSize){
        obj = new T();
        inUse++;        

        //Marcar objeto con InstanceID 
        obj.InstanceID = GetInstanceID();

        return obj;
    }
}

El método GetInstanceID() lleva un control de las ID utilizadas mediante una Queue<int> auxiliar de donde recogemos las ID de las instancias que hayan sido recicladas en el caso de tener suficientes objetos, o bien utilizamos el siguiente número natural en el caso de tener que instanciar una nueva entidad si no hemos llegado al límite maxSize. Por tanto, el método es simplemente:

public int GetInstanceID() => (recycledInstaceID.Count > 0) ? recycledInstaceID.Dequeue() : inUse;

Donde recycledInstaceID es el mencionado Queue<int> donde almacenamos las ID de las instancias recicladas. Mediante el operador ternario comprobamos si existen ID disponibles (recycledInstaceID.Count > 0) y, en el caso de ser así, obtenemos la siguiente mediante Dequeue(); en caso contrario, simplemente utilizamos el contador inUse como ID.

Este método garantiza una identificación automática de las instancias en uso sin que se repita ninguna ID, pero para su correcto funcionamiento es necesario un último cambio en el método de reciclaje dado que ahora no sólo hay que reciclar la instancia sino también su ID.

public int Recycle(T o){    
    pool.Enqueue(o);
    RecycleInstanceID(o.InstanceID);
    inUse--;
    return inUse;
}
public void RecycleInstanceID(int instanceID){
    recycledInstaceID.Enqueue(instanceID);
}

Con estos últimos cambios tendríamos nuestro sistema de Object Pooling con autoidentificación de las instancias en uso.


En este artículo se ha examinado una implementación sencilla de un sistema genérico de Object Pooling y se ha propuesto una variación para escenarios donde sea necesario que las instancias incluyan una ID para su seguimiento y registro. La mayoría de frameworks incluyen sus propios sistemas de pooling para que el usuario no tenga que programarlos por su cuenta; no obstante, es conveniente comprender su funcionamiento interno para poder implementarlo por nuestra cuenta en casos donde se requieran funcionalidades específicas o simplemente no exista una sistema de pooling por defecto.


Referencias