Этот вопрос несколько сложнее, чем можно было бы ожидать из-за нескольких неизвестных: поведение пулируемого ресурса, ожидаемое / требуемое время жизни объектов, реальная причина, по которой требуется пул, и т. Д. Обычно пулы являются специальными - поток пулы, пулы соединений и т. д., поскольку их легче оптимизировать, когда вы точно знаете, что делает ресурс, и, что более важно, можете контролировать его реализацию.
Поскольку это не так просто, я попытался предложить довольно гибкий подход, с которым вы можете поэкспериментировать и посмотреть, что работает лучше всего. Заранее извиняюсь за длинный пост, но есть много оснований, которые нужно покрыть, когда дело доходит до реализации достойного пула ресурсов общего назначения. и я действительно только царапаю поверхность.
У пула общего назначения должно быть несколько основных «настроек», включая:
- Стратегия загрузки ресурсов - нетерпеливый или ленивый;
- Механизм загрузки ресурсов - как его реально создать;
- Стратегия доступа - вы упоминаете «круговой прием», который не так прост, как кажется; эта реализация может использовать циклический буфер, который похож , но не идеален, потому что пул не контролирует, когда ресурсы фактически возвращаются. Другие варианты: 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 уже есть достаточно хороший инструмент для этого, он называется Семафор и разработан специально для разрешения фиксированных количество потоков доступа к ресурсу (в данном случае «ресурс» является внутренним хранилищем элементов). Поскольку мы не внедряем полноценную очередь производителей / потребителей, это вполне соответствует нашим потребностям.
Конструктор выглядит так:
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
(мы могли бы сделать это с помощью библиотеки Dynamic Proxy, такой как 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 для вашего удобства копирования и вставки. Есть также небольшая тестовая программа, которую вы можете использовать для работы с различными режимами загрузки / доступа и многопоточными условиями, чтобы убедиться, что она поточнобезопасна и не глючит.
Дайте мне знать, если у вас есть какие-либо вопросы или опасения по этому поводу.