Что такое пулинг объектов?
Пулинг объектов — это паттерн проектирования, при котором дорогие в создании объекты не уничтожаются после использования, а возвращаются в специальный «пул» для повторного использования. Это как прокат велосипедов: вместо покупки нового велосипеда каждый раз, вы берете его из проката, катаетесь и возвращаете обратно.
В .NET пулинг часто используется в самом фреймворке — вспомните пул потоков или, например, StringBuilder. Все эти абстракции под капотом используют пулинг. Он также может пригодиться и разработчику в написании обычных программ.
Когда стоит использовать пулинг?
Пулинг особенно полезен в нескольких случаях:
Высокочастотное создание объектов. Если ваш код создает сотни или тысячи объектов в секунду — веб-сервер обрабатывает запросы, игровой движок создает снаряды и эффекты, парсер обрабатывает данные, то в таких сценариях пулинг может существенно снизить нагрузку на GC.
Дорогие в создании объекты. Подключения к базе данных, HTTP-клиенты, большие массивы или объекты со сложной инициализацией. Создание таких объектов может занимать заметное время, и их переиспользование дает значительный прирост производительности.
Контролируемое потребление памяти. Пул позволяет ограничить количество одновременно существующих объектов определенного типа, что особенно важно в высоконагруженных системах.
Пулы объектов — не серебряная пуля. У него есть недостатки:
Сложность управления состоянием. Объекты в пуле должны корректно очищаться перед повторным использованием. Забытые данные в переиспользуемых объектах могут привести к утечкам памяти и неожиданному поведению.
Дополнительная сложность кода. Нужно помнить о том, чтобы возвращать объекты в пул, правильно обрабатывать исключения и очищать состояние.
Встроенные решения
В .NET есть несколько готовых решений для пулинга, которые покрывают большинство сценариев использования.
ObjectPool
ObjectPool<T> — это универсальный пул для любых объектов. Он находится в пакете Microsoft.Extensions.ObjectPool и часто используется в самом .NET.
Чтобы использовать пул, достаточно вызвать метод Create<T> из статического класса ObjectPool:
var pool = ObjectPool.Create<List<string>>();
var obj = pool.Get();
А чтобы вернуть объект обратно в пул, можно использовать метод Return:
var pool = ObjectPool.Create<List<string>>();
var obj = pool.Get();
obj.Add("some item");
// some logic
var success = pool.Return(obj);
И… в какой-то момент пул начнет возвращать заполненные массивы:
var pool = ObjectPool.Create<List<string>>();
var obj = pool.Get();
obj.Add("some item");
// some logic
pool.Return(obj);
// ---
var list = pool.Get();
Console.WriteLine(list.First()); // some item
Чтобы этого не произошло, для нашего типа нам нужна своя политика IPooledObjectPolicy. Это интерфейс, состоящий из двух методов:
Create— логика создания нового объекта.Return— метод, который очищает возвращаемый в пул объект.
Пример реализации для List<string>:
class ListPolicy : IPooledObjectPolicy<List<string>>
{
public List<string> Create()
{
return [];
}
public bool Return(List<string> obj)
{
obj.Clear();
return true;
}
}
Теперь можно создать пул с помощью var pool = ObjectPool.Create<List<string>>(new ListPolicy()); и он будет корректно работать.
А по умолчанию используется DefaultPooledObjectPolicy, реализация которого выглядит следующим образом:
public class DefaultPooledObjectPolicy<T> : PooledObjectPolicy<T> where T : class, new()
{
/// <inheritdoc />
public override T Create()
{
return new T();
}
/// <inheritdoc />
public override bool Return(T obj)
{
if (obj is IResettable resettable)
{
return resettable.TryReset();
}
return true;
}
}
Если вы хотите использовать для пулинга свой тип, он должен реализовывать интерфейс IResettable для корректной работы пула объектов.
Сам пул работает по следующему алгоритму:
- Проверяем, не равен ли
_fastItemnull и может ли текущий поток использовать его значение. Потокобезопасность этой операции обеспечивается при помощиInterlocked.CompareExchange. - Если
_fastItemравенnullили уже используется другим потоком, алгоритм пытается извлечь объект из_items. - Если получить значение и из
_fastItem, и из очереди не получилось, создается новый объект с помощью методаCreate.
Возврат объекта в пул работает так:
- Проверяем, проходит ли объект валидацию с помощью
_returnFunc. Если нет, это означает, что объект можно проигнорировать. Это регулируется интерфейсом IPooledObjectPolicy. - Если
_fastItemравенnull, объект сохраняется там при помощиInterlocked.CompareExchange. - Если
_fastItemуже используется, объект добавляется вConcurrentQueue, но только если размер очереди не превышает максимальное значение. - Если пул переполнен, то объект никуда не сохраняется.
ArrayPool
ArrayPool<T> — специализированный пул для массивов, который работает очень быстро и не требует дополнительных зависимостей.
// Берем массив минимум на 1024 элемента (может быть больше)
var buffer = ArrayPool<byte>.Shared.Rent(1024);
try
{
// Важно! Используем только первые 1024 элемента
// Реальный размер массива может быть больше
for (int i = 0; i < 1024; i++)
{
buffer[i] = (byte)(i % 256);
}
}
finally
{
// Возвращаем в пул, можно очистить содержимое
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}
SharedArrayPool под капотом имеет сложную логику: он состоит из бакетов (_buckets), которые уже хранят в себе массивы разной длины. Видов бакетов несколько: есть thread local бакеты на каждый поток, а есть общий кеш бакетов на все потоки.
ArrayPool<T>.Shared — это глобальный экземпляр пула, который подходит для большинства случаев. Но можно создать и свой конфигурируемый пул:
var pool = ArrayPool<byte>.Create(
maxArrayLength: 1024 * 1024, // максимальный размер массива
maxArraysPerBucket: 50 // максимальное количество массивов каждого размера
);
var buffer = pool.Rent(512);
// используем buffer
pool.Return(buffer);
В этом примере мы уже используем ConfigurableArrayPool, который устроен чуть проще: он содержит только один уровень кеширования — массив _buckets с массивами для выдачи. А для синхронизации между потоками используется SpinLock.
Пишем свой пул объектов
Иногда встроенных решений недостаточно. Например, для объектов со сложной логикой инициализации или когда нужен точный контроль над поведением пула. Давайте создадим простой, но эффективный пул:
public class CustomObjectPool<T> where T : class
{
private readonly ConcurrentQueue<T> _objects = new();
private readonly Func<T> _objectGenerator;
private readonly Action<T> _resetAction;
private readonly int _maxSize;
private int _currentCount;
public CustomObjectPool(Func<T> objectGenerator, Action<T> resetAction = null, int maxSize = 100)
{
_objectGenerator = objectGenerator ?? throw new ArgumentNullException(nameof(objectGenerator));
_resetAction = resetAction ?? (_ => { }); // пустое действие по умолчанию
_maxSize = maxSize;
}
public T Rent()
{
if (_objects.TryDequeue(out var item))
{
Interlocked.Decrement(ref _currentCount);
return item;
}
return _objectGenerator(); // создаем новый, если пул пуст
}
public void Return(T item)
{
if (item == null) return;
if (_currentCount < _maxSize)
{
_resetAction(item); // сбрасываем состояние объекта
_objects.Enqueue(item);
Interlocked.Increment(ref _currentCount);
}
// Если пул полный, просто отбрасываем объект
}
}
Пример использования для кастомного класса:
public class HttpRequest
{
public string Url { get; set; }
public Dictionary<string, string> Headers { get; } = new();
public string Body { get; set; }
public void Reset()
{
Url = null;
Headers.Clear();
Body = null;
}
}
// Создаем пул для HTTP-запросов
var requestPool = new CustomObjectPool<HttpRequest>(
objectGenerator: () => new HttpRequest(),
resetAction: req => req.Reset(),
maxSize: 50
);
// Используем
var request = requestPool.Rent();
try
{
request.Url = "https://api.example.com/data";
request.Headers["Authorization"] = "Bearer token123";
// выполняем запрос
}
finally
{
requestPool.Return(request);
}
Небольшой бенчмарк
Давайте сравним производительность нашего кастомного пула со встроенным ObjectPool<T>:
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net80)]
public class ObjectPoolBenchmark
{
private ObjectPool<StringBuilder> _msPool;
private CustomObjectPool<StringBuilder> _customPool;
[GlobalSetup]
public void Setup()
{
var provider = new DefaultObjectPoolProvider();
_msPool = provider.Create(new StringBuilderPooledObjectPolicy());
_customPool = new CustomObjectPool<StringBuilder>(
() => new StringBuilder(),
sb => sb.Clear()
);
}
[Benchmark(Baseline = true)]
public string WithoutPool()
{
var sb = new StringBuilder();
sb.Append("Hello, World!");
return sb.ToString();
}
[Benchmark]
public string WithMsObjectPool()
{
var sb = _msPool.Get();
try
{
sb.Append("Hello, World!");
return sb.ToString();
}
finally
{
_msPool.Return(sb);
}
}
[Benchmark]
public string WithCustomPool()
{
var sb = _customPool.Rent();
try
{
sb.Append("Hello, World!");
return sb.ToString();
}
finally
{
_customPool.Return(sb);
}
}
}
В результатах видно, что пулинг дает значительное преимущество в количестве аллокаций. Наш кастомный пул работает медленнее, чем все остальные подходы, однако по остальным аллокациям не уступает ObjectPool от Microsoft.
Старайтесь не писать свой пул объектов, а использовать существующие: скорее всего, там уже предусмотрены все нюансы, связанные как со скоростью работы, так и с многопоточностью и аллокациями.
Подводные камни и лучшие практики
При работе с пулами объектов важно помнить несколько моментов:
Всегда очищайте состояние объектов. Объекты в пуле переиспользуются, и данные от предыдущего использования могут «просочиться» в новый контекст. Это особенно критично для объектов, содержащих конфиденциальную информацию.
Используйте using или try-finally. Объекты должны возвращаться в пул даже при исключениях. Невозвращенные объекты могут привести к исчерпанию пула и деградации производительности.
// Хороший подход с using
public readonly struct PooledArray<T> : IDisposable
{
private readonly T[] _array;
private readonly ArrayPool<T> _pool;
public PooledArray(ArrayPool<T> pool, int minimumLength)
{
_pool = pool;
_array = pool.Rent(minimumLength);
}
public T[] Array => _array;
public void Dispose()
{
_pool.Return(_array);
}
}
// Использование
using var pooledArray = new PooledArray<byte>(ArrayPool<byte>.Shared, 1024);
// массив автоматически вернется в пул при выходе из scope
Не злоупотребляйте пулингом. Для простых объектов (например, небольших структур или строк) накладные расходы на пулинг могут превышать выгоду. Измеряйте производительность в реальных сценариях.
Когда НЕ нужно использовать пулинг?
- Объекты создаются редко (меньше сотен раз в секунду).
- Объекты простые и дешевые в создании (примитивные типы, маленькие структуры).
- Объекты имеют сложное изменяемое состояние, которое трудно очистить.
- В однопоточных приложениях без высокой нагрузки.
- Когда код усложняется больше, чем выигрыш в производительности.
Выводы
Пулинг объектов — мощный инструмент оптимизации, который может значительно улучшить производительность высоконагруженных приложений. В большинстве случаев достаточно встроенных решений: ArrayPool<T> для массивов и ObjectPool<T> для произвольных объектов.
Собственный пул стоит писать, только если встроенные решения не подходят по функциональности или если нужен очень специфичный контроль над поведением. В таких случаях начните с простой реализации на основе ConcurrentQueue<T> — она покроет 90% потребностей.
Самое главное — не забывайте измерять производительность до и после внедрения пулинга. Иногда «оптимизация» может оказаться преждевременной и ненужной.
Спасибо за прочтение статьи! Кроме того, я веду телеграм-канал Flexible Coding, где рассказываю о своем опыте в программировании и делюсь практическими советами по разработке.