ArrayPool используют ради экономии на аллокациях. Но есть размеры, где он делает обратное: массив уходит в LOH, а через new остаётся в нулевом поколении.Один такой размер зашит в .NET по умолчанию — им копируются потоки. Проверил на четырёх машинах и трёх рантаймах. Читать далее
Уважаемые читатели, в этой статье я хочу разобраться, что происходит с буфером при аренде из ArrayPool, и представить свои выводы.
Пул используют, чтобы переиспользовать массивы вместо создания новых. Особенно крупные: массив от 85 000 байт попадает в кучу больших объектов, а она собирается только вместе со вторым поколением. Но есть размеры, где получается наоборот. Пул отправляет массив в эту кучу, а new оставляет его в нулевом поколении. Копирование файлов идёт через буфер из этих размеров.
Сравниваются три способа получить массив на n байт:
new byte[n]— обычное выделение, память обнуляется;
GC.AllocateUninitializedArray<byte>(n) — выделение без обнуления;
ArrayPool<byte>.Shared.Rent(n) с последующим Return — аренда из общего пула.
Будет 5 историй:
почему буфер на 81 920 байт превращается в 131 072 и попадает в LOH;
где Stream.CopyTo попадает в LOH, а где нет;
что происходит с данными при возврате массива в пул;
сколько занимает выдача массива;
сколько пул удерживает буферов и от чего зависит это число.
Исходники обоих пулов уже разобраны в статье epeshk «ArrayPool<T>: подводные камни». Здесь другое: те же механизмы, но со стороны замеров, на четырёх машинах и трёх рантаймах.
Код — в репозитории PoolProof. Четыре машины:
№ | CPU | Ядра/потоки | Частота | ОС | Процессоров | .NET 10 |
№1 | AMD Ryzen 9 5950X | 16 / 32 | 3,4 ГГц, до 4,9 в турбо | Windows 10 1809 | 32 | 10.0.5 |
№2 | Intel Core i9-10900KF | 10 / 20 | 3,7 ГГц, до 5,3 в турбо | Windows 10 22H2 | 20 | 10.0.10 |
№3 | 2 × Intel Xeon Silver 4314 | 2×16 / 64 | 2,4 ГГц, до 3,4 в турбо | Windows Server 2022 | 64 | 10.0.1 |
№4 | Intel Xeon W-2255 | 10 / 20 | 3,7 ГГц, до 4,5 в турбо | Windows Server 2022 | 20 | 10.0.1 |
BenchmarkDotNet 0.15.8, Release, .NET 8, 9 и 10. Отчёты сняты на обычном сборщике и на серверном, числа совпали, поэтому дальше даны без разделения. Патч-версии рантайма — в последней колонке.
История 1. Откуда берётся 131 072Пул хранит массивы степенями двойки. Номер бакета считается одной строкой — Utilities.cs:
// dotnet/runtime, System/Buffers/Utilities.cs
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static int SelectBucketIndex(int bufferSize)
{
// Buffers are bucketed so that a request between 2^(n-1) + 1 and 2^n is given a buffer of 2^n
// Bucket index is log2(bufferSize - 1) with the exception that buffers between 1 and 16 bytes
// are combined, and the index is slid down by 3 to compensate.
// Zero is a valid bufferSize, and it is assigned the highest bucket index so that zero-length
// buffers are not retained by the pool. The pool will return the Array.Empty singleton for these.
return BitOperations.Log2((uint)bufferSize - 1 | 15) - 3;
}Для 81 920 это бакет 13, а длина массива в нём — 16 << 13, то есть 131 072 байта. Пул хранит массивы степенями двойки, начиная с шестнадцати, и номер бакета задаёт степень.
// PoolProof, вывод dotnet run -c Release -f net10.0 -- extra
=== Где проходит порог кучи больших объектов ===
порог из документации: 85 000
первая длина byte[], попадающая во второе поколение: 84 976
размер бакета для неё: 131 072
наибольшая длина, остающаяся в нулевом поколении: 84 97584 976 + 24 = 85 000. Двадцать четыре байта — заголовок массива на x64: синхроблок, указатель типа, длина. К этому числу вернусь во второй истории: если сдвинуть порог, те же 24 байта отнимутся от нового значения.
Для расчёта хватает двух чисел. На каких размерах пул отправляет массив в LOH, хотя без пула он остался бы в обычной куче:
Запрос, байт | Бакет | Длина массива, байт | В LOH | А без пула |
65 536 | 12 | 65 536 | нет | нет |
65 537 | 13 | 131 072 | да | нет |
81 920 | 13 | 131 072 | да | нет |
84 975 | 13 | 131 072 | да | нет |
84 976 | 13 | 131 072 | да | да |
Бакет посчитан по SelectBucketIndex, порог для byte[] взят из отчёта extra
Пул отправляет массив в кучу больших объектов на размерах от 65 537 до 84 975 байт. new на этих же размерах оставляет массив в нулевом поколении. Ниже 65 537 округление даёт 65 536 и в LOH не попадает. С 84 976 байт массив идёт в LOH и без пула. Буфер CopyTo попадает в этот диапазон.
Замер такой: 16, 32 и 64 буфера по 81 920 байт удерживаются одновременно.

Прирост кучи больших объектов, буферы удерживаются одновременно. Одинаково на четырёх машинах, трёх рантаймах и обоих сборщиках
Из графика можно сделать выводы:
16 буферов из пула дают 2 048 КБ, ровно шестнадцать массивов по 131 072 байта;
32 буфера дают 4 097 КБ при ожидаемых 4 096;
64 буфера дают 8 195 КБ при ожидаемых 8 192;
new во всех трёх случаях даёт ноль: 81 920 байт остаются в нулевом поколении.
Каждый замер идёт отдельным процессом. Иначе 32 буфера возьмутся из тех, что вернул в пул предыдущий замер, и прирост получится вдвое меньше настоящего.
Худший случай выглядит так. Результат меняется, если буфер возвращать сразу:
Запросов подряд | Прирост, КБ | Удержанием было бы, КБ | Выделено, Б |
64 | 128 | 8 192 | 139 296 |
Те же 64 запроса, каждый буфер возвращается сразу. Одинаково на четырёх машинах
Пулу хватает одного массива на все 64 запроса — 139 296 выделенных байт. Массив остаётся в куче больших объектов до конца работы процесса, но он там один. Отсюда и разница: 128 КБ против 8 192 КБ.
История 2. Stream.CopyTo81 920 — это размер буфера по умолчанию у Stream.CopyTo. Константу выбирали так, чтобы буфер не попал в кучу больших объектов.
Комментарий из Stream.cs:
// dotnet/runtime, System/IO/Stream.cs
private int GetCopyBufferSize()
{
// This value was originally picked to be the largest multiple of 4096 that is still smaller than the large object heap threshold (85K).
// The CopyTo{Async} buffer is short-lived and is likely to be collected at Gen0, and it offers a significant improvement in Copy
// performance. Since then, the base implementations of CopyTo{Async} have been updated to use ArrayPool, which will end up rounding
// this size up to the next power of two (131,072), which will by default be on the large object heap. However, most of the time
// the buffer should be pooled, the LOH threshold is now configurable and thus may be different than 85K, and there are measurable
// benefits to using the larger buffer size. So, for now, this value remains.
const int DefaultCopyBufferSize = 81920;В том же файле, внутри CopyToAsync:
// dotnet/runtime, System/IO/Stream.cs
byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);Команда рантайма про это знает. В комментарии перечислены причины оставить константу: буфер живёт недолго, порог настраивается, а на большем буфере копирование быстрее.
Обращение dotnet/runtime issue #43179 закрыто 25 апреля 2025 года. Константа осталась прежней, аренда из общего пула тоже — обе строки выше сняты с текущего Stream.cs.
Дальше копирование. В таблице по две колонки на машину: прирост кучи больших объектов и выделено байт. Первая показывает, сколько памяти осталось после сборки, вторая — сколько её запросили.
Что копируем | №1 | №2 | №3 | №4 | Выделено, Б |
MemoryStream точного типа | 8 200 | ||||
наследник MemoryStream | 128 | 128 | 139 296 | ||
он же второй раз в том же процессе | 2 944 на №2 и №3, 138 768 на №1 и №4 | ||||
наследник, CopyTo(dest, 65 536) | 74 688 | ||||
наследник, CopyToAsync(dest) | 128 | 128 | 139 296 | ||
FileStream, CopyTo(dest) | 128 | 128 | 139 296 |
Прирост кучи больших объектов в КБ по машинам и выделено байт. Каждая строка снята своим процессом. Счётчик выделенных байт округляет до 8 КБ, поэтому в базовой строке 8 200, а не 300
Из таблицы можно сделать выводы:
MemoryStream буфер не арендует: 8 200 выделенных байт, это погрешность счётчика;
наследник MemoryStream арендует, выделенные байты показывают 139 296 на четырёх машинах, трёх рантаймах и двух сборщиках;
размер 65 536 задан явно, выделено 74 688, арендован меньший массив;
FileStream и асинхронный вариант дают те же 139 296;
прирост кучи виден только на двух машинах из четырёх.
Разница между первой и второй строкой — в MemoryStream.cs:
// dotnet/runtime, System/IO/MemoryStream.cs
public override void CopyTo(Stream destination, int bufferSize)
{
// If we have been inherited into a subclass, the following implementation could be incorrect
// since it does not call through to Read() which a subclass might have overridden.
// To be safe we will only use this implementation in cases where we know it is safe to do so,
// and delegate to our base class (which will call into Read) when we are not sure.
if (GetType() != typeof(MemoryStream))
{
base.CopyTo(destination, bufferSize);
return;
}
...
}MemoryStream отдаёт свой массив напрямую, когда GetType() возвращает именно MemoryStream. У наследника GetType() возвращает наследника, условие даёт false, и работа уходит в базовую реализацию, а она арендует буфер. FileStream свой CopyTo не переопределяет — отсюда 131 072 байта при копировании файла.
Ответ даёт третья строка таблицы. На машинах №2 и №3 второе копирование выделяет 2 944 байта: буфер лежит в пуле и переиспользуется. На №1 и №4 выделяется 138 768 байт — буфер создаётся заново. В том месте, где прирост кучи нулевой, массива в пуле уже нет.
Пул выбрасывает массив после сборки второго поколения. О каждой такой сборке он узнаёт через Gen2GcCallback и освобождает лишние массивы — механизм разобран у epeshk. Замер прироста делает полную сборку до копирования и после него. На части машин пул успевает освободить буфер между двумя чтениями.
Это видно в отдельном отчёте того же прогона:
Что проверяем | №1 | №2 | №3 | №4 |
массивов вернулось из 32 без сборки | 32 | 32 | 32 | 32 |
массивов вернулось из 32 после полной сборки | 31 | 32 | 32 | 31 |
Отчёт reports. Единица теряется ровно на тех двух машинах, где прирост кучи в таблице выше нулевой
Два независимых числа сходятся на одних и тех же машинах. В более раннем прогоне прирост был 128 КБ, то есть дело не в машине, а в том, дошла ли до пула сборка. Аренда 131 072 байт держится во всех прогонах и не зависит ни от машины, ни от времени запуска.
Что даёт настройка порогаВторое утверждение из комментария — порог настраивается. Те же отчёты с DOTNET_GCLOHThreshold=0x30000, порог 196 608:
// PoolProof, вывод при DOTNET_GCLOHThreshold=0x30000
=== Где проходит порог кучи больших объектов ===
порог из документации: 85 000
первая длина byte[], попадающая во второе поколение: 196 584
размер бакета для неё: 262 144
наибольшая длина, остающаяся в нулевом поколении: 196 583196 584 + 24 = 196 608. Заголовок в 24 байта подтвердился на втором пороге. FileStream.CopyTo даёт нулевой прирост на всех четырёх машинах, 64 запроса с возвратом тоже дают ноль вместо 128 КБ. Буфер на 131 072 в эту кучу больше не попадает.
У Return второй параметр по умолчанию выключен, а в комментарии к методу записано, что один и тот же массив возвращают ровно один раз:
// dotnet/runtime, System/Buffers/ArrayPool.cs
/// Once a buffer has been returned to the pool, the caller gives up all ownership of the buffer
/// and must not use it. The reference returned from a given call to <see cref="Rent"/> must only be
/// returned via <see cref="Return"/> once. The default <see cref="ArrayPool{T}"/>
/// may hold onto the returned buffer in order to rent it again, or it may release the returned buffer
/// if it's determined that the pool already has enough buffers stored.
public abstract void Return(T[] array, bool clearArray = false);Что проверяем | Результат |
байт от прошлого арендатора из 16 после возврата без очистки | 16 |
живых объектов из 64 в массиве ссылок после возврата без очистки | 64 |
живых объектов из 64 после возврата с очисткой | |
две аренды после двойного возврата вернули один и тот же массив | да |
возврат массива из другого пула | ArgumentException |
Отчёты reports и extra, четыре машины, три рантайма, оба сборщика
Из таблицы можно сделать выводы:
массив байт отдаёт следующему арендатору все 16 байт от предыдущего;
массив ссылок держит все 64 объекта: сборщик их не заберёт, пока кто-нибудь не перезапишет ячейки;
возврат с очисткой обнуляет ячейки, и все 64 объекта уходят в сборку;
пул принимает двойной возврат без ошибки, и две следующие аренды получают один массив.
Последняя строка расходится с комментарием выше: контракт требует возвращать массив один раз, а проверки нет. Два независимых участка кода получают один буфер и не знают об этом. Документация на Return относит двойной возврат к уязвимостям высокой степени опасности и ссылается на CWE-415 и CWE-416 — двойное освобождение памяти и обращение к ней после освобождения. Массив из другого пула при этом отклоняется исключением: проверка в пуле есть, но не на этот случай.
Очистка занимает время, и оно зависит от длины массива:

Во сколько раз возврат с очисткой медленнее возврата без неё, .NET 10, колонка Ratio из отчёта PoolClearBench. Шкала логарифмическая
Из графика можно сделать выводы:
на 1 024 байтах очистка добавляет чуть больше половины: 1,56–1,65;
на 16 384 байтах отношение доходит до 9,51–14,46;
на 81 920 байтах — 95,20–221,24;
на мегабайте — 1 014,69–3 002,97.
Аренда с возвратом без очистки занимает 8,0–13,7 нс на любом размере от 1 024 байт до мегабайта. Пул ничего не копирует, он меняет ссылку. Всё, что выше единицы на графике, — это обнуление.
История 4. Сколько времени уходит на аренду массиваЗамер пишет в массив, иначе сравнение выйдет неполным. new обнуляет память и тем самым обращается ко всем страницам, а GC.AllocateUninitializedArray к ним не обращается и переносит работу на первую запись. Запись уравнивает все три способа.
Первая строка каждого блока — нижняя граница. Массив создан и уже заполнен, остаётся только запись.
Что меряем | №1 Ryzen | №2 i9 | №3 Xeon Silver | №4 Xeon W |
массив уже есть, 81 920 | 803 | 1 341 | 2 544 | 1 601 |
new + запись | 2 351 | 3 858 | 6 431 | 7 471 |
без обнуления + запись | 1 535 | 2 439 | 4 407 | 3 762 |
из пула + запись | 814 | 1 346 | 2 584 | 1 609 |
массив уже есть, 1 МБ | 11 802 | 24 753 | 32 775 | 21 169 |
new + запись | 284 999 | 86 904 | 144 645 | 148 997 |
без обнуления + запись | 273 621 | 68 416 | 138 113 | 109 088 |
из пула + запись | 12 035 | 24 758 | 32 279 | 21 558 |
Наносекунд на вызов, .NET 10, отчёт PoolTouchBench. У строк new и без обнуления на мегабайте разброс доходит до 5% против 0,4% у соседних, между прогонами эти числа заметно расходятся. Строки с готовым массивом и из пула повторяются от прогона к прогону
Из таблицы можно сделать выводы:
на 81 920 байтах аренда идёт по нижней границе: 814 против 803 на первой машине и 1 609 против 1 601 на четвёртой;
на мегабайте отношение то же: 12 035 против 11 802 и 21 558 против 21 169;
new и выделение без обнуления на мегабайте занимают десятки и сотни тысяч наносекунд.
Отношение аренды к нижней границе по восьми замерам двух прогонов лежит между 0,985 и 1,035. Пул не делает ничего сверх записи в массив. Абсолютные числа new и выделения без обнуления зависят от загрузки машины, отношение — нет.
Все предыдущие числа сняты так: буфер возвращается сразу. Удержание буферов исчерпывает запас пула, и аренда переходит на выделение памяти. Замер берёт один размер и удерживает все буферы разом, а границу показывает колонка Allocated.
Удерживается буферов | №1, 32 проц. | №2, 20 проц. | №3, 64 проц. | №4, 20 проц. |
512 по 1 024 байта | ||||
1 024 по 1 024 байта | 401 384 | 401 385 | ||
512 по 81 920 байт | 8 | 32 | ||
1 024 по 81 920 байт | 32 | 50 209 365 | 50 209 383 |
Выделено байт на вызов, .NET 10, отчёт PoolCapacityBench. В шапке после номера машины — число логических процессоров
Из таблицы можно сделать выводы:
на 512 удерживаемых буферах все четыре машины выделяют не больше 32 байт;
машины с двадцатью процессорами на 1 024 удерживаемых буферах выделяют 401 КБ при размере буфера 1 024 байта и 50 МБ при размере 81 920;
машины с 32 и 64 процессорами на тех же 1 024 буферах выделяют 32 байта и ноль.
Эти две пары отличает только число процессоров. Общий пул хранит массивы в стеках, привязанных к процессорам, и вместимость растёт вместе с их количеством. Устройство разобрано у epeshk, исходник — SharedArrayPool.cs.
Отсюда следствие: замер вместимости с рабочей машины не переносится на сервер с другим числом процессоров.
Отдельный вопрос — до какого размера пул хранит массивы. Общий пул хранит всё, что проверялось, до 134 217 728 байт. Пул из ArrayPool.Create с параметрами по умолчанию — до 1 048 576 включительно:
// PoolProof, вывод dotnet run -c Release -f net10.0 -- extra
=== До какого размера пул хранит массивы ===
Размер Shared Create()
524 288 хранит хранит
1 048 576 хранит хранит
2 097 152 хранит нет
4 194 304 хранит нетВыводыПо цифрам:
порог кучи больших объектов для byte[] — 84 976 байт, а не 85 000: к длине добавляется заголовок в 24 байта. При поднятом пороге 196 608 граница проходит по 196 584, те же 24 байта;
аренда отправляет массив в эту кучу на размерах от 65 537 до 84 975 байт, new оставляет его в нулевом поколении;
64 буфера по 81 920 байт, удерживаемые одновременно, дают 8 195 КБ; те же 64 с возвратом после каждого — 128 КБ и 139 296 выделенных байт;
new на 81 920 байт не увеличивает эту кучу ни при каком количестве буферов;
FileStream.CopyTo, CopyToAsync и наследники MemoryStream выделяют 139 296 байт на первом копировании, а сам MemoryStream не выделяет ничего;
прирост кучи виден не всегда: пул через Gen2GcCallback отдаёт буфер по сборке второго поколения, и на части машин это происходит до второго чтения;
то же копирование при DOTNET_GCLOHThreshold=0x30000 прироста кучи не даёт;
возврат без очистки отдаёт следующему арендатору все 16 байт и держит все 64 объекта, возврат с очисткой не оставляет ни одного объекта; пул отклоняет исключением массив из другого пула, а двойной возврат принимает без ошибки;
возврат с очисткой медленнее возврата без неё в 1,56–1,65 раза на 1 024 байтах и в 1 014,69–3 002,97 раза на мегабайте; аренда с возвратом занимает 8,0–13,7 нс на любом размере;
аренда идёт по нижней границе: отношение к времени готового массива 0,985–1,035 по восьми замерам двух прогонов;
машины с 20 процессорами на 1 024 удерживаемых буферах выделяют 401 КБ при размере буфера 1 024 байта и 50 МБ при размере 81 920, машины с 32 и 64 процессорами — 32 байта и ноль;
серверный сборщик не меняет ни одну из таблиц.
Что делать на практике:
создавать разовый буфер от 65 537 до 84 975 байт через new: аренда отправляет массив в кучу больших объектов, new оставляет его в нулевом поколении;
явно задавать размер буфера при копировании потоков, если нужно остаться вне этой кучи: CopyTo(dest, 65 536);
поднимать порог через DOTNET_GCLOHThreshold, когда размер буфера задать нельзя: на замерах прирост пропадает;
возвращать буфер сразу после работы: в куче больших объектов остаётся один массив вместо одного на вызов;
возвращать с очисткой массивы ссылок и буферы с данными, которые не должны попасть следующему арендатору: без очистки пул не даёт сборщику собрать объекты, а на мегабайте очистка в тысячу с лишним раз медленнее обычного возврата;
возвращать массив один раз и не обращаться к нему после возврата: пул двойной возврат не отклоняет;
не переносить замер вместимости с рабочей машины на сервер: вместимость зависит от числа логических процессоров;
мерить прирост кучи больших объектов вместе с выделенными байтами: сборка мусора меняет прирост, но не меняет выделенные байты.
PoolProof — замеры, отчёты и прогоны на четырёх машинах
Всем удачи и до новых встреч!
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
100%Иногда, когда думаю о памяти2
0%Никогда, хватает дефолта0
0%Копирую потоки другим способом0
Проголосовали 2 пользователя. Воздержался 1 пользователь.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Бенчмаркая System.Text.Json: те же данные, те же настройки, до ×4,3 разницы | 0 | 11.26 | 30-07-2026 |
| 2 | Бенчмаркая ZLinq: один IEnumerable в сигнатуре — и .NET 10 быстрее библиотеки в 3,9 раза | 0 | 13.49 | 31-07-2026 |
| 3 | Ошибка, которую никто никогда не совершал. Ведь так, да? | -1 | 11.32 | 17-07-2026 |
| 4 | «lock xadd, и это весь Arc::clone? Не совсем | 0 | 6.06 | 30-07-2026 |
| 5 | Ошибки и подозрительные места в исходниках .NET 10 | 0 | 12.66 | 04-06-2026 |
| 6 | Ошибка в коде, на которую приходится не обращать внимание | -1 | 6.72 | 08-06-2026 |
| 7 | ИграКОД: Генератор судоку на TypeScript: почему найти решение проще, чем доказать единственность | -1 | 17.78 | 01-08-2026 |
| 8 | [Перевод] Стохастический клеточный автомат на системе типов | 0 | 8.92 | 01-08-2026 |
| 9 | «Оценка 300 часов? ИИ мне сделает за вечер!». Считаем тремя методами, и 300 — ниже плинтуса в индустрии | 0 | 11.05 | 02-08-2026 |
| 10 | JOIN как в ORM: связи по foreign key в PostgreSQL | 0 | 7.3 | 02-08-2026 |