Пул объектов (Object Pool) — это паттерн, который позволяет повторно использовать объекты вместо создания новых. Это может быть полезно в ситуациях, когда инициализация объектов затратна. Паттерн широко применяется в разработке игр и приложениях, где важно минимизировать использование памяти. В этой статье мы рассмотрим, как этот шаблон реализован в C#, и как он может улучшить производительность.
Содержание
Дисклеймер
Результаты бенчмарков в этой статье очень условны и верны только при определённых условиях. Допускаю, что бенчмарк может показать другие результаты на другом ПК, с другим ЦП, с другим компилятором или при другом сценарии использования рассматриваемого функционала языка. Всегда проверяйте ваш код на конкретно вашем железе и не полагайтесь лишь на статьи из интернета.
Исходный код бенчмарков и сырые данные результатов можно найти в этом репозитории.
Что такое пул объектов?
Пул объектов (Object Pool) — это паттерн, который позволяет повторно использовать объекты вместо создания новых. Это может быть полезно в ситуациях, когда инициализация объектов затратна. Использование пула состоит из следующих шагов:
Получение объекта из пула.
Использование объекта.
Возврат объекта в пул.
[Опционально] Пул объектов может сбрасывать состояние объекта при его возврате.
Псевдокод использования пула объектов выглядит следующим образом:
var obj = objectPool.Get();
try
{
// выполняем какую-нибудь работу с obj
}
finally
{
objectPool.Return(obj, reset: true);
}
Пулы объектов широко используется в разработке игр и приложениях, где важно минимизировать использование памяти.
В .NET есть несколько классов, реализующих пул объектов:
ObjectPool: универсальный пул объектов.
ArrayPool: класс, предназначенный специально для массивов.
Эти классы кажутся похожими, но их реализация отличается. Мы рассмотрим их отдельно.
Класс ObjectPool
Класс ObjectPool
по умолчанию доступен только в приложениях ASP.NET Core. Его исходный код можно найти здесь. Для других C# приложений необходимо установить пакет Microsoft.Extensions.ObjectPool.
Чтобы использовать пул, нужно вызвать метод Create<T>
из статического класса ObjectPool
:
var pool = ObjectPool.Create<SomeType>();
var obj = pool.Get();
При помощи интерфейса IPooledObjectPolicy
можно контролировать, как объекты создаются и возвращаются. Например, для List<int>
, можно определить следующую политику:
public class ListPolicy : IPooledObjectPolicy<List<int>>
{
public List<int> Create() => [];
public bool Return(List<int> obj)
{
obj.Clear(); // чистим список перед возвратом
return true;
}
}
Теперь посмотрим, как класс ObjectPool
работает внутри.
Что под капотом
Пул состоит из одного поля _fastItem
и потокобезопасной очереди items
.
Получение объекта из пула работает следующим образом:
Алгоритм проверяет, не равен ли
_fastItem
null
и может ли текущий поток использовать его значение. Потокобезопасность этой операции обеспечивается при помощиInterlocked.CompareExchange
.Если
_fastItem
равенnull
или уже используется другим потоком, алгоритм пытается извлечь объект из_items
.Если получить значение и из
_fastItem
, и из очереди не получилось, создается новый объект с помощью фабричного метода.
Возврат объекта в пул происходит противоположным образом:
Алгоритм проверяет, проходит ли объект валидацию с помощью
_returnFunc
. Если нет, это означает, что объект может быть проигнорирован. Это регулируется интерфесом IPooledObjectPolicy.Если
_fastItem
равенnull
, объект сохраняется там при помощиInterlocked.CompareExchange
.Если
_fastItem
уже используется, объект добавляется вConcurrentQueue
, но только если размер очереди не превышает максимальное значение.Если пул переполнен, то объект никуда не сохраняется.
Производительность
Чтобы протестировать, как ObjectPool<T>
влияет на производительность, созданы два бенчмарка:
без пула объектов (создаётся новый список для каждой операции);
с пулом объектов.
Каждый бенчмарк выполняет следующие шаги в цикле:
Создаёт новый список или получает из пула.
Добавляет значения в список.
Возвращает список в пул (если используется пул).
Бенчмарки повторяют этот процесс 100 раз для каждого потока. Количество потоков варьируется от 1 до 32. Размер списка варьируется от 10 до 1 000 000 элементов.
Результаты показаны на диаграмме ниже. Шкала оси x логарифмическая. Ось y показывает процентное отклонение по сравнению с бенчмарком без пула объектов.
Из результатов видно, что использование ObjectPool
в однопоточном сценарии быстрее на 10% – 50% по сравнению с созданием нового списка для каждой итерации. При многопоточном доступе к пулу и работе с относительно маленькими объектами, результаты ObjectPool
хуже. Это, вероятно, связано с задержкой при синхронизации потоков во время доступа к _fastItem
и ConcurrentQueue
.
Класс ArrayPool
ArrayPool<T>
— это класс, доступный в любом C# приложении. Класс находится в пространстве имён System.Buffers
. Его исходный код можно найти здесь. ArrayPool
– это абстрактный класс с двумя реализациями: SharedArrayPool и ConfigurableArrayPool.
Использовать ArrayPool<T>
так же просто, как и ObjectPool. Пример с SharedArrayPool
ниже:
var pool = ArrayPool<int>.Shared;
var buffer = pool.Rent(10);
try
{
// do some work with array
}
finally
{
pool.Return(buffer, clear: true);
}
При помощи статического метода Create
можно настроить пул. В таком случае будет использована реализация ConfigurableArrayPool
.
var pool = ArrayPool<int>.Create(maxArrayLength: 1000, maxArraysPerBucket: 20);
Этот метод позволяет указать максимальную длину массива и максимальное количество массивов в каждом бакете (о бакетах мы поговорим позже). По умолчанию эти значения равны и соответственно.
Важно отметить, что размер возвращаемого массива будет не меньше запрашиваемого размера, но он может быть больше:
using System.Buffers;
var (pow, cnt) = (4, 0);
while (pow <= 30)
{
var x = (1 << pow) - 1;
var arr = ArrayPool<int>.Shared.Rent(x);
Console.WriteLine(
"Renting #{0}. Requested size: {1}. Actual size: {2}.",
++cnt, x, arr.Length);
pow++;
}
// Renting #1. Requested size: 15. Actual size: 16.
// Renting #2. Requested size: 31. Actual size: 32.
// Renting #3. Requested size: 63. Actual size: 64.
// ...
// Renting #26. Requested size: 536870911. Actual size: 536870912.
// Renting #27. Requested size: 1073741823. Actual size: 1073741824.
Что под капотом
Как уже упоминалось, ArrayPool<T>
имеет две реализации. Рассмотрим их отдельно.
Класс SharedArrayPool
SharedArrayPool
имеет двухуровневый кэш:
Кэш для каждого потока (per-thread cache).
Общий кэш.
Кэш для каждого потока реализован как приватное статическое поле t_tlsBuckets
, которое по сути является массивом массивов. У каждого потока своя собственная копия t_tlsBuckets
благодаря Thread Local Storage (TLS). В C# для этого используется атрибут ThreadStaticAttribute. Использование TLS позволяет каждому потоку иметь свой небольшой кэш для различных размеров массивов, от до (всего 27 бакетов).
При попытке получить массив из пула, алгоритм сначала пытается получить его из поля t_tlsBuckets
. Если массив требуемого размера не найден в t_tlsBuckets
, проверяется общий кэш в поле _buckets
. Этот общий кэш представляет собой массив объектов Partitions
, по одному для каждого допустимого размера бакетов. Каждый объект Partitions
содержит массив объектов Partition
, где N
— это количество процессоров. Каждый объект Partition
работает как стек, который может содержать до 32 массивов. Да, это звучит мудрёно, поэтому смотрим диаграмму ниже.
Когда массив возвращается в пул, алгоритм пытается сохранить его в кэше 1 уровня. Если t_tlsBuckets
уже содержит массив того же размера, существующий массив из t_tlsBuckets
помещается в общий кэш, а новый массив сохраняется в t_tlsBuckets
для лучшей производительности (для лучшей локальности кэша процессора). Если стек в Partition
текущего ядра переполнен, алгоритм ищет свободное место в стеках в Partition
других ядер. Если все стеки переполнены, массив игнорируется.
Класс ConfigurableArrayPool
ConfigurableArrayPool
устроен проще, чем SharedArrayPool
. У него есть только одно приватное поле _buckets
. Это поле является массивом объектов Bucket
, где каждый Bucket
представляет собой коллекцию массивов (смотрите диаграмму ниже). Поскольку поле _buckets
используется всеми потоками, каждый Bucket
использует SpinLock для обеспечения потокобезопасного доступа.
Производительность
Бенчмарки для ArrayPool<T>
похожи на бенчмарки для ObjectPool<T>
:
без использования пула (создаётся новый массив для каждой операции);
с общим пулом (
SharedArrayPool
);с настраиваемым пулом (
ConfigurableArrayPool
).
Как видно из результатов, SharedArrayPool
работает быстрее почти во всех случаях, особенно в сценариях с несколькими потоками. Единственное исключение — это когда размер массива равен 10.
Противоположная ситуация наблюдается с ConfigurableArrayPool
. Производительность в многопоточном сценарии и при работе с относительно небольшими массивами хуже. Думаю, что причина та же, что и у ObjectPool<T>
: задержкой при синхронизации потоков во время доступа к массивам внутри Bucket
.
Заключение
ObjectPool
и ArrayPool
могут улучшить производительность если создание объектов затратно и их переиспользование возможно. Но нужно быть осторожным, т.к. механизмы синхронизации могут ухудшить производительность, особенно, если пулы используются для относительно небольших объектов.
Комментарии (2)
mizvekov
09.12.2024 09:01Есть еще хорошие стримы с пулингом - https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream.
serchu
В моём понимании, pool нужен, чтобы снизить memory traffic и GC-pressure в сценариях с частым выделением памяти под массивы. Время выполнения, конечно, зависит от состояния GC, но скрывает смысл этого способа оптимизации. Если у вас в коде нет проблем с memory traffic, то pool не даст особого прироста производительности