Esta pregunta es un poco más difícil de lo que cabría esperar debido a varias incógnitas: El comportamiento del recurso que se agruparon, el tiempo de vida esperado/requerido de los objetos, las razón real por la que se requiere el grupo, etc. Normalmente, los grupos son de propósito especial: grupos de subprocesos, grupos de conexiones, etc., porque es más fácil optimizar uno cuando se sabe exactamente lo que hace el recurso y más importante, controlar cómo ese recurso es implementar ed.
Dado que 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 publicación larga, pero hay mucho terreno por recorrer cuando se trata de implementar un conjunto de recursos de propósito general decente. y realmente solo estoy rascando la superficie.
Una piscina de uso general tendría que tener algunos "ajustes" principales, incluyendo: estrategia de carga
- de Recursos - ansiosos o perezoso;
- Recurso cargando mecanismo - cómo construir realmente uno;
- Estrategia de acceso: mencionas "round robin" que no es tan sencillo como suena; esta implementación puede usar un buffer circular que es similar, pero no es perfecto, porque el grupo no tiene control sobre cuando los recursos son realmente recuperados. 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 utilizada menos recientemente (que usted dijo que estaba fuera del alcance, pero aún así 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 agrupación y hemos terminado con eso. Usar un tipo genérico con una restricción new()
también funciona, pero esto es más flexible.
De los otros dos parámetros, la estrategia de acceso es la bestia más complicado, por lo que mi enfoque era utilizar una herencia (interfaz) enfoque basado en:
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 - Permitirá que la clase pública Pool
maneje los problemas comunes como la seguridad de subprocesos, pero use 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 memoria intermedia circular no muy optimizada pero probablemente adecuada con un List<T>
y un puntero de índice para aproximarme a un patrón de acceso circular.
Todas las clases siguientes son clases internas del Pool<T>
- esta fue una elección de estilo, pero como estas no están destinadas a ser utilizadas 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);
}
}
Estas son las obvias: apilar y hacer cola. No creo que realmente justifiquen mucha explicación. El buffer 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; }
}
}
que podría haber elegido un número de diferentes enfoques, pero la conclusión es que deben tener acceso a los recursos en el mismo orden en que fueron creados, lo que significa que tenemos 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 está adquiriendo y liberándolos varias veces por segundo; realmente no es un problema para un conjunto de 5-10 elementos, y en el caso típico, donde los recursos se utilizan a la ligera, 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 comprobación de errores, el grupo en sí restringe el acceso a ellos.
El tiro en la 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 Estrategia de carga. He definido tres tipos:
public enum LoadingMode { Eager, Lazy, LazyExpanding };
Las dos primeras deben ser autoexplicativas; el tercero es una especie de híbrido, carga los recursos de manera lenta pero en realidad no comienza a reutilizar ningún recurso hasta que el grupo esté lleno. Esta sería una buena solución si desea que el grupo esté lleno (lo cual suena como lo hace) pero desea diferir el gasto de crearlos realmente hasta el primer acceso (es decir, para mejorar los tiempos de inicio).
La carga métodos realmente no son demasiado complicados, ahora que tenemos el elemento de la tienda abstracción:
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;
}
El size
y count
campos anteriormente se refieren al tamaño máximo de la piscina y el número total de recursos propiedad del grupo (pero no necesariamente disponible), respectivamente. AcquireEager
es el más simple, supone que un artículo ya está en la tienda; estos elementos se precargarán en la construcción, es decir, en el método PreloadItems
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. Intenté optimizar esto para minimizar el bloqueo, y espero no haber cometido ningún error (I ha probado bajo condiciones de subprocesos múltiples, pero obviamente no exhaustivamente).
Quizás se pregunte por qué ninguno de estos métodos se molesta en comprobar si la tienda ha alcanzado o no el tamaño máximo. Voy a llegar a eso en un momento.
Ahora para la piscina en sí. Éste es el conjunto completo de datos privados, algunos de los cuales ya se ha demostrado:
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 asegurar que limitamos 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 que un número fijo de hilos acceda a un recurso (en este caso, el "recurso" es el almacén interno de artículos). Como no estamos implementando una cola completa de productores/consumidores, esto es perfectamente adecuado para nuestras necesidades.
El constructor tiene el siguiente aspecto:
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();
}
}
debe haber sorpresas aquí. Lo único a tener en cuenta es la carcasa especial para la carga ansiosa, utilizando el método PreloadItems
ya mostrado anteriormente.
Dado que casi todo ha sido resumidas limpiamente por ahora, los actuales Acquire
y Release
métodos son realmente muy sencillo:
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 ha explicado anteriormente, estamos usando la Semaphore
para controlar la concurrencia en lugar de religiosamente el control de la estado de la tienda de artículos. Siempre que los artículos adquiridos se publiquen correctamente, no hay nada de qué preocuparse.
Por último, pero no menos importante, es la 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 ese IsDisposed
propiedad se aclarará en un momento. Todo el método principal Dispose
realmente es deshacerse de los elementos agrupados reales si implementan IDisposable
.
Ahora básicamente se puede utilizar esto como se encuentra, y un bloque try-finally
, pero no soy aficionado a esa sintaxis, porque si comienza a pasar recursos en torno agrupados entre las clases y los métodos entonces va a ser muy confuso. Es posible que la clase principal que utiliza un recurso ni siquiera tenga como referencia al grupo. Realmente se vuelve bastante desordenado, por lo que un mejor enfoque es crear un objeto agrupado "inteligente".
Digamos que empezamos con la siguiente sencilla interfaz/clase:
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á nuestra pretender desechable Foo
recursos que implementa IFoo
y tiene algo de código reutilizable para generar identidades únicas. Lo que hacemos es crear otra, objeto especial agrupada:
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 se utiliza proxies todos los métodos "reales" en su interior IFoo
(podríamos hacer esto con una biblioteca de proxy dinámico como el Castillo, pero ganó' entrar en eso). También mantiene una referencia al Pool
que lo crea, de modo que cuando obtenemos este objeto, Dispose
se libera automáticamente al grupo. Excepto cuando el grupo ya se ha eliminado, esto significa que estamos en modo "limpieza" y en este caso realmente limpia el recurso interno.
Utilizando el enfoque anterior, se llega a 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();
}
Esta es una muy buena cosaa ser capaz de hacerlo. Significa que el código que usaIFoo
(a diferencia del código que lo crea) en realidad no necesita conocer el grupo. Puede incluso inyectar objetosIFoo
usando su biblioteca DI favorita y Pool<T>
como proveedor/fábrica.
He puesto el complete code on PasteBin para su disfrute de copiar y pegar. También hay un corto test program que puede usar para jugar con diferentes modos de carga/acceso y condiciones multiproceso, para asegurarse de que es seguro para la ejecución de subprocesos y no tiene errores.
Deseo saber si tiene alguna pregunta o inquietud acerca de esto.
¿Puede decirnos algo acerca de sus requisitos?No todos los grupos se crean iguales. ¿Los objetos son caros de crear? ¿Serán adquiridos/lanzados con mucha frecuencia? ¿Bastará con un simple primero que llegue primero o necesita algo más inteligente, es decir, que evite la inanición? ¿Qué pasa con cosas como las prioridades, la carga lenta o impaciente, etc.? Cualquier cosa que pueda agregar nos ayudaría (o al menos a mí) a encontrar una respuesta más completa. – Aaronaught
Chris - solo mirando sus párrafos segundo y tercero, y preguntándome si estas sesiones realmente deberían mantenerse vivas indefinidamente. Parece que eso es lo que no le gusta a su proveedor de servicios (sesiones prolongadas), por lo que podría estar buscando una implementación de grupo que active las sesiones nuevas según sea necesario y las apague cuando no se usen (después de un período específico) . Esto se puede hacer, pero es un poco más complicado, así que me gustaría confirmarlo. – Aaronaught
No estoy seguro de si necesito una solución robusta o no, ya que mi solución es meramente hipotética. Es posible que mi proveedor de servicios me esté mintiendo y que su servicio ya se haya vendido y simplemente haya encontrado una excusa para culpar al usuario. –