Пул объектов (Object Pool) — это паттерн, который позволяет повторно использовать объекты вместо создания новых. Это может быть полезно в ситуациях, когда инициализация объектов затратна. Паттерн широко применяется в разработке игр и приложениях, где важно минимизировать использование памяти. В этой статье мы рассмотрим, как этот шаблон реализован в C#, и как он может улучшить производительность.

Содержание

Дисклеймер

Результаты бенчмарков в этой статье очень условны и верны только при определённых условиях. Допускаю, что бенчмарк может показать другие результаты на другом ПК, с другим ЦП, с другим компилятором или при другом сценарии использования рассматриваемого функционала языка. Всегда проверяйте ваш код на конкретно вашем железе и не полагайтесь лишь на статьи из интернета.

Исходный код бенчмарков и сырые данные результатов можно найти в этом репозитории.

Что такое пул объектов?

Пул объектов (Object Pool) — это паттерн, который позволяет повторно использовать объекты вместо создания новых. Это может быть полезно в ситуациях, когда инициализация объектов затратна. Использование пула состоит из следующих шагов:

  1. Получение объекта из пула.

  2. Использование объекта.

  3. Возврат объекта в пул.

  4. [Опционально] Пул объектов может сбрасывать состояние объекта при его возврате.

Псевдокод использования пула объектов выглядит следующим образом:

var obj = objectPool.Get();

try
{
    // выполняем какую-нибудь работу с obj
}
finally
{
    objectPool.Return(obj, reset: true);
}

Пулы объектов широко используется в разработке игр и приложениях, где важно минимизировать использование памяти.

Пример поиска Object Pool в GitHub
Пример поиска Object Pool в GitHub

В .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> под капотом
ObjectPool<T> под капотом

Получение объекта из пула работает следующим образом:

  1. Алгоритм проверяет, не равен ли _fastItem null и может ли текущий поток использовать его значение. Потокобезопасность этой операции обеспечивается при помощи Interlocked.CompareExchange.

  2. Если _fastItem равен null или уже используется другим потоком, алгоритм пытается извлечь объект из _items.

  3. Если получить значение и из _fastItem, и из очереди не получилось, создается новый объект с помощью фабричного метода.

Возврат объекта в пул происходит противоположным образом:

  1. Алгоритм проверяет, проходит ли объект валидацию с помощью _returnFunc. Если нет, это означает, что объект может быть проигнорирован. Это регулируется интерфесом IPooledObjectPolicy.

  2. Если _fastItem равен null, объект сохраняется там при помощи Interlocked.CompareExchange.

  3. Если _fastItem уже используется, объект добавляется в ConcurrentQueue, но только если размер очереди не превышает максимальное значение.

  4. Если пул переполнен, то объект никуда не сохраняется.

Производительность

Чтобы протестировать, как ObjectPool<T> влияет на производительность, созданы два бенчмарка:

  • без пула объектов (создаётся новый список для каждой операции);

  • с пулом объектов.

Каждый бенчмарк выполняет следующие шаги в цикле:

  1. Создаёт новый список или получает из пула.

  2. Добавляет значения в список.

  3. Возвращает список в пул (если используется пул).

Бенчмарки повторяют этот процесс 100 раз для каждого потока. Количество потоков варьируется от 1 до 32. Размер списка варьируется от 10 до 1 000 000 элементов.

Результаты показаны на диаграмме ниже. Шкала оси x логарифмическая. Ось y показывает процентное отклонение по сравнению с бенчмарком без пула объектов.

Результаты бенчмарка ObjectPool <T> в относительных значениях
Результаты бенчмарка ObjectPool <T> в относительных значениях

Из результатов видно, что использование ObjectPool в однопоточном сценарии быстрее на 10% – 50% по сравнению с созданием нового списка для каждой итерации. При многопоточном доступе к пулу и работе с относительно маленькими объектами, результаты ObjectPool хуже. Это, вероятно, связано с задержкой при синхронизации потоков во время доступа к _fastItem и ConcurrentQueue.

Результаты бенчмарка ObjectPool<T> в абсолютных значениях
Результаты бенчмарка 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
{
    // do some work with array
}
finally
{
    pool.Return(buffer, clear: true);
}

При помощи статического метода Create можно настроить пул. В таком случае будет использована реализация ConfigurableArrayPool.

var pool = ArrayPool<int>.Create(maxArrayLength: 1000, maxArraysPerBucket: 20);

Этот метод позволяет указать максимальную длину массива и максимальное количество массивов в каждом бакете (о бакетах мы поговорим позже). По умолчанию эти значения равны 2^{20} и 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 имеет двухуровневый кэш: 

  1. Кэш для каждого потока (per-thread cache).

  2. Общий кэш.

Кэш для каждого потока реализован как приватное статическое поле 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> под капотом 
SharedArrayPool<T> под капотом 

Когда массив возвращается в пул, алгоритм пытается сохранить его в кэше 1 уровня. Если t_tlsBuckets уже содержит массив того же размера, существующий массив из t_tlsBuckets помещается в общий кэш, а новый массив сохраняется в t_tlsBuckets для лучшей производительности (для лучшей локальности кэша процессора). Если стек в Partition текущего ядра переполнен, алгоритм ищет свободное место в стеках в Partition других ядер. Если все стеки переполнены, массив игнорируется.

Класс ConfigurableArrayPool

ConfigurableArrayPool устроен проще, чем SharedArrayPool. У него есть только одно приватное поле _buckets. Это поле является массивом объектов Bucket, где каждый Bucket представляет собой коллекцию массивов (смотрите диаграмму ниже). Поскольку поле _buckets используется всеми потоками, каждый Bucket использует SpinLock для обеспечения потокобезопасного доступа.

ConfigurableArrayPool<T> под капотом 
ConfigurableArrayPool<T> под капотом 

Производительность

Бенчмарки для ArrayPool<T> похожи на бенчмарки для ObjectPool<T>:

  • без использования пула (создаётся новый массив для каждой операции);

  • с общим пулом (SharedArrayPool);

  • с настраиваемым пулом (ConfigurableArrayPool).

Результаты бенчмарка ArrayPool<T> в относительных значениях
Результаты бенчмарка ArrayPool<T> в относительных значениях

Как видно из результатов, SharedArrayPool работает быстрее почти во всех случаях, особенно в сценариях с несколькими потоками. Единственное исключение — это когда размер массива равен 10.

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

Результаты бенчмарка ArrayPool<T> в абсолютных значениях
Результаты бенчмарка ArrayPool<T> в абсолютных значениях

Заключение

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

Комментарии (2)


  1. serchu
    09.12.2024 09:01

    В моём понимании, pool нужен, чтобы снизить memory traffic и GC-pressure в сценариях с частым выделением памяти под массивы. Время выполнения, конечно, зависит от состояния GC, но скрывает смысл этого способа оптимизации. Если у вас в коде нет проблем с memory traffic, то pool не даст особого прироста производительности


  1. mizvekov
    09.12.2024 09:01

    Есть еще хорошие стримы с пулингом - https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream.