Уважаемые читатели, в этой статье я хочу разобраться, что происходит с буфером при аренде из 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, а длина массива в нём — 131 072 байта.
Строка 16 << 13 — это 16 * 2¹³: пул хранит массивы степенями двойки, начиная с шестнадцати байт, и номер бакета задаёт степень.
// PoolProof, вывод dotnet run -c Release -f net10.0 -- extra=== Где проходит порог кучи больших объектов ===порог из документации: 85 000первая длина byte[], попадающая во второе поколение: 84 976размер бакета для неё: 131 072наибольшая длина, остающаяся в нулевом поколении: 84 975
84 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.CopyTo
81 920 — это размер буфера по умолчанию у Stream.CopyTo. Константу выбирали так, чтобы буфер не попал в кучу больших объектов.
Комментарий из Stream.cs:
// dotnet/runtime, System/IO/Stream.csprivate 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.csbyte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
Команда рантайма про это знает. В комментарии перечислены причины оставить константу: буфер живёт недолго, порог настраивается, а на большем буфере копирование быстрее.
Обращение dotnet/runtime issue #43179 закрыто 25 апреля 2025 года. Константа осталась прежней, аренда из общего пула тоже — обе строки выше сняты с текущего Stream.cs.
Дальше копирование. В таблице по две колонки на машину: прирост кучи больших объектов и выделено байт. Первая показывает, сколько памяти осталось после сборки, вторая — сколько её запросили.
|
Что копируем |
№1 |
№2 |
№3 |
№4 |
Выделено, Б |
|
MemoryStream точного типа |
0 |
0 |
0 |
0 |
8 200 |
|
наследник MemoryStream |
0 |
128 |
128 |
0 |
139 296 |
|
он же второй раз в том же процессе |
0 |
0 |
0 |
0 |
2 944 на №2 и №3, 138 768 на №1 и №4 |
|
наследник, CopyTo(dest, 65 536) |
0 |
0 |
0 |
0 |
74 688 |
|
наследник, CopyToAsync(dest) |
0 |
128 |
128 |
0 |
139 296 |
|
FileStream, CopyTo(dest) |
0 |
128 |
128 |
0 |
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.cspublic 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 583
196 584 + 24 = 196 608. Заголовок в 24 байта подтвердился на втором пороге. FileStream.CopyTo даёт нулевой прирост на всех четырёх машинах, 64 запроса с возвратом тоже дают ноль вместо 128 КБ. Буфер на 131 072 в эту кучу больше не попадает.
История 3. Возврат в пул
У 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 после возврата с очисткой |
0 |
|
две аренды после двойного возврата вернули один и тот же массив |
да |
|
возврат массива из другого пула |
ArgumentException |
Отчёты reports и extra, четыре машины, три рантайма, оба сборщика
Из таблицы можно сделать выводы:
-
массив байт отдаёт следующему арендатору все 16 байт от предыдущего;
-
массив ссылок держит все 64 объекта: сборщик их не заберёт, пока кто-нибудь не перезапишет ячейки;
-
возврат с очисткой обнуляет ячейки, и все 64 объекта уходят в сборку;
-
пул принимает двойной возврат без ошибки, и две следующие аренды получают один массив.
Последняя строка расходится с комментарием выше: контракт требует возвращать массив один раз, а проверки нет. Два независимых участка кода получают один буфер и не знают об этом. Документация на Return относит двойной возврат к уязвимостям высокой степени опасности и ссылается на CWE-415 и CWE-416 — двойное освобождение памяти и обращение к ней после освобождения. Массив из другого пула при этом отклоняется исключением: проверка в пуле есть, но не на этот случай.
Очистка занимает время, и оно зависит от длины массива:
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 и выделения без обнуления зависят от загрузки машины, отношение — нет.
История 5. Сколько буферов удерживает пул
Все предыдущие числа сняты так: буфер возвращается сразу. Удержание буферов исчерпывает запас пула, и аренда переходит на выделение памяти. Замер берёт один размер и удерживает все буферы разом, а границу показывает колонка Allocated.
|
Удерживается буферов |
№1, 32 проц. |
№2, 20 проц. |
№3, 64 проц. |
№4, 20 проц. |
|
512 по 1 024 байта |
0 |
0 |
0 |
0 |
|
1 024 по 1 024 байта |
0 |
401 384 |
0 |
401 385 |
|
512 по 81 920 байт |
8 |
0 |
0 |
32 |
|
1 024 по 81 920 байт |
32 |
50 209 365 |
0 |
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 — замеры, отчёты и прогоны на четырёх машинах
Ссылки
Всем удачи и до новых встреч!
ссылка на оригинал статьи https://habr.com/ru/articles/1065518/