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

в 12:28, , рубрики: .net, многопоточность, паттерны, пул, метки: , , , ,

Доброго времени суток!
В этой статье я расскажу, как повысить производительность многопоточного (и не только) 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 по многопоточному тесту с пулом:
Реализация пула объектов на языке C#

Как видим, все упирается в метод 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 сокет-серверов, для которых данный пул как раз и писался.

Автор: Dem0n13

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js