Questa domanda è un po 'più complicata di quanto ci si potrebbe aspettare a causa di diverse incognite: il comportamento della risorsa in pool, la durata prevista / richiesta degli oggetti, la vera ragione per cui è richiesto il pool, ecc. In genere i pool sono per scopi speciali - thread pool, pool di connessioni, ecc. - perché è più facile ottimizzarne uno quando si conosce esattamente cosa fa la risorsa e, cosa più importante, si ha il controllo su come tale risorsa viene implementata.
Dal momento che non è così semplice, quello che ho provato a fare è offrire un approccio abbastanza flessibile che puoi sperimentare e vedere cosa funziona meglio. Ci scusiamo in anticipo per il lungo post, ma c'è molto terreno da affrontare quando si tratta di implementare un discreto pool di risorse per scopi generici. e sto davvero solo grattando la superficie.
Un pool di uso generale dovrebbe avere alcune "impostazioni" principali, tra cui:
- Strategia di caricamento delle risorse: desiderosa o pigra;
- Meccanismo di caricamento delle risorse - come costruirne effettivamente uno;
- Strategia di accesso - menzioni "round robin" che non è così semplice come sembra; questa implementazione può utilizzare un buffer circolare simile , ma non perfetto, poiché il pool non ha alcun controllo su quando le risorse vengono effettivamente recuperate. Altre opzioni sono FIFO e LIFO; FIFO avrà più di un modello di accesso casuale, ma LIFO semplifica notevolmente l'implementazione di una strategia di liberazione utilizzata di recente (che hai detto non rientra nell'ambito di applicazione, ma vale comunque la pena menzionarla).
Per il meccanismo di caricamento delle risorse, .NET ci offre già un'astrazione chiara: i delegati.
private Func<Pool<T>, T> factory;
Passa attraverso il costruttore del pool e abbiamo finito. L'uso di un tipo generico con un new()
vincolo funziona anche, ma questo è più flessibile.
Degli altri due parametri, la strategia di accesso è la bestia più complicata, quindi il mio approccio è stato quello di utilizzare un approccio basato sull'ereditarietà (interfaccia):
public class Pool<T> : IDisposable
{
// Other code - we'll come back to this
interface IItemStore
{
T Fetch();
void Store(T item);
int Count { get; }
}
}
Il concetto qui è semplice: lasceremo che la Pool
classe pubblica gestisca i problemi comuni come la sicurezza dei thread, ma utilizzeremo un "archivio oggetti" diverso per ogni modello di accesso. LIFO è facilmente rappresentato da uno stack, FIFO è una coda e ho usato un'implementazione del buffer circolare non molto ottimizzata ma probabilmente adeguata utilizzando un List<T>
puntatore e indice per approssimare un modello di accesso round-robin.
Tutte le classi sottostanti sono classi interne della Pool<T>
- questa è stata una scelta di stile, ma dal momento che queste non sono pensate per essere utilizzate al di fuori della Pool
, ha più senso.
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);
}
}
Questi sono quelli ovvi: pila e coda. Non credo che meritino davvero molte spiegazioni. Il buffer circolare è un po 'più complicato:
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; }
}
}
Avrei potuto scegliere una serie di approcci diversi, ma la linea di fondo è che le risorse dovrebbero essere accessibili nello stesso ordine in cui sono state create, il che significa che dobbiamo mantenere i riferimenti a loro ma contrassegnarli come "in uso" (o no ). Nel peggiore dei casi, è sempre disponibile solo uno slot, che richiede un'iterazione completa del buffer per ogni recupero. Ciò è negativo se si hanno in comune centinaia di risorse e le si stanno acquisendo e rilasciando più volte al secondo; non è davvero un problema per un pool di 5-10 elementi e, nel caso tipico , in cui le risorse sono leggermente utilizzate, deve solo avanzare di uno o due slot.
Ricorda, queste classi sono classi interne private - ecco perché non hanno bisogno di molto controllo degli errori, il pool stesso limita l'accesso ad esse.
Aggiungi un elenco e un metodo factory e abbiamo finito con questa 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);
}
}
Il prossimo problema da risolvere è il caricamento della strategia. Ho definito tre tipi:
public enum LoadingMode { Eager, Lazy, LazyExpanding };
I primi due dovrebbero essere autoesplicativi; il terzo è una specie di ibrido, carica le risorse in modo pigro ma in realtà non inizia a riutilizzare alcuna risorsa fino a quando il pool non è pieno. Questo sarebbe un buon compromesso se si desidera che il pool sia pieno (come sembra che si faccia) ma si desidera rinviare le spese di creazione effettiva fino al primo accesso (ovvero per migliorare i tempi di avvio).
I metodi di caricamento non sono davvero troppo complicati, ora che abbiamo l'astrazione dell'archivio articoli:
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;
}
I campi size
e count
sopra si riferiscono alla dimensione massima del pool e al numero totale di risorse possedute dal pool (ma non necessariamente disponibili ), rispettivamente. AcquireEager
è il più semplice, presuppone che un articolo sia già nel negozio - questi elementi sarebbero precaricati durante la costruzione, cioè nel PreloadItems
metodo mostrato per ultimo.
AcquireLazy
controlla se ci sono articoli gratuiti nel pool e, in caso contrario, ne crea uno nuovo. AcquireLazyExpanding
creerà una nuova risorsa purché il pool non abbia ancora raggiunto la dimensione target. Ho cercato di ottimizzare questo per ridurre al minimo il blocco, e spero di non aver fatto errori (io ho testato questo in condizioni di multi-threaded, ma ovviamente non esaustivo).
Ci si potrebbe chiedere perché nessuno di questi metodi si preoccupi di verificare se il negozio ha raggiunto o meno la dimensione massima. Ci arrivo tra un momento.
Ora per la piscina stessa. Ecco la serie completa di dati privati, alcuni dei quali sono già stati mostrati:
private bool isDisposed;
private Func<Pool<T>, T> factory;
private LoadingMode loadingMode;
private IItemStore itemStore;
private int size;
private int count;
private Semaphore sync;
Rispondendo alla domanda che ho ripassato nell'ultimo paragrafo - come garantire che limitiamo il numero totale di risorse create - si scopre che .NET ha già uno strumento perfettamente valido per questo, si chiama Semaphore ed è progettato specificamente per consentire un numero di thread di accesso a una risorsa (in questo caso la "risorsa" è l'archivio oggetti interno). Poiché non stiamo implementando una coda produttore / consumatore completa, questo è perfettamente adeguato alle nostre esigenze.
Il costruttore si presenta così:
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();
}
}
Non dovrebbero esserci sorprese qui. L'unica cosa da notare è l'involucro speciale per il caricamento desideroso, utilizzando il PreloadItems
metodo già mostrato in precedenza.
Poiché ormai quasi tutto è stato chiaramente sottratto, i metodi Acquire
e i Release
metodi sono davvero molto semplici:
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();
}
Come spiegato in precedenza, stiamo usando il Semaphore
per controllare la concorrenza invece di controllare religiosamente lo stato del negozio di articoli. Finché gli oggetti acquisiti vengono rilasciati correttamente, non c'è nulla di cui preoccuparsi.
Ultimo ma non meno importante, c'è la pulizia:
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; }
}
Lo scopo di quella IsDisposed
proprietà diventerà chiaro in un momento. Tutto il Dispose
metodo principale è davvero quello di eliminare gli oggetti in pool reali se implementati IDisposable
.
Ora puoi fondamentalmente usare questo così com'è, con un try-finally
blocco, ma non mi piace quella sintassi, perché se inizi a passare risorse raggruppate tra classi e metodi, diventerà molto confuso. E 'possibile che la classe principale che utilizza una risorsa non ha nemmeno avere un riferimento per la piscina. Diventa davvero piuttosto disordinato, quindi un approccio migliore è quello di creare un oggetto pool "intelligente".
Diciamo che iniziamo con la seguente semplice interfaccia / classe:
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);
}
}
Ecco la nostra finta Foo
risorsa usa IFoo
e getta che implementa e ha un po 'di codice boilerplate per generare identità univoche. Quello che facciamo è creare un altro oggetto speciale, raggruppato:
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();
}
}
Questo inoltra semplicemente tutti i metodi "reali" al suo interno IFoo
(potremmo farlo con una libreria di proxy dinamici come Castle, ma non entrerò in quello). Mantiene anche un riferimento a Pool
ciò che lo crea, in modo che quando Dispose
questo oggetto viene rilasciato automaticamente nel pool. Tranne quando il pool è già stato eliminato, ciò significa che siamo in modalità "cleanup" e in questo caso pulisce invece la risorsa interna .
Usando l'approccio sopra, arriviamo a scrivere codice in questo modo:
// 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();
}
Questa è una molto buona cosa per essere in grado di fare. Significa che il codice che utilizza il IFoo
(al contrario del codice che lo crea) non deve effettivamente essere a conoscenza del pool. Puoi anche iniettare IFoo
oggetti usando la tua libreria DI preferita e Pool<T>
come provider / factory.
Ho inserito il codice completo su PasteBin per il tuo divertimento con il copia e incolla. C'è anche un breve programma di test che puoi usare per giocare con diverse modalità di caricamento / accesso e condizioni multithread, per assicurarti che sia thread-safe e non buggy.
Fammi sapere se hai domande o dubbi in merito.