Це питання трохи складніше, ніж можна було очікувати через декілька невідомих: поведінка ресурсу, що об'єднується, очікуваний / необхідний термін експлуатації об'єктів, справжня причина необхідності пулу тощо. Зазвичай пули мають спеціальну ціль - нитка пули, пули підключення тощо - адже оптимізувати їх простіше, коли ви точно знаєте, що робить ресурс, і що важливіше мати контроль над тим, як цей ресурс реалізований.
Оскільки це не так просто, те, що я намагався зробити, - це запропонувати досить гнучкий підхід, з яким можна експериментувати і побачити, що найкраще працює. Заздалегідь вибачте за довгий пост, але є багато підґрунтя, коли мова йде про впровадження гідного фонду загальних цілей. і я справді лише дряпаю поверхню.
Пул загального призначення повинен мати кілька основних "налаштувань", зокрема:
- Стратегія завантаження ресурсів - нетерплячий чи лінивий;
- Механізм завантаження ресурсів - як насправді побудувати його;
- Стратегія доступу - ви згадуєте "круглу машину", яка не настільки проста, як це звучить; ця реалізація може використовувати круговий буфер, який схожий , але не ідеальний, тому що пул не має контролю над тим, коли ресурси реально відшкодовані. Інші варіанти - FIFO та LIFO; FIFO матиме більше схеми з випадковим доступом, але LIFO значно спрощує реалізацію стратегії звільнення, яка нещодавно використовувалась (яка, за вашими словами, вийшла за рамки, але все-таки варто згадати).
Для механізму завантаження ресурсів .NET вже дає нам чисту абстракцію - делегатів.
private Func<Pool<T>, T> factory;
Пропустіть це через конструктор пулу, і ми вже з цим готові. Використання загального типу з new()
обмеженням теж працює, але це більш гнучко.
З двох інших параметрів стратегія доступу є складнішим звіром, тому мій підхід полягав у використанні підходу, заснованого на успадкуванні (інтерфейсі):
public class Pool<T> : IDisposable
{
// Other code - we'll come back to this
interface IItemStore
{
T Fetch();
void Store(T item);
int Count { get; }
}
}
Концепція тут проста - ми дозволимо громадському Pool
класу вирішувати поширені проблеми, такі як безпека потоків, але використовуватимемо інший "магазин товарів" для кожного шаблону доступу. LIFO легко представляється стеком, FIFO - чергою, і я використовував не дуже оптимізовану, але, ймовірно, адекватну кругову буферну реалізацію, використовуючиList<T>
вказівний та покажчик покажчиків, щоб наблизити схему доступу круглолітового доступу.
Усі класи нижче є внутрішніми класами Pool<T>
- це був вибір стилю, але оскільки вони справді не призначені для використання поза межами Pool
, це має найбільш сенс.
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);
}
}
Це очевидні - стек і черга. Я не думаю, що вони дійсно вимагають великих пояснень. Круглий буфер трохи складніше:
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; }
}
}
Я міг вибрати декілька різних підходів, але підсумок полягає в тому, що до ресурсів слід звертатися в тому ж порядку, в якому вони були створені, а це означає, що ми повинні підтримувати посилання на них, але позначати їх як "у використанні" (чи ні ). У гіршому випадку, коли-небудь доступний лише один слот, і він потребує повної ітерації буфера для кожного вибору. Це погано, якщо у вас є сотні ресурсів, об'єднані і ви їх набуваєте та випускаєте кілька разів на секунду; насправді не є проблемою для пулу з 5-10 елементів, і в типовому випадку, коли ресурси використовуються злегка, він має лише просунути один або два слоти.
Пам'ятайте, що ці класи є приватними внутрішніми класами - саме тому їм не потрібно багато перевірки помилок, сам пул обмежує доступ до них.
Увімкніть перерахування та заводський метод, і ми закінчили цю частину:
// 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);
}
}
Наступна проблема, яку потрібно вирішити, - це стратегія завантаження. Я визначив три типи:
public enum LoadingMode { Eager, Lazy, LazyExpanding };
Перші два мають бути роз'яснювальними; третій - це різновид гібриду, він ледаче завантажує ресурси, але насправді не починає повторно використовувати будь-які ресурси, поки пул не заповнений. Це було б непогано, якщо ви хочете, щоб пул був заповнений (це здається, що ви робите), але хочете відкласти витрати на фактичне їх створення до першого доступу (тобто покращити час запуску).
Способи завантаження насправді не надто складні, тепер ми маємо абстракцію предмета-магазину:
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;
}
Ці size
та count
поля вище , відносяться до розміру максимального пулу і загальною кількістю ресурсів , що знаходяться в власності басейну (але не обов'язково є ), відповідно. AcquireEager
найпростіший, він передбачає, що товар вже є в магазині - ці предмети будуть попередньо завантажені під час виготовлення, тобто за PreloadItems
останнім методом.
AcquireLazy
перевіряє, чи є в пулі безкоштовні предмети, а якщо ні, то створюється новий. AcquireLazyExpanding
створить новий ресурс до тих пір, поки пул ще не досяг свого цільового розміру. Я намагався оптимізувати це , щоб мінімізувати блокування, і я сподіваюся , що я не зробив ні однієї помилки (я вже відчув це при багатопоточних умовах, але , очевидно , НЕ вичерпно).
Вам може бути цікаво, чому жоден із цих методів не заважає перевірити, чи досяг магазин чи не максимальний розмір. Я дістанусь до цього через мить.
Тепер для самого басейну. Ось повний набір приватних даних, деякі з яких уже показані:
private bool isDisposed;
private Func<Pool<T>, T> factory;
private LoadingMode loadingMode;
private IItemStore itemStore;
private int size;
private int count;
private Semaphore sync;
Відповідаючи на запитання, яке я охарактеризував в останньому пункті - як забезпечити обмеження загальної кількості створених ресурсів - виявляється, що .NET вже має ідеально хороший інструмент для цього, він називається Semaphore і розроблений спеціально, щоб дозволити фіксувати кількість потоків доступу до ресурсу (у цьому випадку "ресурс" є внутрішнім сховищем елементів). Оскільки ми не впроваджуємо повну чергу виробників / споживачів, це цілком адекватно для наших потреб.
Конструктор виглядає так:
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();
}
}
Тут не повинно бути сюрпризів. Єдине, що слід зазначити, це спеціальний кожух для прагнення до завантаження, використовуючиPreloadItems
метод, який вже був показаний раніше.
Оскільки в даний час майже все було чисто абстраговано, фактичні Acquire
та Release
методи дійсно дуже прості:
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();
}
Як було пояснено раніше, ми використовуємо Semaphore
для контролю паралельності замість того, щоб релігійно перевіряти стан магазину товарів. Поки придбані предмети будуть правильно випущені, хвилюватися нема про що.
І останнє, але не менш важливе, є прибирання:
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; }
}
Призначення цього IsDisposed
майна стане зрозумілим через мить. Основним Dispose
методом дійсно є утилізація фактичних об'єднаних елементів, якщо вони реалізовані IDisposable
.
Тепер ви можете в основному використовувати це як є, з try-finally
блоком, але мені не подобається цей синтаксис, тому що якщо ви почнете передавати об'єднані ресурси між класами та методами, то це стане дуже заплутаним. Цілком можливо, що основний клас, який використовує ресурс, навіть не має має посилання на пул. Це дійсно стає безладно, тому кращим підходом є створення "розумного" об'єднаного об'єкта.
Скажімо, ми починаємо з наступного простого інтерфейсу / класу:
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);
}
}
Ось наш одноразовий Foo
ресурс, який видається, який реалізує IFoo
та має код котла для створення унікальних ідентичностей. Ми робимо ще один спеціальний об'єднаний об'єкт:
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();
}
}
Це просто пов’язує всі "реальні" методи з його внутрішнім IFoo
(ми могли б зробити це за допомогою бібліотеки динамічних проксі, як Castle, але я не вникаю в це). Він також підтримує посилання на те, Pool
що його створює, так що коли ми Dispose
цей об'єкт, він автоматично відпускає себе назад у пул. За винятком випадків, коли пул вже розміщений - це означає, що ми перебуваємо в режимі "очищення", і в цьому випадку він фактично очищає внутрішній ресурс .
Використовуючи підхід вище, ми отримуємо такий код:
// 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();
}
Це дуже гарна річ, яку можна зробити. Це означає , що код , який використовуєIFoo
(на відміну від коду , який створює його) на насправді не потрібно бути в курсі басейну. Ви навіть можете вводити IFoo
об'єкти, використовуючи свою улюблену бібліотеку DI та Pool<T>
постачальник / завод.
Я поставив повний код на PasteBin для вашого задоволення від копіювання та вставки. Існує також коротка програма тестування, яку можна використовувати для розігрування з різними режимами завантаження / доступу та багатопотоковими умовами, щоб переконатись у тому, що це безпечно для потоків і не є помилкою.
Повідомте мене, якщо у вас є якісь запитання чи проблеми.