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 Pool
classe 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 Pool
dela, 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 size
e count
acima 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 PreloadItems
método mostrado por último.
AcquireLazy
verifica se há itens gratuitos no pool e, se não, ele cria um novo. AcquireLazyExpanding
criará 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 PreloadItems
método já mostrado anteriormente.
Como quase tudo já foi abstraído de maneira limpa até agora, os métodos Acquire
e os Release
mé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 Semaphore
para 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 IsDisposed
propriedade ficará claro em um momento. Todo o Dispose
método principal realmente faz é descartar os itens agrupados reais, se eles implementarem IDisposable
.
Agora você pode basicamente usar isso como está, com um try-finally
bloco, 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 Foo
recurso descartável fingido que implementa IFoo
e 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 Pool
que o cria, para que, quando Dispose
este 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.