Esta pregunta es un poco más complicada de lo que uno podría esperar debido a varias incógnitas: el comportamiento del recurso que se está agrupando, la vida útil esperada / requerida de los objetos, la razón real por la que se requiere el grupo, etc. Por lo general, los grupos son de propósito especial - hilo grupos, grupos de conexión, etc., porque es más fácil optimizar uno cuando se sabe exactamente qué hace el recurso y, lo que es más importante, tiene control sobre cómo se implementa ese recurso.
Como no es tan simple, lo que he intentado hacer es ofrecer un enfoque bastante flexible con el que pueda experimentar y ver qué funciona mejor. Disculpas de antemano por la larga publicación, pero hay mucho terreno por cubrir cuando se trata de implementar un grupo de recursos decente de propósito general. y realmente solo estoy rascando la superficie.
Un grupo de uso general tendría que tener algunas "configuraciones" principales, que incluyen:
- Estrategia de carga de recursos: ansiosa o perezosa;
- Mecanismo de carga de recursos : cómo construir uno realmente;
- Estrategia de acceso: usted menciona "round robin", que no es tan sencillo como parece; Esta implementación puede usar un búfer circular que es similar , pero no perfecto, porque el grupo no tiene control sobre cuándo se recuperan los recursos. Otras opciones son FIFO y LIFO; FIFO tendrá más de un patrón de acceso aleatorio, pero LIFO hace que sea significativamente más fácil implementar una estrategia de liberación menos utilizada recientemente (que usted dijo que estaba fuera de alcance, pero aún vale la pena mencionarla).
Para el mecanismo de carga de recursos, .NET ya nos da una abstracción limpia: delegados.
private Func<Pool<T>, T> factory;
Pase esto a través del constructor de la piscina y ya estamos listos para eso. El uso de un tipo genérico con una new()
restricción también funciona, pero esto es más flexible.
De los otros dos parámetros, la estrategia de acceso es la bestia más complicada, por lo que mi enfoque fue utilizar un enfoque basado en herencia (interfaz):
public class Pool<T> : IDisposable
{
// Other code - we'll come back to this
interface IItemStore
{
T Fetch();
void Store(T item);
int Count { get; }
}
}
El concepto aquí es simple: dejaremos que la Pool
clase pública maneje los problemas comunes como la seguridad de subprocesos, pero usaremos un "almacén de elementos" diferente para cada patrón de acceso. LIFO se representa fácilmente mediante una pila, FIFO es una cola y he utilizado una implementación de búfer circular no muy optimizada pero probablemente adecuada utilizando un List<T>
puntero e índice para aproximar un patrón de acceso de round-robin.
Todas las clases a continuación son clases internas de Pool<T>
: esta fue una elección de estilo, pero dado que realmente no están destinadas a usarse fuera del Pool
, tiene más sentido.
class QueueStore : Queue<T>, IItemStore
{
public QueueStore(int capacity) : base(capacity)
{
}
public T Fetch()
{
return Dequeue();
}
public void Store(T item)
{
Enqueue(item);
}
}
class StackStore : Stack<T>, IItemStore
{
public StackStore(int capacity) : base(capacity)
{
}
public T Fetch()
{
return Pop();
}
public void Store(T item)
{
Push(item);
}
}
Estos son los obvios: apilar y hacer cola. No creo que realmente justifiquen mucha explicación. El búfer circular es un poco más complicado:
class CircularStore : IItemStore
{
private List<Slot> slots;
private int freeSlotCount;
private int position = -1;
public CircularStore(int capacity)
{
slots = new List<Slot>(capacity);
}
public T Fetch()
{
if (Count == 0)
throw new InvalidOperationException("The buffer is empty.");
int startPosition = position;
do
{
Advance();
Slot slot = slots[position];
if (!slot.IsInUse)
{
slot.IsInUse = true;
--freeSlotCount;
return slot.Item;
}
} while (startPosition != position);
throw new InvalidOperationException("No free slots.");
}
public void Store(T item)
{
Slot slot = slots.Find(s => object.Equals(s.Item, item));
if (slot == null)
{
slot = new Slot(item);
slots.Add(slot);
}
slot.IsInUse = false;
++freeSlotCount;
}
public int Count
{
get { return freeSlotCount; }
}
private void Advance()
{
position = (position + 1) % slots.Count;
}
class Slot
{
public Slot(T item)
{
this.Item = item;
}
public T Item { get; private set; }
public bool IsInUse { get; set; }
}
}
Podría haber elegido varios enfoques diferentes, pero la conclusión es que se debe acceder a los recursos en el mismo orden en que fueron creados, lo que significa que tenemos que mantener referencias a ellos pero marcarlos como "en uso" (o no ) En el peor de los casos, solo hay una ranura disponible, y se necesita una iteración completa del búfer para cada búsqueda. Esto es malo si tiene cientos de recursos agrupados y los adquiere y libera varias veces por segundo; no es realmente un problema para un grupo de 5-10 elementos, y en el caso típico , donde los recursos se usan ligeramente, solo tiene que avanzar uno o dos espacios.
Recuerde, estas clases son clases internas privadas, es por eso que no necesitan una gran cantidad de verificación de errores, el grupo en sí restringe el acceso a ellas.
Agregue una enumeración y un método de fábrica y hemos terminado con esta parte:
// Outside the pool
public enum AccessMode { FIFO, LIFO, Circular };
private IItemStore itemStore;
// Inside the Pool
private IItemStore CreateItemStore(AccessMode mode, int capacity)
{
switch (mode)
{
case AccessMode.FIFO:
return new QueueStore(capacity);
case AccessMode.LIFO:
return new StackStore(capacity);
default:
Debug.Assert(mode == AccessMode.Circular,
"Invalid AccessMode in CreateItemStore");
return new CircularStore(capacity);
}
}
El siguiente problema a resolver es la estrategia de carga. He definido tres tipos:
public enum LoadingMode { Eager, Lazy, LazyExpanding };
Los dos primeros deben explicarse por sí mismos; el tercero es una especie de híbrido, carga los recursos de forma diferida pero en realidad no comienza a reutilizar ningún recurso hasta que el grupo esté lleno. Esto sería una buena compensación si desea que el grupo esté lleno (lo que parece que sí) pero desea diferir el gasto de crearlos hasta el primer acceso (es decir, para mejorar los tiempos de inicio).
Los métodos de carga realmente no son demasiado complicados, ahora que tenemos la abstracción de la tienda de artículos:
private int size;
private int count;
private T AcquireEager()
{
lock (itemStore)
{
return itemStore.Fetch();
}
}
private T AcquireLazy()
{
lock (itemStore)
{
if (itemStore.Count > 0)
{
return itemStore.Fetch();
}
}
Interlocked.Increment(ref count);
return factory(this);
}
private T AcquireLazyExpanding()
{
bool shouldExpand = false;
if (count < size)
{
int newCount = Interlocked.Increment(ref count);
if (newCount <= size)
{
shouldExpand = true;
}
else
{
// Another thread took the last spot - use the store instead
Interlocked.Decrement(ref count);
}
}
if (shouldExpand)
{
return factory(this);
}
else
{
lock (itemStore)
{
return itemStore.Fetch();
}
}
}
private void PreloadItems()
{
for (int i = 0; i < size; i++)
{
T item = factory(this);
itemStore.Store(item);
}
count = size;
}
Los campos size
y count
arriba se refieren al tamaño máximo del grupo y al número total de recursos que posee el grupo (pero no necesariamente disponibles ), respectivamente. AcquireEager
es el más simple, supone que un artículo ya está en la tienda; estos artículos se cargarán previamente en la construcción, es decir, en el PreloadItems
método que se muestra al final.
AcquireLazy
comprueba si hay elementos gratuitos en el grupo y, si no, crea uno nuevo. AcquireLazyExpanding
creará un nuevo recurso siempre que el grupo aún no haya alcanzado su tamaño objetivo. He intentado optimizar esto para minimizar el bloqueo, y espero no haber cometido ningún error (lo he probado en condiciones de subprocesos múltiples, pero obviamente no de forma exhaustiva).
Tal vez se pregunte por qué ninguno de estos métodos se molesta en verificar si la tienda ha alcanzado el tamaño máximo. Llegaré a eso en un momento.
Ahora para la piscina en sí. Aquí está el conjunto completo de datos privados, algunos de los cuales ya se han mostrado:
private bool isDisposed;
private Func<Pool<T>, T> factory;
private LoadingMode loadingMode;
private IItemStore itemStore;
private int size;
private int count;
private Semaphore sync;
Respondiendo a la pregunta que pasé por alto en el último párrafo: cómo asegurarnos de limitar el número total de recursos creados, resulta que .NET ya tiene una herramienta perfectamente buena para eso, se llama Semaphore y está diseñado específicamente para permitir un número de hilos de acceso a un recurso (en este caso, el "recurso" es el almacén de elementos interno). Como no estamos implementando una cola completa de productores / consumidores, esto es perfectamente adecuado para nuestras necesidades.
El constructor se ve así:
public Pool(int size, Func<Pool<T>, T> factory,
LoadingMode loadingMode, AccessMode accessMode)
{
if (size <= 0)
throw new ArgumentOutOfRangeException("size", size,
"Argument 'size' must be greater than zero.");
if (factory == null)
throw new ArgumentNullException("factory");
this.size = size;
this.factory = factory;
sync = new Semaphore(size, size);
this.loadingMode = loadingMode;
this.itemStore = CreateItemStore(accessMode, size);
if (loadingMode == LoadingMode.Eager)
{
PreloadItems();
}
}
No debería haber sorpresas aquí. Lo único a tener en cuenta es la carcasa especial para la carga ansiosa, utilizando el PreloadItems
método que ya se mostró anteriormente.
Dado que casi todo se ha abstraído limpiamente por ahora, lo real Acquire
y los Release
métodos son realmente muy sencillos:
public T Acquire()
{
sync.WaitOne();
switch (loadingMode)
{
case LoadingMode.Eager:
return AcquireEager();
case LoadingMode.Lazy:
return AcquireLazy();
default:
Debug.Assert(loadingMode == LoadingMode.LazyExpanding,
"Unknown LoadingMode encountered in Acquire method.");
return AcquireLazyExpanding();
}
}
public void Release(T item)
{
lock (itemStore)
{
itemStore.Store(item);
}
sync.Release();
}
Como se explicó anteriormente, estamos usando el Semaphore
para controlar la concurrencia en lugar de verificar religiosamente el estado de la tienda de artículos. Mientras los artículos adquiridos se liberen correctamente, no hay nada de qué preocuparse.
Por último, pero no menos importante, hay limpieza:
public void Dispose()
{
if (isDisposed)
{
return;
}
isDisposed = true;
if (typeof(IDisposable).IsAssignableFrom(typeof(T)))
{
lock (itemStore)
{
while (itemStore.Count > 0)
{
IDisposable disposable = (IDisposable)itemStore.Fetch();
disposable.Dispose();
}
}
}
sync.Close();
}
public bool IsDisposed
{
get { return isDisposed; }
}
El propósito de esa IsDisposed
propiedad quedará claro en un momento. Todo lo que el Dispose
método principal realmente hace es deshacerse de los elementos agrupados reales si se implementan IDisposable
.
Ahora, básicamente, puede usar esto tal cual, con un try-finally
bloque, pero no me gusta esa sintaxis, porque si comienza a distribuir recursos agrupados entre clases y métodos, se volverá muy confuso. Es posible que la clase principal que utiliza un recurso ni siquiera tenga una referencia al grupo. Realmente se vuelve bastante desordenado, por lo que un mejor enfoque es crear un objeto agrupado "inteligente".
Digamos que comenzamos con la siguiente interfaz / clase simple:
public interface IFoo : IDisposable
{
void Test();
}
public class Foo : IFoo
{
private static int count = 0;
private int num;
public Foo()
{
num = Interlocked.Increment(ref count);
}
public void Dispose()
{
Console.WriteLine("Goodbye from Foo #{0}", num);
}
public void Test()
{
Console.WriteLine("Hello from Foo #{0}", num);
}
}
Aquí está nuestro Foo
recurso desechable que se implementa IFoo
y tiene un código repetitivo para generar identidades únicas. Lo que hacemos es crear otro objeto agrupado especial:
public class PooledFoo : IFoo
{
private Foo internalFoo;
private Pool<IFoo> pool;
public PooledFoo(Pool<IFoo> pool)
{
if (pool == null)
throw new ArgumentNullException("pool");
this.pool = pool;
this.internalFoo = new Foo();
}
public void Dispose()
{
if (pool.IsDisposed)
{
internalFoo.Dispose();
}
else
{
pool.Release(this);
}
}
public void Test()
{
internalFoo.Test();
}
}
Esto simplemente representa todos los métodos "reales" en su interior IFoo
(podríamos hacer esto con una biblioteca de proxy dinámico como Castle, pero no voy a entrar en eso). También mantiene una referencia a lo Pool
que lo crea, de modo que cuando Dispose
este objeto, se libera automáticamente al grupo. Excepto cuando el grupo ya se ha eliminado, esto significa que estamos en modo de "limpieza" y, en este caso, en realidad limpia el recurso interno .
Usando el enfoque anterior, podemos escribir código como este:
// Create the pool early
Pool<IFoo> pool = new Pool<IFoo>(PoolSize, p => new PooledFoo(p),
LoadingMode.Lazy, AccessMode.Circular);
// Sometime later on...
using (IFoo foo = pool.Acquire())
{
foo.Test();
}
Esto es algo muy bueno para poder hacer. Significa que el código que usa el IFoo
(en oposición al código que lo crea) en realidad no necesita conocer el grupo. Incluso puede inyectar IFoo
objetos usando su biblioteca DI favorita y Pool<T>
como proveedor / fábrica.
Puse el código completo en PasteBin para que disfrutes al copiar y pegar. También hay un breve programa de prueba que puede usar para jugar con diferentes modos de carga / acceso y condiciones multiproceso, para asegurarse de que es seguro para subprocesos y no tiene errores.
Avíseme si tiene alguna pregunta o inquietud sobre esto.