Опубликовано
- 6 мин чтения
Пулы объектов в C#: примеры, устройство и производительность

Пул объектов (Object Pool) — это паттерн, который позволяет повторно использовать объекты вместо создания новых. Это может быть полезно в ситуациях, когда инициализация объектов затратна. Паттерн широко применяется в разработке игр и приложениях, где важно минимизировать использование памяти. В этой статье мы рассмотрим, как этот шаблон реализован в C#, и как он может улучшить производительность.
Эта статья подготовлена в рамках C# Advent 2024.
Дисклеймер
Результаты бенчмарков в этой статье очень условны и верны только при определённых условиях. Допускаю, что бенчмарк может показать другие результаты на другом ПК, с другим ЦП, с другим компилятором или при другом сценарии использования рассматриваемого функционала языка. Всегда проверяйте ваш код на конкретно вашем железе и не полагайтесь лишь на статьи из интернета.
Исходный код бенчмарков и сырые данные результатов можно найти в этом репозитории.
Что такое пул объектов?
Пул объектов (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
.

Внутреннее устройство ObjectPool<T>
Получение объекта из пула работает следующим образом:
-
Алгоритм проверяет, не равен ли
_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<T>. Разница в процентах.
Из результатов видно, что использование ObjectPool
в однопоточном сценарии быстрее на 10% – 50% по сравнению с созданием нового списка для каждой итерации. При многопоточном доступе к пулу и работе с относительно маленькими объектами, результаты ObjectPool
хуже. Это, вероятно, связано с задержкой при синхронизации потоков во время доступа к _fastItem
и ConcurrentQueue
.

Результаты ObjectPool<T>. Абсолютные значения.
Класс ArrayPool
ArrayPool<T>
— это класс, доступный в любом C# приложении. Класс находится в пространстве имён System.Buffers
. Его исходный код можно найти здесь. ArrayPool
– это абстрактный класс с двумя реализациями: SharedArrayPool и ConfigurableArrayPool.
Использовать ArrayPool<T>
так же просто, как и ObjectPool
. Пример с SharedArrayPool
ниже:
var pool = ArrayPool<int>.Shared;
var buffer = pool.Rent(10);
try
{
// работа с массивом
}
finally
{
pool.Return(buffer, clear: true);
}
При помощи статического метода Create
можно настроить пул. В таком случае будет использована реализация ConfigurableArrayPool
.
var pool = ArrayPool<int>.Create(
maxArrayLength: 1000,
maxArraysPerBucket: 20);
Этот метод позволяет указать максимальную длину массива и максимальное количество массивов в каждом бакете (о бакетах мы поговорим позже). По умолчанию эти значения равны 2^20 и 2^50 соответственно.
Важно отметить, что размер возвращаемого массива будет не меньше запрашиваемого размера, но он может быть больше:
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 позволяет каждому потоку иметь свой небольшой кэш для различных размеров массивов, от 2^4 до 2^30 (всего 27 бакетов).
При попытке получить массив из пула, алгоритм сначала пытается получить его из поля t_tlsBuckets
. Если массив требуемого размера не найден в t_tlsBuckets
, проверяется общий кэш в поле _buckets
. Этот общий кэш представляет собой массив объектов Partitions
, по одному для каждого допустимого размера бакетов. Каждый объект Partitions
содержит массив объектов Partition
, где N
— это количество процессоров. Каждый объект Partition
работает как стек, который может содержать до 32 массивов. Да, это звучит мудрёно, поэтому смотрим диаграмму ниже.

Внутреннее устройство SharedArrayPool<T>
Когда массив возвращается в пул, алгоритм пытается сохранить его в кэше 1 уровня. Если t_tlsBuckets
уже содержит массив того же размера, существующий массив из t_tlsBuckets
помещается в общий кэш, а новый массив сохраняется в t_tlsBuckets
для лучшей производительности (для лучшей локальности кэша процессора). Если стек в Partition
текущего ядра переполнен, алгоритм ищет свободное место в стеках в Partition
других ядер. Если все стеки переполнены, массив игнорируется.
Класс ConfigurableArrayPool
ConfigurableArrayPool
устроен проще, чем SharedArrayPool
. У него есть только одно приватное поле _buckets
. Это поле является массивом объектов Bucket
, где каждый Bucket представляет собой коллекцию массивов (смотрите диаграмму ниже). Поскольку поле _buckets
используется всеми потоками, каждый Bucket
использует SpinLock для обеспечения потокобезопасного доступа.

Внутреннее устройство ConfigurableArrayPool<T>
Производительность
Бенчмарки для ArrayPool<T>
похожи на бенчмарки для ObjectPool<T>
:
- без использования пула (создаётся новый массив для каждой операции);
- с общим пулом (
SharedArrayPool
); - с настраиваемым пулом (
ConfigurableArrayPool
).

Результаты ArrayPool<T>. Разница в процентах.
Как видно из результатов, SharedArrayPool
работает быстрее почти во всех случаях, особенно в сценариях с несколькими потоками. Единственное исключение — это когда размер массива равен 10.
Противоположная ситуация наблюдается с ConfigurableArrayPool
. Производительность в многопоточном сценарии и при работе с относительно небольшими массивами хуже. Думаю, что причина та же, что и у ObjectPool<T>
: задержкой при синхронизации потоков во время доступа к массивам внутри Bucket
.

Результаты ArrayPool<T>. Абсолютные значения.
Выводы
ObjectPool
и ArrayPool
могут улучшить производительность если создание объектов затратно и их переиспользование возможно. Но нужно быть осторожным, т.к. механизмы синхронизации могут ухудшить производительность, особенно, если пулы используются для относительно небольших объектов.