Примечание переводчика:
Эта небольшая статья Конрада Кокосы дополняет опубликованный неделей ранее перевод Внутреннее устройство Pinned Object Heap в .NET. В этом материале Кокоса подходит немного ближе к практике, рассказывая об API, используемом для выделения объектов в POH, сравнивая его с закреплением объектов в SOH и LOH, и не забывая упомянуть об ограничениях.
В сборщике мусора .NET 5 появилась очень интересная возможность — Куча Закрепленных Объектов (Pinned Object Heap, POH) — новый вид управляемой кучи (до сих пор у нас были только Куча Малых и Куча Больших Объектов — Small и Large Object Heap, SOH и LOH). У закрепления объектов (object pinning) есть свои издержки, поскольку оно приводит к фрагментации кучи и сильно усложняет уплотнение объектов. Конечно, есть несколько проверенных способов минимизации этих проблем, например:
Закреплять объекты только на очень короткое время, чтобы уменьшить вероятность того, что сборка мусора произойдет, пока объекты еще закреплены. Для этого обычно используется ключевое слово
fixed
, которое является легковесным способом пометить конкретную локальную переменную, как закрепленную ссылку. Пока сборки мусора не происходит, нет и никаких дополнительных накладных расходов.Закреплять объекты только на очень долгое время. В этом случае сборщик мусора переведет закрепленные объекты в поколение 2, и поскольку сборка мусора в поколении 2 происходит редко, влияние закрепленных объектов будет минимизировано. Для этого используется
GCHandle.Alloc(obj, GCHandleType.Pinned)
, что требует больше накладных расходов, потому что нам нужно выделить/освободитьGCHandle
.
Однако, даже если всегда следовать этим правилам, избежать фрагментации кучи все равно не выйдет. Она будет зависеть от того, сколько объектов вы закрепляете, на какой срок, каково результирующее расположение закрепленных объектов в памяти, а также от многих других факторов.
Поэтому, в конце концов, было бы идеально просто избавиться от закрепленных объектов в SOH/LOH, поместив их в другое место. Сборщик мусора будет игнорировать это отдельное место при уплотнении кучи, так что мы из коробки получим такое же поведение, как и при закреплении объектов.
И хоть описанная концепция довольно проста, API .NET до версии 5 не позволял ее реализовать. Ранее закрепление объекта представляло собой двухфазный процесс:
Выделяем объект и сохраняем полученную ссылку в каком-либо месте.
Фиксируем объект с помощью
fixed
илиGCHandle
.
Другими словами, аллокатор ничего не знает о том, что создаваемый объект в будущем будет закреплен.
Вот почему вместе с Pinned Object Heap, появившейся в .NET 5, был представлен новый API для выделения памяти. Вместо использования оператора new
, мы можем выделять массивы при помощи одного из двух методов:
GC.AllocateArray<T>(arrayLength, pinned: true);
GC.AllocateUninitializedArray<T>(arrayLength, pinned: true);
Как мы видим, новый API для выделения памяти позволяет нам сразу указать, что мы хотим закрепить созданный объект. И этот факт позволяет выделять объект непосредственно в POH, а не в SOH/LOH. Возникает вопрос, почему только массивы? Microsoft отвечает на этот вопрос так:
Разрешить размещение в POH объекта, не являющегося массивом, возможно, но в настоящее время мы не видим в этом большой пользы.
Это связано со сценариями, в которых чаще всего и используется закрепление. А чаще всего используется именно закрепление буферов для различных целей. А буферы — это массивы. Другими словами, хоть технически POH может содержать любой объект, в настоящее время имеющийся API поддерживает только массивы. С детальным описанием Pinned Object Heap вы можете ознакомиться в документации к CLR, а также в статье Внутреннее устройство Pinned Object Heap в .NET.
Важно помнить, что выделение памяти в Pinned Object Heap происходит немного медленнее, чем обычное выделение в SOH. Оно основано не на контексте выделения (allocation context), который создается для каждого потока, а на едином списке свободных участков (free-list allocation), как в LOH (подробнее о выделении памяти в .NET вы можете узнать из доклада Конрада Кокосы). Таким образом, когда мы выделяем память в POH, необходимый объем свободного места должен быть найден в одном из ее сегментов. Вот почему нужно рассматривать POH, как замену закрепления на длительное время через GCHandle.Alloc()
, а не как замену закрепления на малый срок при помощи fixed
.
Еще одно очень важное ограничение Pinned Object Heap заключается в том, что ее содержимое ограничено массивами типов, которые и сами не являются ссылками, и не содержат ссылок (т. н. blittable types или непреобразуемые типы). Опять же, это не техническое ограничение, а решение, вытекающее из типичных сценариев использования — в основном мы закрепляем буферы неуправляемых типов, таких как int
или byte
. Это решение имеет дополнительное преимущество в производительности, т. к. сборщик мусора может пропустить POH при маркировке достижимых объектов. Другими словами, поскольку из объектов непреобразуемых типов не может быть исходящих ссылок, нет и необходимости рассматривать объекты в POH, как потенциальные корни.
Учтите, что соответствующая проверка проводится во время исполнения программы, так как она зависит от флага pinned
:
{
...
if (pinned)
{
if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
ThrowHelper.ThrowInvalidTypeWithPointersNotSupported(typeof(T));
}
...
То есть мы сможем скомпилировать такой код:
var x = GC.AllocateArray<string>(10, pinned: true);
однако во время исполнения возникнет исключение:
System.ArgumentException
Message=Cannot use type 'System.String'. Only value types without pointers or references are supported.
Вы можете спросить, почему проверка во время исполнения оказалась предпочтительнее, чем ограничение при компиляции? Очевидно, для этого потребовалось бы создать отдельный метод только для закрепления:
public static T[] AllocatePinnedArray<T>(int length) where T : unmanaged
Однако, команда .NET планирует в будущем расширять AllocateArray
дополнительными параметрами (например, указать, в каком поколении мы хотим выделять память), или по крайней мере не хочет себя ограничивать, предоставляя узкоспециализированные методы.
Продолжается обсуждение, нужно ли добавить метод GC.IsPinnedHeapObject(obj)
для проверки, был ли объект выделен в POH. Решение пока не принято, т. к. такая проверка сопряжена с накладными расходами, которые, вероятно, перекроют преимущества от ее использования в реальных сценариях.
В заключение поговорим о различных сценариях использования массивов с помощью этого API. Скорее всего, нам понадобится указатель на выделенную область памяти, так что ключевое слово fixed
все еще необходимо:
{
var pinnedArray = GC.AllocateArray<byte>(128, pinned: true);
fixed (byte* ptr = pinnedArray)
{
// Вызов fixed не добавляет накладных расходов
}
}
Использование fixed
здесь не добавляет накладных расходов, потому что оно применяется к объекту в POH, и поэтому уже никак не влияет на сборку мусора.
Примеры использования Pinned Object Heap в больших и известных проектах уже появились, например POH используется в MemoryPool в Kestrel.
DistortNeo
Хотелось бы понять, есть ли у POH преимущество? Я вот набросал простенький бенчмарк с выделением-освобождением памяти и получил следующее:
Код
Понятное дело, что здесь не рассматривались аспекты фрагментирования кучи, сложности с ручным освобождением памяти, но вот преимущество у нативного выделения в разы мне кажется немного странным.
Также с POH есть второй неочевидный момент: передача указателя на буфер в вызов, инициирующий асинхронную операцию. То есть по выходу из функции буфер должен продолжать жить. Но если мы не знаем, был ли выделен в POH, то мы не имеем права использовать
fixed
, а должны использовать медленныйGCHandle
, что сводит преимущество POH на нет.commanderkid
Не совсем понятно по тексту статьи (мне). Мы fixed если используем - элемент не в POH будет, а в SOH/LOG?
Sing
DistortNeo
Не. Выделение управляемой памяти в GC-языках обычно более быстрое. Тут львиная доля времени уходит на
fixed/GCHandle.Alloc
, а не на выделение.Я имею в виду нативные вызовы типа
WSASend
, куда вы передаёте указатель на буфер, а данные туда пишутся в фоне.Вы можете вызывать эту функцию явно, написав свою библиотеку для работы с сокетами, а можете неявно, просто используя асинхронные методы работы с библиотечным
Socket
. И единственный способ зафиксировать буфер здесь — этоGCHandle.Alloc
, а неfixed
.Резюме: я бы рассматривал POH как альтернативу выделения неуправляемой памяти, но с поддержкой GC.
Sing
Силюсь понять, для чего может быть нужна своя библиотека для работы с сокетами — и не получается.
А зачем вообще тут использовать именно неуправляемый буфер, а не, скажем, Memory{T}?
DistortNeo
В учебниках. Это основы.
А если не верите — напишите бенчмарк и убедитесь в этом самостоятельно. Я вот прямо сейчас проверил: у меня на небольших объектах (64 байта) скорость выделения управляемой памяти оказалась в 3 раза выше, чем неуправляемой (выделение + удаление).
Ну, например, для работы с io_uring вместо epoll.
И причём, тут, собственно, своя библиотека? Можно подумать, когда вы используете системную библиотеку, под капотом оно работает как-то по-другому.
Потому что RTFM. Это просто обёртка над управляемым массивом.
Sing
Так это же работа с файлами. Я спрашивал про сокеты.
Так это же вы написали про свою библиотеку)
Memory — это «не обёртка над управляемым массивом». Так что, действительно, RTFM.
DistortNeo
https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
Читайте основы. Управляемую память не нужно освобождать. Это сделает сборщик мусора в фоновом потоке. Да, возможны ситуации, когда он может не успевать, но это редкий сценарий.
man io_uring
man socket
Нет. Вы просто не дочитали фразу до конца: своя библиотека или библиотечный Socket. А под капотом всё все равно сводится к вызову API операционной системы.
Да, ошибся. Аргументом
Memory<T>
может быть не только массив:https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/shared/System/Memory.cs#L174
но и ещё строка и
MemoryManager<T>
. А вот через последний и можно реализовать нативные буферы. Но в целом, это натягивание совы на глобус.Kolonist Автор
Я думаю, можно посмотреть у Майкрософт, как они заменили
GCHandle.Alloc
наGC.AllocateUninitializedArray
: https://github.com/dotnet/aspnetcore/pull/21614/files