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

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

411723a6ee5c51d3d53b849bde0b279f.jpg

Дисклеймер

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

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

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

Пул объектов (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 из статического класса 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.

ObjectPool<T> под капотом» /></p>

<p>ObjectPool<T> под капотом</p>

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

<ol><li><p>Алгоритм проверяет, не равен ли <code>_fastItem</code> <code>null</code> и может ли текущий поток использовать его значение. Потокобезопасность этой операции обеспечивается при помощи <code>Interlocked.CompareExchange</code>.</p></li><li><p>Если <code>_fastItem</code> равен <code>null</code> или уже используется другим потоком, алгоритм пытается извлечь объект из <code>_items</code>.</p></li><li><p>Если получить значение и из <code>_fastItem</code>, и из очереди не получилось, создается новый объект с помощью фабричного метода.</p></li></ol>

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

<ol><li><p>Алгоритм проверяет, проходит ли объект валидацию с помощью <code>_returnFunc</code>. Если нет, это означает, что объект может быть проигнорирован. Это регулируется интерфесом IPooledObjectPolicy.</p></li><li><p>Если <code>_fastItem</code> равен <code>null</code>, объект сохраняется там при помощи <code>Interlocked.CompareExchange</code>.</p></li><li><p>Если <code>_fastItem</code> уже используется, объект добавляется в <code>ConcurrentQueue</code>, но только если размер очереди не превышает максимальное значение.</p></li><li><p>Если пул переполнен, то объект никуда не сохраняется.</p></li></ol>

<h3>Производительность</h3>

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

<ul><li><p>без пула объектов (создаётся новый список для каждой операции); </p></li><li><p>с пулом объектов.</p></li></ul>

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

<ol><li><p>Создаёт новый список или получает из пула.</p></li><li><p>Добавляет значения в список.</p></li><li><p>Возвращает список в пул (если используется пул).</p></li></ol>

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

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

<p><img src=var pool = ArrayPool.Shared; var buffer = pool.Rent(10); try { // do some work with array } finally { pool.Return(buffer, clear: true); }

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

var pool = ArrayPool.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.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 имеет две реализации. Рассмотрим их отдельно.

Класс 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> под капотом » /></p>

<p>SharedArrayPool<T> под капотом </p>

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

<h4>Класс ConfigurableArrayPool</h4>

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

<p><img src=© Habrahabr.ru