Реализация пула объектов на языке C#

Доброго времени суток!
В этой статье я расскажу, как повысить производительность многопоточного (и не только) C#-приложения, в котором часто создаются объекты для «одноразовой» работы.
Немного про многопоточность, неблокирующую синхронизацию, использование встроенного в VS2012 профилировщика и небольшой бенчмарк.

Введение


Пул объектов — порождающий шаблон проектирования, набор инициализированных и готовых к использованию объектов.
Зачем он нужен? Вкратце — для повышения производительности, когда инициализация нового объекта приводит к большим затратам. Но тут важно понимать, что встроенный в .NET сборщик мусора прекрасно справляется с уничтожением легких короткоживущих объектов, поэтому применимость пула ограничивается следующими критериями:
  • дорогие для создания и/или уничтожения объекты (примеры: сокеты, потоки, неуправляемые ресурсы);
  • очистка объектов для переиспользования дешевле создания нового (или ничего не стоит);
  • объекты очень большого размера.

Немного поясню последний пункт. Если ваш объект занимает в памяти 85 000 байт и более, он попадает в кучу больших объектов (large object heap) во втором поколении сборки мусора, что автоматически делает его «долгоживущим» объектом. Прибавим к этому фрагментированность (эта куча не сжимается) и получим потенциальную проблему нехватки памяти при постоянных выделениях/уничтожениях.
Идея пула состоит в том, чтобы организовать переиспользование «дорогих» объектов, используя следующий сценарий:

var obj = pool.Take(); // нам потребовался рабочий объект. Вместо создания мы запрашиваем его из пула
obj.DoSomething();
pool.Release(obj); // возвращаем ("освобождаем") объект в пул, когда он становится не нужным

Проблемы такого подхода:
  • после выполнения работы с объектом может потребоваться его сброс в начальное состояние, чтобы предыдущее использование никак не влияло на последующие;
  • пул должен обеспечивать потокобезопасность, ведь применяется он, как правило, в многопоточных системах;
  • пул должен обрабатывать ситуацию, когда в нем не осталось доступных для выдачи объектов.

С учетом этих проблем были составлены требования к новому классу:
  1. Типобезопасность пула на этапе компиляции.
  2. Работа пула с любыми классами, в том числе сторонними.
  3. Простое использование в коде.
  4. Авто-выделение новых объектов при нехватке, их пользовательская инициализация.
  5. Ограничение общего количества выделенных объектов.
  6. Авто-очистка объекта при его возвращении в пул.
  7. Потокобезопасность (желательно, с минимальными расходами на синхронизацию).
  8. Поддержка множества экземпляров пула (отсюда вытекает хотя-бы простейший контроль того, чтобы объекты возвращались именно в свои пулы).


Решение проблем использования


В некоторых реализациях, для поддержки пулом объекта, объект должен реализовать интерфейс IPoolable или аналогичные, но моей задачей было обеспечение работы пула с любыми классами, даже если они закрыты для наследования. Для этого была создана generic-оболочка PoolSlot, которая внутри содержит сам объект и ссылку на пул. Сам же пул представляет собой абстрактный generic-класс для хранения этих слотов, с двумя нереализованными методами для создания нового объекта и очистки старого.

public abstract class Pool<T>
{
	public PoolSlot<T> TakeSlot() {...} // операция "взять из пула"
	public void Release(PoolSlot<T> slot) {...} // операция "вернуть из пула"
	
	/* ... */
	
	// методы для переопределения:
	protected abstract T ObjectConstructor(); // создание нового объекта, готового для использования
	protected virtual void CleanUp(T item) {} // очистка использованного объекта, вызываемая автоматически
}

Использование на примере класса SocketAsyncEventArgs

Определение пула
public class SocketClientPool : Pool<SocketAsyncEventArgs>
{
	private readonly int _bufferSize;

	public SocketClientPool(int bufferSize, int initialCount, int maxCapacity)
		: base(maxCapacity)
	{
		if (initialCount > maxCapacity)
			throw new IndexOutOfRangeException();

		_bufferSize = bufferSize;
		TryAllocatePush(initialCount); // в базовом классе объявлено несколько protected-методов; этот создает и помещает в пул указанное число новых объектов
	}

	protected override SocketAsyncEventArgs ObjectConstructor()
	{
		var args = new SocketAsyncEventArgs();
		args.SetBuffer(new byte[_bufferSize], 0, _bufferSize);
		return args;
	}

	protected override void CleanUp(SocketAsyncEventArgs @object)
	{
		Array.Clear(@object.Buffer, 0, _bufferSize);
	}
}

Использование в коде:

var pool = new SocketClientPool(1024, 5, 10); // при старте сервера, например
/* ...где-то в коде... */
var slot = pool.TakeSlot(); // взятие слота с объектом
var args = slot.Object; // оригинальный объект для каких-либо действий
pool.Release(slot); // возвращение обратно в пул

Или даже так:

using(var slot = pool.TakeSlot()) // класс PoolSlot реализует IDisposable
{
	var args = slot.Object;
}

Те, кто знаком с асинхронной моделью .NET и/или с асинхронными методами того же самого класса Socket знают, что использование такой реализации затруднено, потому что методы Socket.XxxAsync принимают на вход именно SocketAsyncEventArgs, а не какой-то там PoolSlot<SocketAsyncEventArgs>. Для вызова метода это не беда, но откуда же брать слот в обработчике окончания?
Один из вариантов — сохранить слот в свойстве SocketAsyncEventArgs.UserToken при создании объекта, для этого в пуле есть метод для переопределения HoldSlotInObject.
Переопределение для нашего примера
protected override void HoldSlotInObject(SocketAsyncEventArgs @object, PoolSlot<SocketAsyncEventArgs> slot)
{
	@object.UserToken = slot;
}

/* ...где-то в коде... */
pool.Release(args.UserToken as PoolSlot<SocketAsyncEventArgs>);

Конечно, не каждый объект предоставляет пользователю такое свойство. И если ваш класс все-таки не закрыт от наследования, то предлагается специальный интерфейс IPoolSlotHolder с одним единственным свойством для хранения слота. И если я знаю, что мой объект гарантированно содержит слот, то было бы логичным дописать методы TakeObject/Release, возвращающие/принимающие сами объекты (и получать их слот внутри), что и было сделано в потомке пула.
Упрощенная реализация улучшенного пула (для объектов, реализующих IPoolSlotHolder
public abstract class PoolEx<T> : Pool<T>
	where T : IPoolSlotHolder
{
	public T TakeObject() { ... }
	public void Release(T @object) { ... }
	protected sealed void HoldSlotInObject(T @object, PoolSlot<T> slot) { ... } // уже ничего переопределять не надо
}

Далее я предлагаю ознакомиться с разработкой внутренней «кухни».

Хранилище


Для хранения объектов «в пуле» используется коллекция ConcurrentStack. Возможное использование нескольких экземпляров пула потребовало ведение учета, какой из объектов был создан именно этим пулом.
Так был введен «реестр» на основе ConcurrentDictionary, который содержит ID слотов, когда-либо созданных пулом и флаг доступности объекта (true — «в пуле», false — «не в пуле»).
Это позволило убить сразу 2х зайцев: предотвратить ошибочное многократное возвращение одного и того же объекта (ведь стек не обеспечивает уникальности хранимых в нем объектов) и предотвратить возвращение объектов, созданных в другом пуле. Данный подход был временным решением, и далее я от него избавился.

Многопоточность


Классическая реализация пула предполагает использование семафора (в .NET это Semaphore и SemaphoreSlim) для слежения за количеством объектов, либо других примитивов синхронизации в связке со счетчиком, но ConcurrentStack, как и ConcurrentDictionary — потокобезопасные коллекции, поэтому сам ввод-вывод объектов регулировать уже не требуется. Замечу только, что вызов свойства ConcurrentStack.Count вызывает полный перебор всех элементов, что занимает существенное время, поэтому было решено добавить свой счетчик элементов. В итоге, было получено две «атомарных» операции над пулом — Push и TryPop, на основе которых строились все остальные.
Реализация простейших операций
private void Push(PoolSlot<T> item)
{
	_registry[token.Id] = true; // реестр: объект "в пуле"
	_storage.Push(item); // возвращаем объект в хранилище
	Interlocked.Increment(ref _currentCount);
}

private bool TryPop(out PoolSlot<T> item)
{
	if (_storage.TryPop(out item)) // пытаемся взять объект из хранилища
	{
		Interlocked.Decrement(ref _currentCount);
		_registry[token.Id] = false; // реестр: объект "не в пуле"
		return true;
	}
	item = default(PoolSlot<T>);
	return false;
}

Помимо ввода-вывода существующих объектов, необходимо синхронизировать и выделение новых до указанного верхнего предела.
Тут примени́м семафор, инициализированный максимальным числом элементов в пуле (верхним лимитом) и вычитающий по единице каждый раз при создании нового объекта, но проблема в том, что при достижении нуля он просто заблокирует поток. Выходом из этой ситуации мог бы стать вызов метода SemaphoreSlim.Wait(0), который при текущем значении семафора «0» почти без задержки отдает false, но было решено написать легковесный аналог этого функционала. Так появился класс LockFreeSemaphore, который при достижении нуля без задержек возвращает false. Для внутренней синхронизации он использует быстрые CAS-операции Interlocked.CompareExchange.
Пример использования CAS-операции в семафоре
public bool TryTake() // возвращает true, если успешно вошли в семафор, иначе false (если все ресурсы заняты)
{
	int oldValue, newValue;
	do
	{
		oldValue = _currentCount; // запоминаем старое значение
		newValue = oldValue - 1; // вычисляем новое значение
		if (newValue < 0) return false; // если семафор уже равен 0 - возвращаем false без ожиданий
	} while (Interlocked.CompareExchange(ref _currentCount, newValue, oldValue) != oldValue); // если старое значение не было изменено другим потоком, то оно заменяется новым и цикл успешно завершается
	return true;
}

Таким образом, операция пула «взять объект» работает по следующему алгоритму:
  1. Пытаемся взять объект из хранилища, если там нету — пункт 2.
  2. Пытаемся создать новый объект, если семафор равен нулю (достигнут верхний лимит) — пункт 3.
  3. Самый плохой сценарий — ожидаем возвращение объекта до победного конца.


Первые результаты, оптимизация и рефакторинг


Так ли нужен пул объектов? Зависит от ситуации. Вот результаты небольшого тестирования с использованием «типичного серверного объекта», SocketAsyncEventArgs с буфером на 1024 байта (время в секундах, создание пула включено):
Запросов нового объекта Один поток, без пула Один поток, с пулом 25 задач*, без пула 25 задач*, с пулом
1 000 0.002 0.003 0.027 0.009
10 000 0.010 0.001 0.272 0.039
25 000 0.030 0.003 0.609 0.189
50 000 0.048 0.006 1.285 0.287
1 000 000 0.959 0.125 27.965 8.345

* задача — класс System.Threading.Tasks.Task из библиотеки TPL, начиная с .NET 4.0
Результаты прохода профилировщика VS2012 по многопоточному тесту с пулом:


Как видим, все упирается в метод ConcurrentStack.TryPop, который (будем считать) ускорять некуда. На втором месте обращение к «реестру», который отбирает примерно по 14% в обеих операциях.
В принципе, поддержка второй коллекции внутри пула и так казалась мне костыльной, поэтому признак «в пуле/не в пуле» был перенесен в сам слот, а реестр благополучно удален. Результаты тестов после рефакторинга (прирост, как и ожидалось, 30-40%):
Запросов нового объекта 25 задач, с пулом
25 000 0.098
1 000 000 5.751

Думаю, на этом можно остановиться.

Заключение


Вкратце напомню, как решались поставленные задачи:
  1. Типобезопасность на этапе компиляции — использование generic-классов.
  2. Работа пула с любыми классами — использование generic-оболочки без наследований.
  3. Облегчение использования — конструкция using (реализация оболочкой интерфейса IDisposable).
  4. Авто-выделение новых объектов — абстрактный метод Pool.ObjectConstructor, в котором инициализируется объект как душе угодно.
  5. Ограничение количества объектов — облегченный вариант семафора.
  6. Авто-очистка объекта при его возвращении — виртуальный метод Pool.CleanUp, который автоматически вызывается пулом при возвращении.
  7. Потокобезопасность — использование коллекции ConcurrentStack и CAS-операций (методов класса Interlocked).
  8. Поддержка множества экземпляров пула — класс Pool не статический, не синглтон и обеспечивает проверки на допустимость операций.


Исходный код с unit-тестами и тестовым приложением: Github
Если интересно, могу продолжить статью реализацией асинхронных TCP и UDP сокет-серверов, для которых данный пул как раз и писался.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 16

    +1
    Конечно пишите.
    И ещё предложение, наверное…
    Например, там ребята на WCF раз в пять минут рвут и пересоздают коннекты из пула.
    Load Balancing the Net.TCP Binding (на биндинге, для которого имеет смысл, конечно).
    По крайней мере в моем последнем самописном пуле, безо всякой перебалансировки, коннекты к одной замечательной системе печальным образом протухали, и моя самописка сразу выросла раза в два :\
      +1
      Тут важно не забывать в CleanUp-е чистить ссылки на другие объекты, иначе будет утекать память.
      Вообще складывается ощущение, что сборка мусора порождает у программистов некоторую эйфорию по отношению к выделению-освобождению памяти, и утечки порой находишь не только в сторонних библиотеках (как раз такая утечка из-за нечищеных ссылок в пуле есть в EZ GUI), но и в системных.
        0
        В копилку знаний добавлю размышления тов. Rico Mariani на счет пулов:
        If you understand the usage patterns of your program well (say it’s a web server) and you can readily predict that the objects you are about to release are going to be needed again in about 2 milliseconds (or something) when the next request comes in, there isn’t a whole lot of point in freeing them. You might just as well hang on to those objects, maybe put them in a pool somewhere, and when that request does come along, you can get them out of the pool.

        Sounds great… but there’s a catch. There’s always a catch. Or two.

        ;)
          +3
          У вас в коде есть race condition. Т.к. фактическое добавление/удаление объектов не атомарно с изменением счётчика. Так сходу, ничего особо криминального из побочных эффектов назвать не могу. Но как минимум могут быть ложные срабатывания ограничителя: то есть объект в пул ужу вернули, а счётчик ещё не обновился и другой поток не сможет получить экземпляр.

          Lock-free это всё круто, но надо ещё учитывать реальную частоту, с которой будут идти обращения к пулу. Вот в вашем, примере идёт работа с сокетами. Любое обращение к сети в 1000 и более раз медленнее, чем время переключения потоков/синхронизации. Так что, если написать такой же пул с обычным List и помечать какой свободен, а какой занят, то реальная производительность приложения вряд ли будет отличаться хотя бы на 1%.
          Хотя при этом, конечно, синтетический тест lock-free пул будет быстрее.
            0
            Именно поэтому я взял слово «атомарная» в кавычки. Ничего страшного действительно не произойдет.
            Поток получит свой объект в таком случае при следующем запросе к пулу.
              +1
              А что поток будет делать пока не получит объект? У вас что, ещё и каждый поток должен уметь несколько раз попробовать? На мой взгляд это очень не типично для пулов. Пул должен либо вернуть существующий объект, либо создать новый, либо бросить исключение, если по какой-то причине невозможно вернуть объект (кончилась память, достигнут жёсткий лимит на кол-во объектов).

              Повторюсь: для данной задачи, синхронизация с блокировками будет тривиальной в реализации, полностью надёжной/корректной, и почти никак не скажется на производительности реального приложения.
              Я считаю, что не нужно усложнять. Это из серии микрооптимизаций, вместо X /2 писать X >> 1 :)

              При этом, сами по себе синхронные примитивы вроде ConcurrentDictionary мне очень нравятся, они особенно хороши, когда много потоков пишет одновременно. Тогда есть очень большой выигрыш. Если же, пишет 1-2 потока, то обычный Dictionary + ReaderWriterLockSlim работает никак не хуже, а логика работы опять же оказывается проще.
                0
                Конечно же ConcurrentDictionary — это не «синхронный примитив», а коллекция, заговорился :)
                  +1
                  Нет, поток ничего не должен уметь. Сам пул уходит в ожидание возврата объекта, то есть метод Take гарантированно вернет объект. Суть еще в том, что при адекватном максимальном значении такая ситуация будет редко возникать.

                  Отвечу на замечания по микрооптимизации. Я согласен с вашим комментарием, в простейшей реализации пула будет достаточно и простого Lock, он хоть и проигрывает в синтетике, но реальное приложение редко будет требовать такого прироста. Моей целью была не реализация наипростейшего пула (на мсдн кстати лежит вполне годная с использованием ConcurrentBag), а демонстрация того, как можно сделать вообще без классических примитивов.
                  За критику — спасибо.
                    0
                    Интересно, насколько та очевидная реализация быстрее этой.
              +1
              Думаю использование пула имеет очень ограниченную область применения.
              Во-первых, он усложняет приложение.
              Во-вторых, он требует очень аккуратного использования. Как уже писали, если вы не очистите ссылки в CleanUp — будет утечка памяти (и это еще в лучшем случае), а сделать такю ошибку — очень легко.
              И в-третьих — он бесполезен для больших объектов (те, которые в LOH). И вот почему: откуда может взяться объект размером 85к и больше? Очевидно, что это массив(список, строка). Но есть ли смысл хранить массив в пуле? Ведь массив обычно нужен переменной длины. И как пул может нам помочь здесь? Менять длину массива он не умеет. А если бы даже и умел, то только путем создания нового массива, что сводит на нет преимущество пула. А кроме того, метод CleanUp должен будет чистить весь массив, а это может занять значительное время, если массив действительно большой.
              Если же объект не является сам массивом, а содержит поле с массивом, то пул тоже бесполезен, потому что метод CleanUp обязан будет уничтожить ссылку на этот массив. В общем как ни крути, но для больших объектов — пул не подходит в общем случае.
                0
                Разумеется, что пулы объектов не нужно пытаться использовать везде. Производительности сборщика мусора, особенно в последней версии фреймворка хватает для большинства случаев.

                Для больших массивов есть похожий паттерн — buffer pooling, когда заранее создается большой массив (байтов например), и по требованию выделяются сегменты этого массива.
                  0
                  Попробую ответить на каждый пункт:
                  1. Мне кажется, что написать Take вместо new, а Release вместо потенциального Dispose — это вполне приемлимое усложение в ситуации, когда нам пул нужен. Если про общую архитектуру идет речь — согласен, +(1-2) класса, хотя это тоже не так много.
                  2. Ошибку можно сделать и просто «бросая» объекты. Может, сложнее, чем с пулом, не спорю, но тема отдельная))
                  3. Объекты 85к как правило массивы, вполне могут быть неуправляемым блоком памяти и/или фиксированным буфером для череды каких-либо операций. Операция Array.Clear работает быстрее, чем создание такого же огромного массива.
                  Посмотрите мой пример, там используется объект SocketAsyncEventArgs, который содержит фиксированный буфер памяти для операции с сокетами. И по тестам видно, что очистка этого буфера происходит быстрее, чем создание нового объекта. Конечно, ограничения использования есть и часть их приведена в самом начале статьи.
                  0
                  Хороших статей очень мало, а они нужны как воздух, спасибо Вам за эту статью, будет здорово, если поделитесь опытом сокет-серверов. Может быть, Вы даже можете подсказать, что делать с такой проблемой — когда есть множество сокет-клиентов, которые должны непрерывно читать данные, а также всегда пересоединяться при обрыве соединения, и этих сокетов может быть очень много. И тут, с одной стороны, не хочется плодить потоки, а с другой — объединять обработку всех сокетов в один поток. Как найти золотую середину?
                    +1
                    Использовать асинхронные сокеты и быстрые обработчики событий. При вызове асинхронной операции, она завершается в другом потоке из ThreadPool, который для этого выделяется. Т. е. напрямую потоками управлять не нужно. Их будет использоваться ровно столько, сколько одновременно будет выполняться обработчиков завершения операции. Поэтому обработчики нужно делать быстрыми, чтобы они на долго не занимали временно выделенные под них потоки. Если не получается, то данные можно складывать в очередь и выделять отдельные потоки-задачи для обслуживания этой очереди. Конкретное решение будет зависеть от ваших требований, количества одновременных клиентов, операций и так далее.
                      +1
                      Напрямую с потоками работать не надо — проще использовать асинхронную модель .NET (которая поточность разруливает сама через ThreadPool) и методы сокетов Socket.BeginXxx и EndXxx, либо обновленные методы Socket.XxxAsync. Последние как раз ориентированы на работу с пулом переиспользуемых объектов и должны работать быстрее.
                      Начать можно отсюда, хорошая статья вплоть до методов BeginXxx/EndXxx и про обновленные методы немножко здесь. Также по ссылке из статьи можете глянуть мою реализацию.
                      0
                      Поддерживаю автора в том, что такие вещи нужно пытаться написать. Продолжайте, это хорошая тренировка.

                      И не поддерживаю конкретно в этой затее.
                      Т. к. создание объекта в «управляемой памяти» фактически инкремент специального указателя (или нескольких указателей, в случае нескольких потоков), то ускориться в этом месте нельзя.

                      А вот если объекты «долго создаются», т.е. имеется разбазаривание времени в конструкторе, то этот момент нужно найти и победить.

                      А насчет пулов…
                      В одной системе я выкинул чей-то пул коннектов, и добился не только повышения ясности кода и повышения надежности (ради чего и выкидывал), но и увеличения производительности.
                      В другой системе мне удалось заменить пул одним коннектом к БД — тоже настала нирвана.

                      Only users with full accounts can post comments. Log in, please.