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

от автора

Пул объектов (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 могут улучшить производительность если создание объектов затратно и их переиспользование возможно. Но нужно быть осторожным, т.к. механизмы синхронизации могут ухудшить производительность, особенно, если пулы используются для относительно небольших объектов.


ссылка на оригинал статьи https://habr.com/ru/articles/864902/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *