Уверен, многие замечали: стоит программе активно выделять объекты в куче, как сборщик мусора тут как тут. Пара лишних мегабайт и ваше приложение уже тратит время на паузы GC, вместо того чтобы радовать пользователей скоростью. Сегодня речь пойдёт про пул объектов, шаблон проектирования, позволяющий переиспользовать уже созданные экземпляры, вместо того чтобы порождать их заново. Меньше новых объектов и меньше работы сборщику мусора.

Зачем нужен пул объектов и как он спасает память

Начну с краткого экскурса. Когда .NET-приложение выделяет объект, память под него берётся из управляемой кучи. CLR использует сборку мусора по поколениям: мелкие объекты сначала попадают в Gen 0, и обычно сборщик быстро их там убирает. Но если объект живёт достаточно долго, его могут продвинуть в Gen 1 и Gen 2. А большие объекты (размером более ~85 000 байт) сразу размещаются в специальной области памяти Large Object Heap (LOH). Проблема в том, что сборка мусора в Gen 2 и на LOH, дело довольно тяжелое. Такие полноцикличные сборки очищают сразу всю кучу (Gen 0, Gen 1, Gen 2) и заметно тормозят приложение. Поэтому частое создание крупных объектов (например, больших массивов) чревато просадками производительности. Да и множество мелких объектов, если они плодятся без меры, могут напрячь GC не на шутку.

А что, если объекты вообще не создавать лишний раз? Вот тут поможет object pool (он же пул объектов).

Идея проста, мы заранее резервируем некоторое количество объектов и храним их в специальной структуре, «пуле». Когда нужна новая сущность, мы не делаем new, а берем готовый объект из пула (это обычно называют «арендовать» объект). Отработали, возвращаем его обратно в пул, вместо того чтобы позволить сборщику мусора его уничтожить. Таким образом, часто используемые ресурсоёмкие объекты не создаются и не удаляются постоянно, а переиспользуются многократно. Выгода очевидна, снижаем количество аллокаций, а значит и нагрузку на GC.

Кроме того, пул шикарно сокращает фрагментацию памяти. Если вы каждый раз выделяете 100 KB под буфер, эти куски памяти могут разбрестись по куче и со временем привести к фрагментации LOH. При использовании пула же у вас есть ограниченный набор буферов фиксированных размеров, которые вы гоняете по кругу, память более предсказуемо переиспользуется. В итоге реже приходится дергать выделение новых больших сегментов под LOH.

Конечно, пул объектов не таблеточка от всего. CLR сама оптимизирует работу с памятью под сценарий «много мелких краткоживущих объектов» (именно так устроен поколенческий GC). Если объект маленький и умирает сразу после использования, сборщик мусора уберет его из Gen 0 практически бесплатно. В таком случае городить пул смысла нет. Но когда у нас объекты тяжелые, создаются многократно или живут достатоно долго, ручное управление их жизненным циклом через пул дает ощутимый выигрыш.

Итак, с мотивацией разобрались: уменьшить нагрузку на память, избегая лишних выделений и освобождений. Теперь посмотрим на конкретные инструменты в .NET, которые позволяют это сделать.

ArrayPool в .NET: переиспользуем массивы

Самый распространённый пример пула в экосистеме .NET, это, конечно, ArrayPool<T> из пространства имен System.Buffers. По сути, это высокопроизводительный пул для массива типа T.

Класс ArrayPool<T> появился в .NET относительно давно (для старых версий был доступен через NuGet-пакет System.Buffers, а ныне включен напрямую). Он дает готовую реализацию пула: внутренне там поддерживается набор buckets, где хранятся массивы разного размера. ArrayPool умеет сам подбирать массив подходящего размера при аренде и эффективно управлять памятью. При этом он потокобезопасен, так что им смело можно пользоваться из разных потоков одновременно.

Чтобы начать работать с пулам массивов, сначала надо получить экземпляр ArrayPool<T>. Здесь есть несколько вариантов на выборa:

  • Встроенный Singleton-пул: использовать свойство ArrayPool<T>.Shared. Это рекомендованный способ для большинства случаев. Получаем общую для всего приложения экземпляр пула, не паримся о его жизни, он управляется рантаймом. У этого пула есть дефолту: например, максимальный размер арендуемого массива по умолчанию ~1 048 576 элементов (около 1 МБ для типа byte). Также там задано ограничение на количество массивов в каждом бакете.

  • Создать свой пул: если дефолтный Shared не подходит (нужны массивы больше, чем 1 МБ, или хотите свои ограничения), можно вызвать ArrayPool<T>.Create(maxLength, maxArraysPerBucket). Метод вернёт новый экземпляр пула с вашими настройками. Обычно это тоже потокобезопасный пул, просто с другими лимитами. Однако обычно не рекомендует делать свой пул без необходимости, стандартного хватает, а дополнительные пулы расходуют память под свои буферы. К тому же следить за жизнью кастомного пула придётся вам самим.

  • Реализовать свой класс-наследник от ArrayPool<T>. Теоретически, можно унаследоваться и сделать свою реализацию пула с особым поведением.

Проще говоря, почти всегда мы делаем так: берем готовый ArrayPool<T>.Shared и работаем с ним.

Аренда и возврат массива через ArrayPool

Допустим, есть задача обработать входящие данные фиксированного максимального размера, скажем, до 100 KB. Можно было бы каждый раз делать new byte[100_000] под эти данные, а потом, обработав, позволять GC их собирать. Вместо этого попробуем использовать ArrayPool<byte> и арендовать буфер нужного размера. Ниже небольшой пример консольного кода:

using System;
using System.Buffers;

class Program
{
    static void Main()
    {
        var pool = ArrayPool<byte>.Shared;
        int size = 100_000;
        byte[] buffer = pool.Rent(size);           // арендуем массив как минимум на 100 000 элементов
        try
        {
            Console.WriteLine($"Получили массив длиной {buffer.Length}");
            // Здесь можно заполнить buffer данными и обработать их
            // ...
        }
        finally
        {
            pool.Return(buffer);                  // возвращаем массив обратно в пул
        }
    }
}

Запрашиваем буфер длиной не менее size. Важно еще понять, что пул может дать массив больше запрошенного размера. Он вернет первый подходящий по размеру кусок из своих резервов. Например, если попросить 100 000 байт, а в пуле ведра размечены по степеням двойки, мы, возможно, получим массив на 131072 байта (2^17) – больше, чем просили. Это нормально. Алгоритм Rent старается не выдавать массивы «впритык», а берёт из ближайшего большего подходящего сегмента. Поэтому будьте осторожны: длина возвращенного массива (buffer.Length) может превышать запрошенное значение. Если вы копируете данные в буфер, ориентируйтесь на изначальный необходимый размер данных, а не на buffer.Length, лишнее просто останется неиспользованным.

Далее обязательно возвращаем массив в пул, вызвав pool.Return(buffer). Лучше делать это в блоке finally, чтобы гарантировать возврат даже если в процессе обработки вылетит исключение. При возврате массив помещается обратно в соответствующую корзину пула и становится доступен для повторного использования в будущем.

Как только вы вызвали Return, не вздумайте продолжать использовать старую ссылку на массив!

Формально вам ничто не мешает обращаться к переменной buffer и ее элементам, ведь это же тот же объект типа byte[] в памяти. Но логически объект вам уже не принадлежит, вы вернули его пулу. Если в вашем коде после Return случайно окажется попытка читать/писать в этот массив, вы рискуете наткнуться на трудноуловимые баги. Пул может отдать этот же массив другому месту программы, и там данные перезапишутся или испортятся.

Метод Return имеет перегрузку: Return(array, bool clearArray). По дефолту второй параметр clearArray равен false, то есть при возврате буфер не очищается. Это сделано для производительности, зануление 100 KB может быть заметной затратой, а зачастую нет смысла тратить время, ведь следующий потребитель всё равно перезапишет весь массив новыми данными. Однако, в некоторых случаях имеет смысл очищать возвращаемый массив (передав clearArray: true). Например, если буфер содержал чувствительную информацию (пароли, личные данные) и вы не хотите, чтобы она утекла куда-то ещё. Либо если новый потребитель внезапно решит читать больше, чем записал предыдущий (хотя это уже логическая ошибка использования). В общем, по ситуации знайте, что такая опция есть.

Работа с большими массивами

Мы упомянули, что ArrayPool.Shared по дефолту рассчитан на максимальный размер ~1 МБ (на самом деле, 1 048 576 элементов). Но что будет, если запросить буфер больше этого размера? Тут поведение такое: пул вернёт вам новый массив нужной длины, но он не будет помещён в пул. Иными словами, запрос превышает ёмкость пула, придётся сделать отдельный new. При возврате такого громадного массива вызов Return его просто проигнорирует(внутри ArrayPool решение простое: не пытаться запихнуть гиганта обратно, чтобы не раздувать свои структуры). Поэтому если вашему приложению реально требуются очень большие куски памяти на регулярной основе, есть смысл настроить свой пул через ArrayPool.Create(...) с бóльшим maxLength. Но помните, что большой пул будет держать крупные массивы зарезервированными, что равносильно тому, что вы сразу отъедаете приличный кусок памяти под них. В ряде случаев эффективнее пересмотреть алгоритм, разбив обработку на несколько более мелких буферов, чтобы уложиться в стандартные размеры, так вы избежите лишних аллокаций вообще.

В обычных сценариях лимита в миллион элементов чаще всего хватает с запасом. Например, буфер на 1 MB это уже довольно большой JSON или изображение. Так что ArrayPool.Shared покрывает нужды большинства приложений. А если нет, вы знаете, что делать: создайте свой пул с подходящими параметрами.

Свой пул для своих объектов

До сих пор мы говорили о пулах применительно к массивам, потому что для них есть готовая реализация в .NET. Но что если у нас не массив, а какой-то другой дорогостоящий в создании объект? Например, объект, внутри которого открыто сетевое соединение, или объект с кучей сложной инициализации. Выкидывать такие объекты жаль, но .NET не предоставляет прям из коробки пул под каждый тип. К счастью, ничто не мешает реализовать паттерн pooling самостоятельно.

Самый простой способ воспользоваться потокобезопасной коллекцией, например, ConcurrentBag<T>, для хранения доступных объектов. Когда нужен объект, вытаскиваем (TryTake), когда освобождаем, складываем обратно (Add). На основе этого принципа можно написать универсальный класс-пул.

using System;
using System.Collections.Concurrent;

public class ObjectPool<T> where T : class
{
    private readonly ConcurrentBag<T> _objects = new ConcurrentBag<T>();
    private readonly Func<T> _objectGenerator;

    public ObjectPool(Func<T> objectGenerator)
    {
        _objectGenerator = objectGenerator ?? throw new ArgumentNullException(nameof(objectGenerator));
    }

    public T Get()
    {
        if (_objects.TryTake(out T item))
            return item;
        else
            return _objectGenerator();
    }

    public void Return(T item)
    {
        _objects.Add(item);
    }
}

ObjectPool<T> хранит внутри ConcurrentBag<T> для выдачи/приёма экземпляров и функцию objectGenerator, через которую знает, как создать новый объект, если в пуле ничего нет. Метод Get() либо берёт готовый экземпляр из objects, либо, если пул пуст, создает новый через _objectGenerator. Метод Return() возвращает объект обратно, кладя его в мешок. В использовании всё понятно:

// Создаём пул для типа MyHeavyObject, используя лямбда-фабрику:
var pool = new ObjectPool<MyHeavyObject>(() => new MyHeavyObject());

MyHeavyObject obj = pool.Get();
try 
{
    obj.DoWork();
}
finally 
{
    pool.Return(obj);
}

Конечно, в реальности могут понадобиться доработки: например, сбрасывать состояние объектов при возврате, чтобы следующий потребитель не унаследовал грязные данные. В случае массивов, кстати, похожую задачу решает как раз флаг clearArray.

Стоит также упомянуть, что в .NET уже есть готовые классы для пула объектов общего назначения. В пространстве имен Microsoft.Extensions.ObjectPool присутствует абстракция ObjectPool<T> и несколько реализаций, например DefaultObjectPool<T> и ConcurrentObjectPool<T>. Они используются внутри фреймворков типа ASP.NET Core для повторного использования тяжелых объектов (скажем, там переиспользуются StringBuilder, Parsers и прочие штуки). В своих приложениях вы тоже можете взять эту библиотеку на вооружение, чтобы не писать велосипеды. Лучше использовать готовый Microsoft.Extensions.ObjectPool<T>, прежде чем реализовывать свой. Пример использования:

using Microsoft.Extensions.ObjectPool;
// ...
var policy = new DefaultPooledObjectPolicy<MyHeavyObject>();
var pool = new DefaultObjectPool<MyHeavyObject>(policy, maximumRetained: 20);

MyHeavyObject obj = pool.Get();
// ... работаем ...
pool.Return(obj);

DefaultPooledObjectPolicy знает, как создавать новый MyHeavyObject (вызывает конструктор по умолчанию) и при необходимости может проверить его пригодность при возврате. В общем, на всякий случай знайте про эти классы, возможно, они уже решают вашу задачу.


Однако, не стоит пихать в пул всё подряд. Как я говорил, малые объекты сборщик сам приберёт без труда. Пул же усложняет код и сохраняет объекты дольше, чем нужно, что при неграмотном использовании может даже ухудшить дело. Всегда профилируйте. Если видите в профайлере сотни мегабайт аллокаций под схожие объекты или массивы, вот тогда и доставайте из инструментов пул. В противном случае можно довериться автоматике .NET.

Пулы объектов — лишь частный случай того, как внутренняя кухня CLR задаёт пределы производительности вашего кода. На курсе «C# Developer. Advanced» такие вещи разбирают системно: от модели памяти и работы GC до проектирования библиотек и инструментов, которые выдерживают нагрузку в проде. Курс рассчитан на разработчиков уровня middle+ и помогает перейти от «как-то работает» к предсказуемому, управляемому по эффективности коду.

Если хотите понять формат обучения — записывайтесь на бесплатные демо-уроки от преподавателей курса:

  • 26 ноября в 20:00. «Реактивное Программирование в C# Advanced: Сложные Операторы, Обработка Ошибок и Холодные/Горячие Observable». Записаться

  • 4 декабря в 20:00. «Коллекции dotnet: взгляд изнутри». Записаться

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