Пулы объектов в C#: примеры, устройство и производительность
Пул объектов (Object Pool) — это паттерн, который позволяет повторно использовать объекты вместо создания новых. Это может быть полезно в ситуациях, когда инициализация объектов затратна. Паттерн широко применяется в разработке игр и приложениях, где важно минимизировать использование памяти. В этой статье мы рассмотрим, как этот шаблон реализован в C#, и как он может улучшить производительность.
Дисклеймер
Результаты бенчмарков в этой статье очень условны и верны только при определённых условиях. Допускаю, что бенчмарк может показать другие результаты на другом ПК, с другим ЦП, с другим компилятором или при другом сценарии использования рассматриваемого функционала языка. Всегда проверяйте ваш код на конкретно вашем железе и не полагайтесь лишь на статьи из интернета.
Исходный код бенчмарков и сырые данные результатов можно найти в этом репозитории.
Что такое пул объектов?
Пул объектов (Object Pool) — это паттерн, который позволяет повторно использовать объекты вместо создания новых. Это может быть полезно в ситуациях, когда инициализация объектов затратна. Использование пула состоит из следующих шагов:
Получение объекта из пула.
Использование объекта.
Возврат объекта в пул.
[Опционально] Пул объектов может сбрасывать состояние объекта при его возврате.
Псевдокод использования пула объектов выглядит следующим образом:
var obj = objectPool.Get();
try
{
// выполняем какую-нибудь работу с obj
}
finally
{
objectPool.Return(obj, reset: true);
}
Пулы объектов широко используется в разработке игр и приложениях, где важно минимизировать использование памяти.
Пример поиска Object Pool в GitHub
В .NET есть несколько классов, реализующих пул объектов:
ObjectPool: универсальный пул объектов.
ArrayPool: класс, предназначенный специально для массивов.
Эти классы кажутся похожими, но их реализация отличается. Мы рассмотрим их отдельно.
Класс ObjectPool
Класс ObjectPool
по умолчанию доступен только в приложениях ASP.NET Core. Его исходный код можно найти здесь. Для других C# приложений необходимо установить пакет Microsoft.Extensions.ObjectPool.
Чтобы использовать пул, нужно вызвать метод Create
из статического класса ObjectPool
:
var pool = ObjectPool.Create();
var obj = pool.Get();
При помощи интерфейса IPooledObjectPolicy
можно контролировать, как объекты создаются и возвращаются. Например, для List
, можно определить следующую политику:
public class ListPolicy : IPooledObjectPolicy>
{
public List Create() => [];
public bool Return(List obj)
{
obj.Clear(); // чистим список перед возвратом
return true;
}
}
Теперь посмотрим, как класс ObjectPool
работает внутри.
Что под капотом
Пул состоит из одного поля _fastItem
и потокобезопасной очереди items
.
var pool = ArrayPool При помощи статического метода Этот метод позволяет указать максимальную длину массива и максимальное количество массивов в каждом бакете (о бакетах мы поговорим позже). По умолчанию эти значения равны и соответственно. Важно отметить, что размер возвращаемого массива будет не меньше запрашиваемого размера, но он может быть больше: Как уже упоминалось, Кэш для каждого потока (per-thread cache). Общий кэш. Кэш для каждого потока реализован как приватное статическое поле При попытке получить массив из пула, алгоритм сначала пытается получить его из поля Create
можно настроить пул. В таком случае будет использована реализация ConfigurableArrayPool
.var pool = ArrayPool
using System.Buffers;
var (pow, cnt) = (4, 0);
while (pow <= 30)
{
var x = (1 << pow) - 1;
var arr = ArrayPool
Что под капотом
ArrayPool
имеет две реализации. Рассмотрим их отдельно.Класс SharedArrayPool
SharedArrayPool
имеет двухуровневый кэш: t_tlsBuckets
, которое по сути является массивом массивов. У каждого потока своя собственная копия t_tlsBuckets
благодаря Thread Local Storage (TLS). В C# для этого используется атрибут ThreadStaticAttribute. Использование TLS позволяет каждому потоку иметь свой небольшой кэш для различных размеров массивов, от до (всего 27 бакетов).t_tlsBuckets
. Если массив требуемого размера не найден в t_tlsBuckets
, проверяется общий кэш в поле _buckets
. Этот общий кэш представляет собой массив объектов Partitions
, по одному для каждого допустимого размера бакетов. Каждый объект Partitions
содержит массив объектов Partition
, где N
— это количество процессоров. Каждый объект Partition
работает как стек, который может содержать до 32 массивов. Да, это звучит мудрёно, поэтому смотрим диаграмму ниже.