To pytanie jest trochę trudniejsze, niż można by się spodziewać, z powodu kilku niewiadomych: zachowanie zasobu, który jest gromadzony, oczekiwany / wymagany czas życia obiektów, rzeczywisty powód, dla którego pula jest wymagana itp. Zazwyczaj pule są specjalnego przeznaczenia - wątek pule, pule połączeń itp. - ponieważ łatwiej jest je zoptymalizować, gdy wiesz dokładnie, co robi zasób, a co ważniejsze, masz kontrolę nad tym, jak ten zasób jest wdrażany.
Ponieważ nie jest to takie proste, starałem się zaproponować dość elastyczne podejście, z którym można eksperymentować i zobaczyć, co działa najlepiej. Z góry przepraszamy za długi post, ale jest wiele powodów do omówienia, jeśli chodzi o wdrożenie przyzwoitej puli zasobów ogólnego przeznaczenia. i tak naprawdę tylko drapię powierzchnię.
Pula ogólnego przeznaczenia musiałaby mieć kilka głównych „ustawień”, w tym:
- Strategia ładowania zasobów - chętna lub leniwa;
- Mechanizm ładowania zasobów - jak właściwie go zbudować;
- Strategia dostępu - wspominasz o „okrągłym robocie”, który nie jest tak prosty, jak się wydaje; ta implementacja może korzystać z bufora cyklicznego, który jest podobny , ale nie doskonały, ponieważ pula nie ma kontroli nad faktycznym odzyskiwaniem zasobów. Inne opcje to FIFO i LIFO; FIFO będzie mieć więcej wzorca dostępu losowego, ale LIFO znacznie ułatwia wdrożenie strategii zwalniania najmniej ostatnio używanych (która, jak powiedziałeś, wykracza poza zakres, ale nadal warto o tym wspomnieć).
W przypadku mechanizmu ładowania zasobów .NET już daje nam czystą abstrakcję - delegatów.
private Func<Pool<T>, T> factory;
Prześlij to przez konstruktor puli i już z tym skończymy. Używanie typu ogólnego z new()
ograniczeniem również działa, ale jest to bardziej elastyczne.
Z pozostałych dwóch parametrów strategia dostępu jest bardziej skomplikowaną bestią, więc moje podejście polegało na zastosowaniu podejścia opartego na dziedziczeniu (interfejsie):
public class Pool<T> : IDisposable
{
// Other code - we'll come back to this
interface IItemStore
{
T Fetch();
void Store(T item);
int Count { get; }
}
}
Koncepcja tutaj jest prosta - pozwolimy Pool
klasie publicznej zająć się typowymi problemami, takimi jak bezpieczeństwo wątków, ale używamy innego „magazynu elementów” dla każdego wzorca dostępu. LIFO jest łatwo reprezentowane przez stos, FIFO to kolejka, a ja użyłem niezbyt zoptymalizowanej, ale prawdopodobnie wystarczającej implementacji bufora cyklicznego, używającej List<T>
wskaźnika i indeksu do przybliżenia wzorca dostępu typu round-robin.
Wszystkie poniższe klasy są wewnętrznymi klasami Pool<T>
- to był wybór stylu, ale ponieważ tak naprawdę nie są przeznaczone do użytku poza nimi Pool
, ma to największy sens.
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);
}
}
To są oczywiste - stos i kolejka. Nie sądzę, żeby naprawdę uzasadniały wiele wyjaśnień. Bufor kołowy jest trochę bardziej skomplikowany:
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; }
}
}
Mogłem wybrać wiele różnych podejść, ale najważniejsze jest to, że zasoby powinny być dostępne w tej samej kolejności, w jakiej zostały utworzone, co oznacza, że musimy zachować odniesienia do nich, ale oznaczyć je jako „w użyciu” (lub nie ). W najgorszym przypadku dostępne jest tylko jedno gniazdo, a każde pobranie wymaga pełnej iteracji bufora. Jest to złe, jeśli masz zebrane setki zasobów i pozyskujesz je i zwalniasz kilka razy na sekundę; nie stanowi to problemu w przypadku puli 5-10 przedmiotów, aw typowym przypadku, gdy zasoby są słabo wykorzystywane, wystarczy przesunąć o jeden lub dwa miejsca.
Pamiętaj, te klasy są prywatnymi klasami wewnętrznymi - dlatego nie potrzebują dużo sprawdzania błędów, sama pula ogranicza do nich dostęp.
Wrzuć wyliczenie i metodę fabryczną i skończymy z tą częścią:
// 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);
}
}
Następnym problemem do rozwiązania jest strategia ładowania. Zdefiniowałem trzy typy:
public enum LoadingMode { Eager, Lazy, LazyExpanding };
Pierwsze dwa powinny być oczywiste; trzeci jest rodzajem hybrydy, leniwie ładuje zasoby, ale w rzeczywistości nie zaczyna ich ponownie używać, dopóki pula nie zostanie zapełniona. Byłby to dobry kompromis, jeśli chcesz, aby pula była pełna (co brzmi tak, jakbyś to robiła), ale chcesz odłożyć koszt faktycznego utworzenia ich do pierwszego dostępu (tj. Aby poprawić czas uruchamiania).
Metody ładowania naprawdę nie są zbyt skomplikowane, teraz, gdy mamy abstrakcję sklepu z przedmiotami:
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;
}
Powyższe pola size
i count
odnoszą się odpowiednio do maksymalnego rozmiaru puli i całkowitej liczby zasobów należących do puli (ale niekoniecznie dostępnych ). AcquireEager
jest najprostszy, zakłada, że towar jest już w sklepie - elementy te zostałyby załadowane na etapie budowy, czyli w PreloadItems
sposób pokazany jako ostatni.
AcquireLazy
sprawdza, czy w puli są wolne elementy, a jeśli nie, tworzy nowy. AcquireLazyExpanding
utworzy nowy zasób, o ile pula nie osiągnęła jeszcze swojego docelowego rozmiaru. Próbowałem w celu optymalizacji tego celu zminimalizowania blokowania i mam nadzieję, że nie popełniłem żadnych błędów (I zostały przetestowane w warunkach wielowątkowych, ale oczywiście nie w sposób wyczerpujący).
Możesz się zastanawiać, dlaczego żadna z tych metod nie zawraca sobie głowy sprawdzaniem, czy sklep osiągnął maksymalny rozmiar. Zaraz do tego dojdę.
Teraz czas na sam basen. Oto pełny zestaw prywatnych danych, z których część została już pokazana:
private bool isDisposed;
private Func<Pool<T>, T> factory;
private LoadingMode loadingMode;
private IItemStore itemStore;
private int size;
private int count;
private Semaphore sync;
Odpowiadając na pytanie, które przemilczałem w ostatnim akapicie - jak zapewnić ograniczenie całkowitej liczby tworzonych zasobów - okazuje się, że .NET ma już do tego doskonale dobre narzędzie, nazywa się Semafor i jest zaprojektowane specjalnie, aby umożliwić naprawę liczba wątków dostępu do zasobu (w tym przypadku „zasób” to wewnętrzny magazyn elementów). Ponieważ nie wdrażamy pełnej kolejki producent / konsument, jest to całkowicie adekwatne do naszych potrzeb.
Konstruktor wygląda tak:
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();
}
}
Nie powinno tu być żadnych niespodzianek. Jedyną rzeczą, na którą należy zwrócić uwagę, jest specjalna obudowa do szybkiego ładowania, przy użyciu PreloadItems
metody już przedstawionej wcześniej.
Ponieważ prawie wszystko zostało już czysto wyabstrahowane, rzeczywistość Acquire
i Release
metody są naprawdę bardzo proste:
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();
}
Jak wyjaśniono wcześniej, używamy Semaphore
do kontrolowania współbieżności zamiast religijnego sprawdzania statusu sklepu z przedmiotami. Dopóki zdobyte przedmioty są prawidłowo wydawane, nie ma się czym martwić.
Wreszcie, jest porządek:
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; }
}
Cel tej IsDisposed
własności stanie się jasny za chwilę. Jedyną główną Dispose
metodą jest usunięcie rzeczywistych pozycji w puli, jeśli są one implementowane IDisposable
.
Teraz możesz zasadniczo używać tego tak, jak jest, z try-finally
blokiem, ale nie przepadam za tą składnią, ponieważ jeśli zaczniesz przekazywać zasoby w puli między klasami i metodami, stanie się to bardzo zagmatwane. Jest możliwe, że główne klasy, który korzysta z zasobów nawet nie mają odniesienie do basenu. Naprawdę robi się dość bałagan, więc lepszym podejściem jest utworzenie „inteligentnego” obiektu w puli.
Powiedzmy, że zaczynamy od następującego prostego interfejsu / klasy:
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);
}
}
Oto nasz udawany Foo
zasób jednorazowego użytku, który implementuje IFoo
i ma pewien standardowy kod do generowania unikalnych tożsamości. Tworzymy kolejny specjalny obiekt w puli:
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();
}
}
To po prostu przekazuje wszystkie „prawdziwe” metody do swojego wewnętrznego IFoo
(moglibyśmy to zrobić z biblioteką Dynamic Proxy, taką jak Castle, ale nie będę się w to zagłębiał). Utrzymuje również odniesienie do tego, Pool
który go tworzy, więc kiedy my Dispose
ten obiekt, automatycznie zwalnia się z powrotem do puli. Z wyjątkiem sytuacji, gdy pula została już usunięta - oznacza to, że jesteśmy w trybie „czyszczenia” iw tym przypadku faktycznie czyści zasoby wewnętrzne .
Korzystając z powyższego podejścia, możemy napisać kod w następujący sposób:
// 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();
}
To bardzo dobra rzecz, aby móc to zrobić. Oznacza to, że kod, który wykorzystujeIFoo
(w przeciwieństwie do kodu, który ją tworzy) faktycznie nie trzeba zdawać sobie sprawę z basenu. Możesz nawet wstrzykiwać IFoo
obiekty przy użyciu swojej ulubionej biblioteki DI i Pool<T>
dostawcy / fabryki.
Umieściłem cały kod w PasteBin dla przyjemności kopiowania i wklejania. Istnieje również krótki program testowy, którego możesz użyć do zabawy z różnymi trybami ładowania / dostępu i warunkami wielowątkowymi, aby upewnić się, że jest bezpieczny dla wątków i nie zawiera błędów.
Daj mi znać, jeśli masz jakieś pytania lub wątpliwości w związku z którymkolwiek z tych tematów.