Пулы объектов: ArrayPool, ObjectPool, RecyclableMemoryStream

↑ 2.2 .NET runtime: CLR, память, GC · ← Предыдущая

Обязательно~6 мин чтенияУровень: middle

Зачем это на собесе

Пулы — стандартный ответ на вопрос «как уменьшить аллокации и давление на GC». Ждут понимания, когда пул оправдан, как безопасно вернуть буфер, чем ArrayPool отличается от ObjectPool и зачем RecyclableMemoryStream вместо MemoryStream.

Объяснение

Зачем пулы

Частое создание больших или тяжёлых объектов нагружает GC (особенно LOH ≥ 85 000 байт). Пул переиспользует объекты: взял, использовал, вернул. Выигрыш — в меньшем числе аллокаций и сборок Gen 2.

ArrayPool<T>

Пул массивов (System.Buffers). Общий пул: ArrayPool<byte>.Shared.

var buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);       // может вернуть массив БОЛЬШЕ запрошенного, содержимое не обнулено
try
{
    int read;
    while ((read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct)) > 0)
        Process(buffer.AsSpan(0, read));                     // работаем только с реально прочитанной частью
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer, clearArray: true);    // вернуть; clearArray, если внутри секреты
}

Правила:

  • Rent(n) возвращает массив длиной не меньше n. Используйте размер, который вы сами знаете, а не buffer.Length.
  • Возвращайте буфер ровно один раз, в finally. Повторный Return или использование после Return — use-after-free на уровне логики, данные будут испорчены другим потребителем.
  • Не храните арендованный массив в долгоживущих объектах и не отдавайте наружу.
  • Содержимое при аренде не обнулено: могут быть чужие данные.
  • Для чувствительных данных — clearArray: true.

ObjectPool<T>

Microsoft.Extensions.ObjectPool — пул произвольных объектов (StringBuilder, тяжёлые парсеры, буферы).

services.AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>();
services.AddSingleton(sp => sp.GetRequiredService<ObjectPoolProvider>().CreateStringBuilderPool());
 
public sealed class Formatter(ObjectPool<StringBuilder> pool)
{
    public string Build(IEnumerable<string> parts)
    {
        var sb = pool.Get();
        try { foreach (var p in parts) sb.Append(p).Append(';'); return sb.ToString(); }
        finally { pool.Return(sb); }         // политика Return сбрасывает состояние (Clear)
    }
}

Для своего типа реализуют IPooledObjectPolicy<T> (создание и сброс состояния). Объект перед возвратом нужно сбросить.

RecyclableMemoryStream

Пакет Microsoft.IO.RecyclableMemoryStream — замена MemoryStream, использует пул блоков вместо одного большого массива.

private static readonly RecyclableMemoryStreamManager Manager = new();
 
await using var ms = Manager.GetStream();
await report.ExportAsync(ms, ct);
ms.Position = 0;
await ms.CopyToAsync(response.Body, ct);

Плюсы против MemoryStream: не создаёт больших массивов в LOH при росте (нет удвоения буфера), меньше фрагментации, блоки переиспользуются. Полезно при генерации PDF/Excel/архивов в памяти.

Другие приёмы уменьшения аллокаций

  • Span<T>, stackalloc для небольших буферов (см. тему про Span).
  • StringBuilder вместо конкатенаций, string.Create.
  • struct для маленьких неизменяемых значений, ValueTask вместо Task.
  • System.IO.Pipelines для сетевого ввода-вывода.
  • Кэширование неизменяемых объектов, static readonly для повторно используемых значений.

Нюансы и подводные камни

  • Пул оправдан только для крупных или очень частых аллокаций. Для маленьких коротких объектов GC быстрее: пул добавляет накладные расходы и риск ошибок.
  • Забыли вернуть объект — пул создаст новый, утечки нет (для ArrayPool.Shared), но выигрыш потерян.
  • Вернули дважды или использовали после возврата — повреждение данных, трудноуловимые баги.
  • Несброшенное состояние объекта из ObjectPool — утечка данных между запросами (безопасность!).
  • Пул без ограничений держит память постоянно; у ArrayPool.Shared ограничение на размер и число массивов по корзинам.
  • Проверяйте бенчмарком ([MemoryDiagnoser]): пул иногда не даёт выигрыша.

Практика

  • Переписать чтение файла с new byte[81920] на ArrayPool и сравнить Allocated в BenchmarkDotNet.
  • Заменить MemoryStream в генерации отчёта на RecyclableMemoryStream и посмотреть размер LOH под нагрузкой.
  • Добавить ObjectPool<StringBuilder> в сервис форматирования.

Вопросы с ответами

Связанные темы