Автоматическая сборка мусора упрощает разработку программ, избавляя от необходимости отслеживать жизненный цикл объектов и удалять их вручную. Однако, чтобы сборщик мусора был полезным инструментом, а не главным врагом на пути к высокой производительности — иногда имеет смысл помогать ему, оптимизируя частые аллокации и аллокации больших объектов. Для уменьшения аллокаций в современном .NET предусмотрены Span/Memory<T>, stackalloc с поддержкой Span, структуры и другие средства. Но если без объекта в куче не обойтись, например, если объект слишком большой для стека, или используется в асинхронном коде — этот объект можно переиспользовать. И для самых крупных объектов — массивов, в .NET встроены несколько реализаций ArrayPool<T>. В этой статье я расскажу о внутреннем устройстве реализаций ArrayPool<T> в .NET, о подводных камнях, которые могут сделать пулинг неэффективным, о concurrent-структурах данных, а также о пулинге объектов, отличных от массивов. Пул можно рассматривать как аллокатор объектов. У них одинаковый интерфейс с двумя методами, выполняющими функции new и delete. Хорошая реализация нативного аллокатора также переиспользует память: при delete участок памяти не сразу отдаётся операционной системе обратно, а переиспользуется для новых объектов внутри программы, т.е. работает как пул. Возникает вопрос, зачем делать пул managed-объектов, вместо перехода на нативный аллокатор? Отличие пула от аллокатора в том, что пулу можно не сохранять часть объектов, например, при превышении вместимости — тогда их соберёт сборщик мусора. Аллокатор же должен гарантировать отсутствие утечек памяти. В .NET встроены две разные реализации абстрактного класса ArrayPool<T>. Т.к. невозможно сохранить массивы для каждого из размера по-отдельности (их будет слишком много), при вызове .Rent(N) пул возвращает массив размера N или больше. Внутри пул хранит массивы с длинами, равными степеням двойки. Первая — для пулов, создающихся с помощью ArrayPool<T>.Create()/ArrayPool<T>.Create(maxArrayLength, maxArraysPerBucket). Вторая — статический ArrayPool<T>.Shared. Также можно сделать свою реализацию ArrayPool<T>. Разбор отличий пулов, добываемых через ArrayPool<T>.Shared и ArrayPool<T>.Create(...) начнём с бенчмарка. Кроме этих реализаций, протестируем также реализацию, которая ничего не переиспользует, а просто аллоцирует новые массивы и бросает их на совесть GC. Код, не связанный с работой с пулом занимает ~13 ms, что было замерено отдельно. Пул, созданный через ArrayPool<byte>.Create() оказался медленнее даже аллокации (оператора new), и гораздо медленнее ArrayPool<byte>.Shared (за вычетом оверхеда). Но не торопитесь делать выводы по одному бенчмарку, тестирующему лишь частный случай, и списывать эту реализацию пула — далее мы разберёмся, как эти пулы устроены внутри, и почему результат получился таким. Отмечу, что пул — лишь вспомогательный компонент, и бенчмаркать нужно алгоритм или сервис, в котором пул используется — сравнивать, улучшилась ли производительность от переиспользования объектов. Да и самая быстрая реализация пулинга — та, которая не содержит никакой синхронизации с другими потоками. В однопоточном коде проще сохранить массив в поле класса и всегда использовать его, без всяких пулов. А если нужно хранить несколько объектов в однопоточном алгоритме, то подойдут обычные Queue<T>/Stack<T>.  Методы .Create() и .Create(maxArrayLength, maxArraysPerBucket)создают ConfigurableArrayPool<T>. Здесь нужно быть осторожным — значение максимальной длины массива в этом пуле по умолчанию — всего лишь 1024 * 1024, при её превышении массивы будут аллоцироваться и не сохраняться в пуле. Поэтому, если ArrayPool создаётся для больших массивов — параметры придётся переопределить. Реализация ConfigurableArrayPool<T> очень проста: Как раз из-за блокировки эта реализация пула не масштабируется на множество потоков. В итоге основным её применением становятся большие массивы, в случае с которыми время работы с пулом пренебрежимо мало по сравнению с обработкой данных, которыми эти массивы заполняются.  Это статический пул, разделяемый всем кодом в программе. Реализация называется TlsOverPerCoreLockedStacksArrayPool<T> и на данный момент, никаких настроек не имеет. Максимальный размер массива в пуле — 2^30 элементов. Tls в названии значит ThreadLocalStorage, исходя из этого можно догадаться, за счёт чего этот пул работает быстро в многопоточной среде. В этом пуле реализовано двухуровневое хранение объектов. Первый уровень — локальный набор массивов для каждого потока. Хранится в  поле. Доступ к локальной части пула не требует синхронизации с другими потоками. Однако, локально хранится максимум по одному массиву каждого размера. Использование статического поля здесь возможно, т.к. пул глобальный, т.е. создаётся в единственном экземпляре. В нестатическом пуле для этой оптимизации придётся использовать ThreadLocal. Второй уровень — разделяемый между потоками. Но в отличие от ConfigurableArrayPool<T>, для каждого размера хранится не один список массивов, а несколько — по количеству логических ядер (max. 64), каждый из списков защищён отдельной блокировкой. Это снижает конкуренцию между потоками — теперь они идут под разные блокировки, а не под одну. Вместо SpinLock в реализации Shared пула используется обычный lock/Monitor. Оптимизация с thread local слотом имеет свою цену: в пуле может скопиться большое количество крупных массивов, раздувая память приложения. В примере ниже первый поток создаёт новый массив и возвращает его в пул. Этот массив попадает в Thread Local слот первого потока. В итоге, при попытке получить массив того же размера из другого потока — переиспользования не произойдёт и будет выделен новый массив. В итоге для больших массивов может быть выгоднее использовать реализацию пула с общим набором объектов для всех потоков. Отчасти для решения предыдущей проблемы, в ArrayPool<T> предусмотрена очистка памяти при сборке мусора. С помощью хака с финализатором пул узнаёт о срабатываниях сборщика мусора и периодически выбрасывает избыток массивов, помогая освободить память. Не всегда это поведение желательное. Преждевременное удаление больших массивов, находящихся в Large Object Heap, может привести к излишней фрагментации кучи. Это ещё один повод задуматься об использовании другого механизма для переиспользования больших массивов. Бенчмарк, с которого мы начали, в случае ArrayPool<T>.Shared "пробивает" только первый уровень пулинга — thread local. Возникает вопрос, насколько производителен второй уровень — per core locked stacks, особенно учитывая то, что в нём есть блокировка. Для замера, сделаем бенчмарк, использующий сразу два массива из пула. Нагрузка, не связанная с пулом, заняла 24 ms — второй уровень Shared пула уже не бесплатен, но пул по прежнему достаточно производителен — это достигается за счёт того, что потоки в бенчмарке берут массивы из разных списков и захватывают разные блокировки — contention не возникает. Каждый поток не всегда использует только один список. Если при .Rent(N) подходящий массив не был найден в "своём" списке, то по кругу обходятся все остальные, лишь после этого аллоцируется новый массив. И если не возвращать массивы в пул обратно, то каждый вызов .Rent(N) будет по очереди захватывать все блокировки, приводя к большому contention. От этой проблемы есть некоторая "защита". Второй уровень пула (PerCoreLockedStacks) инициализируется только при первом возврате массива в него. Если нигде в программе массивы не возвращаются в пул, то .Rent(N) будет аллоцировать новый массив без захвата блокировок. Также, вместимость Shared пула относительно небольшая — 8 массивов каждого размера в каждом PerCoreLockedStacks (т.е. 512 на размер максимум). И если требуется много массивов, каждый из которых будет использоваться долгосрочно — эффекта от пулинга не будет, т.к. неизбежно будут создаваться новые массивы, а потоки будут обходить блокировки в надежде найти хоть что-то в опустошенном пуле. Для мониторинга работы стандартных реализаций ArrayPool<T> предусмотрены события System.Buffers.ArrayPoolEventSource. Их можно получить через PerfView, dotnet trace, EventListener и другими способами. Основное событие, на которое имеет смысл смотреть — BufferAllocated — если аллоцируется много новых массивов, значит пулинг неэффективен. Проблему с lock contention в случае с Shared пулом можно вычислить по событиям Microsoft-Windows-DotNETRuntime/Contention/Start. Подробнее о диагностике .NET-приложений уже было описано в недавней статье. Пример стектрейса из PerfView: Увы, о неэффективном использовании SpinLock нет никаких событий. Но о неэффективном использовании ConfigurableArrayPool<T> можно узнать при профилировании или анализе дампа. Пример стектрейса из dotnet dump: Пока что речь шла лишь о реализациях ArrayPool<T> — пулах массивов. Иногда требуется переиспользовать не только массивы, но и другие объекты. И их пулинг также имеет свои тонкости. Второй уровень ArrayPool<T>.Shared для массивов одного размера — по сути уже потокобезопасный пул одинаковых объектов. Казалось бы, почему не взять его в качестве пула объектов? В некоторых сценариях такой подход будет удачным, но большинство объектов, не являющихся массивами — маленькие. С одной стороны, это увеличивает требования к производительности пула — overhead от пулинга уже не спрятать среди полезной нагрузки. С другой — позволяет не заморачиваться с многопоточностью и сделать [ThreadStatic]Stack/Queue<T> пул, локальный для каждого потока. Такой вариант не подходит для объектов, которые могут перемещаться из одного потока в другой, т.к. возникает риск опустошения пула в одном из потоков и переполнения в другом — разницы с аллокацией тогда не будет. Также часто пул объектов реализуют на основе ConcurrentQueue<T>. Например, так реализован DefaultObjectPool<T> из пакета Microsoft.Extensions.ObjectPool, с тем отличием, что в нём есть ещё и первый уровень — поле _fastItem под хранение одного объекта. Это поле класса, не статическое, и не [ThreadStatic]/ThreadLocal. Работа с ним ведётся через atomic инструкции (Interlocked). Вместо ConcurrentQueue<T> можно использовать другую структуру данных — ConcurrentStack<T> или ConcurrentBag<T>. Использовать стек не имеет смысла — он аллоцирует по ноде связного списка на каждый элемент, и хуже масштабируется — когда ConcurrentQueue<T> разрывается потоками с двух разных сторон, вся нагрузка на стек приходится только на один его край, а LIFO-порядок не даёт никаких преимуществ для пула. ConcurrentBag<T> работает быстрее в случае, когда добавления и удаления элементов происходят из одного потока за счёт ThreadLocal<> внутри, но не догоняет по производительности алгоритм из ArrayPool<T>.Shared. Для теста были сделаны несколько реализаций пулов — наивные поверх одной concurrent-коллекции, глобальной Stack<T> под локом (с _fastItem слотом и без), отдельным ThreadLocal Stack<T> для каждого потока, также взяты DefaultObjectPool<T> (и его модификация с ThreadLocal) и ArrayPool<T>.Shared для сравнения. В качестве объекта использовался массив размером 1 КБ для унификации кода с бенчмарком для ArrayPool<T>. Структуре данных недостаточно быть lock-free, чтобы быть быстрой — разница с обычной блокировкой оказалась всего ~2 раза. Атомарные инструкции — не магия, хоть они и работают быстрее блокировок, но atomic write масштабируется плохо. В итоге для большей производительности следует пользоваться теми же техниками, что в ArrayPool<T>.Shared — thread local слоты и шардирование разделяемого между потоками хранилища, чтобы минимизировать любую синхронизацию потоков. В критичных для производительности местах внутри dotnet/runtime реализованы свои пулы. Например, в PoolingAsyncValueTaskMethodBuilder, использующимся для снижения числа аллокаций в асинхронном коде, реализован двухуровневый пул —  слот + слоты по количеству ядер, работа с которыми ведётся через Interlocked. Может возникнуть желание сделать свой собственный пул. Например, есть подозрение, что мешают блокировки внутри стандартного Shared пула — внешне это может проявляться как высокая tail latency в метриках сервиса (какие-то два потока делят один LockedStack — разделение происходит по ProcessorId). Или нужен эффективный пул для объектов с общим хранилищем между потоками. Казалось, можно взять ArrayPool<T>.Shared и заменить LockedStacks на несколько ConcurrentQueue<T>. Но важной деталью реализаций ArrayPool<T>.Shared — поддержка ограничения числа элементов, хранящихся внутри пула. В пулах на основе ConcurrentQueue<T> это реализуется с помощью дополнительного счётчика элементов, изменяющегося через Interlocked. Это, во-первых, замедляет работу с очередью, добавляя ещё одну точку синхронизации. Во-вторых, это неэстетично — ConcurrentQueue<T> реализована поверх internal-класса ConcurrentQueue<T>.Segment, который представляет из себя очередь с ограниченной ёмкостью. В результате, при использовании счётчика bounded-очередь обёрнута в unbounded, а поверх неё реализована bounded-очередь. Возникает желание извлечь ConcurrentQueue<T>.Segment в отдельный класс и использовать его для реализации пула. Причём, не только у меня. Об этом есть очень старый proposal в dotnet/runtime, но, что называется, не договорились. Сам класс ConcurrentQueue<T>.Segment легко отделяется от ConcurrentQueue<T> и сразу готов к использованию. Протестируем такую реализацию. В случае, когда используется по одному LockedStack или очереди на каждое логическое ядро — разницы нет. Но если уменьшить шардирование в 4 раза, становится видна разница между разными вариантами. Это показывает, насколько важно предположение о том, что разные потоки будут пользоваться разными блокировками в структуре ArrayPool<T>.Shared, а также то, что казалось бы легковесный Interlocked счётчик вокруг очереди всё же добавляет накладные расходы. Если же вас устраивает пул с неограниченной вместимостью, то ConcurrentQueue<T> — хорошая структура для этой задачи. Пулинг объектов помогает снизить аллокации и нагрузку на сборщик мусора, но сами пулы — сложные структуры данных, и неудачная или неподходящая для конкретного профиля нагрузки реализация пула может испортить производительность. Так, даже оставаясь в рамках стандартных реализаций пулов, для небольших массивов, нужных на короткое время предпочтительно использовать масштабирующийся ArrayPool<T>.Shared, а для больших массивов — пул, созданный через ArrayPool<T>.Create(..., ...) как более вместительный и экономный в плане отсутствия разделения по потокам. Также, если вам захотелось запулить все объекты в программе — подумайте дважды. Избыточный пулинг значительно усложняет код, особенно когда появляется ручной подсчёт ссылок и другие нетривиальные способы трекинга жизненного цикла объектов. Всё это не избавляет от риска использовать уже возвращённый в пул объект и не заметить этого. В некоторых случаях, проще положиться на сборщик мусора, или снизить число аллокаций другими инструментами, будто структуры, stackalloc, Span/Memory<T>, или scatter-gather IO. Кроме пулинга массивов и объектов есть и более сложные случаи, например динамические структуры данных — листы, хэш-таблицы; переиспользование объектов внутри concurrent структур данных. Но это уже совсем другая история.