Essa pergunta é um pouco mais complicada do que se poderia esperar devido a várias incógnitas: o comportamento do recurso que está sendo agrupado, a vida útil esperada / necessária dos objetos, o motivo real pelo qual o pool é necessário etc. Geralmente, os pools são para fins especiais - thread pools, pools de conexão etc. - porque é mais fácil otimizar um quando você sabe exatamente o que o recurso faz e, mais importante, tem controle sobre como esse recurso é implementado.
Como não é tão simples, o que tentei fazer é oferecer uma abordagem bastante flexível com a qual você possa experimentar e ver o que funciona melhor. Pedimos desculpas antecipadamente pelo longo cargo, mas há muito a ser abordado quando se trata de implementar um conjunto decente de recursos de uso geral. e estou realmente apenas arranhando a superfície.
Um pool de uso geral teria que ter algumas "configurações" principais, incluindo:
- Estratégia de carregamento de recursos - ansiosa ou preguiçosa;
- Mecanismo de carregamento de recursos - como realmente construir um;
- Estratégia de acesso - você menciona "round robin", que não é tão direto quanto parece; essa implementação pode usar um buffer circular que é semelhante , mas não perfeito, porque o pool não tem controle sobre quando os recursos são realmente recuperados. Outras opções são FIFO e LIFO; O FIFO terá mais de um padrão de acesso aleatório, mas o LIFO facilita significativamente a implementação de uma estratégia de liberação Menos usados recentemente (que você disse que estava fora do escopo, mas ainda vale a pena mencionar).
Para o mecanismo de carregamento de recursos, o .NET já nos fornece uma abstração limpa - delegados.
private Func<Pool<T>, T> factory;
Passe isso pelo construtor do pool e estamos prontos para isso. O uso de um tipo genérico com new()restrição também funciona, mas isso é mais flexível.
Dos outros dois parâmetros, a estratégia de acesso é a besta mais complicada, então minha abordagem foi usar uma abordagem baseada em herança (interface):
public class Pool<T> : IDisposable
{
// Other code - we'll come back to this
interface IItemStore
{
T Fetch();
void Store(T item);
int Count { get; }
}
}
O conceito aqui é simples - vamos deixar a Poolclasse pública lidar com problemas comuns, como segurança de threads, mas usar um "armazenamento de itens" diferente para cada padrão de acesso. O LIFO é facilmente representado por uma pilha, o FIFO é uma fila e eu usei uma implementação de buffer circular não muito otimizada, mas provavelmente adequada usando um List<T>ponteiro e index para aproximar um padrão de acesso round-robin.
Todas as classes abaixo são classes internas do Pool<T>- essa foi uma escolha de estilo, mas como elas realmente não devem ser usadas fora Pooldela, faz mais 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);
}
}
Estes são os óbvios - pilha e fila. Eu não acho que eles realmente justifiquem muita explicação. O buffer circular é um pouco mais 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; }
}
}
Eu poderia ter escolhido uma série de abordagens diferentes, mas a linha inferior é que os recursos devem ser acessados na mesma ordem em que foram criados, o que significa que precisamos manter referências a elas, mas marcá-las como "em uso" (ou não ) No pior cenário, apenas um slot está disponível e é necessária uma iteração completa do buffer para cada busca. Isso é ruim se você tiver centenas de recursos reunidos e estiver adquirindo e liberando-os várias vezes por segundo; não é realmente um problema para um conjunto de 5 a 10 itens e, no caso típico , onde os recursos são pouco usados, ele só precisa avançar um ou dois slots.
Lembre-se de que essas classes são classes internas privadas - é por isso que elas não precisam de muita verificação de erros; o próprio pool restringe o acesso a elas.
Jogue uma enumeração e um método de fábrica e terminamos 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);
}
}
O próximo problema a ser resolvido é a estratégia de carregamento. Eu defini três tipos:
public enum LoadingMode { Eager, Lazy, LazyExpanding };
Os dois primeiros devem ser auto-explicativos; o terceiro é uma espécie de híbrido, carrega preguiçosamente recursos, mas na verdade não começa a reutilizar nenhum recurso até que o pool esteja cheio. Isso seria uma boa escolha se você deseja que o pool esteja cheio (o que parece ser o seu caso), mas quer adiar a despesa de realmente criá-los até o primeiro acesso (ou seja, para melhorar o tempo de inicialização).
Os métodos de carregamento realmente não são muito complicados, agora que temos a abstração da loja de itens:
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;
}
Os campos sizee countacima se referem ao tamanho máximo do pool e ao número total de recursos pertencentes ao pool (mas não necessariamente disponíveis ), respectivamente. AcquireEageré o mais simples, assume que um item já está na loja - esses itens seriam pré-carregados na construção, ou seja, no PreloadItemsmétodo mostrado por último.
AcquireLazyverifica se há itens gratuitos no pool e, se não, ele cria um novo. AcquireLazyExpandingcriará um novo recurso, desde que o pool ainda não tenha atingido o tamanho desejado. Eu tentei otimizar isso para minimizar o bloqueio, e eu espero não ter cometido nenhum erro (eu ter testado esta em condições multi-threaded, mas, obviamente, não exaustivamente).
Você pode estar se perguntando por que nenhum desses métodos se incomoda em verificar se a loja atingiu ou não o tamanho máximo. Eu vou chegar a isso em um momento.
Agora para a piscina em si. Aqui está o conjunto completo de dados particulares, alguns dos quais já foram mostrados:
private bool isDisposed;
private Func<Pool<T>, T> factory;
private LoadingMode loadingMode;
private IItemStore itemStore;
private int size;
private int count;
private Semaphore sync;
Respondendo à pergunta que encobri no último parágrafo - como garantir a limitação do número total de recursos criados -, o .NET já possui uma ferramenta perfeitamente boa para isso, chamada Semaphore, e foi projetada especificamente para permitir uma correção número de threads que acessam um recurso (nesse caso, o "recurso" é o armazenamento interno de itens). Como não estamos implementando uma fila completa de produtores / consumidores, isso é perfeitamente adequado para nossas necessidades.
O construtor fica assim:
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();
}
}
Não deve haver surpresas aqui. A única coisa a se notar é o revestimento especial para carregamento rápido, usando o PreloadItemsmétodo já mostrado anteriormente.
Como quase tudo já foi abstraído de maneira limpa até agora, os métodos Acquiree os Releasemétodos reais são realmente muito diretos:
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 explicado anteriormente, estamos usando o Semaphorepara controlar a simultaneidade, em vez de verificar religiosamente o status do armazenamento de itens. Desde que os itens adquiridos sejam liberados corretamente, não há com o que se preocupar.
Por último, mas não menos importante, há a limpeza:
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; }
}
O objetivo dessa IsDisposedpropriedade ficará claro em um momento. Todo o Disposemétodo principal realmente faz é descartar os itens agrupados reais, se eles implementarem IDisposable.
Agora você pode basicamente usar isso como está, com um try-finallybloco, mas não gosto dessa sintaxe, porque se você começar a repassar recursos agrupados entre classes e métodos, isso ficará muito confuso. É possível que a classe principal que usa um recurso nem tenha uma referência ao pool. Ele realmente se torna bastante confuso, portanto, uma abordagem melhor é criar um objeto em pool "inteligente".
Digamos que começamos com a seguinte interface / classe simples:
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);
}
}
Aqui está nosso Foorecurso descartável fingido que implementa IFooe possui algum código padrão para gerar identidades únicas. O que fazemos é criar outro objeto especial em pool:
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();
}
}
Isso apenas copia todos os métodos "reais" para seu interior IFoo(poderíamos fazer isso com uma biblioteca de Proxy Dinâmico como o Castle, mas não vou entrar nisso). Ele também mantém uma referência ao Poolque o cria, para que, quando Disposeeste objeto, ele seja automaticamente liberado de volta ao pool. Exceto quando o pool já tiver sido descartado - isso significa que estamos no modo "limpeza" e, nesse caso, ele realmente limpa o recurso interno .
Usando a abordagem acima, conseguimos escrever 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();
}
Isso é uma coisa muito boa de poder fazer. Isso significa que o código que usa o IFoo(ao contrário do código que o cria) não precisa realmente estar ciente do pool. Você pode até injetar IFoo objetos usando sua biblioteca DI favorita e Pool<T>como fornecedor / fábrica.
Coloquei o código completo no PasteBin para sua diversão de copiar e colar. Há também um pequeno programa de teste que você pode usar para brincar com diferentes modos de carregamento / acesso e condições multithread, para se certificar de que é seguro para threads e não com erros.
Entre em contato se tiver alguma dúvida ou preocupação sobre isso.