[Перевод] Pinned Object Heap в .NET 5

Примечание переводчика:
Эта небольшая статья Конрада Кокосы дополняет опубликованный неделей ранее перевод
Внутреннее устройство 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 не позволял ее реализовать. Ранее закрепление объекта представляло собой двухфазный процесс:

  1. Выделяем объект и сохраняем полученную ссылку в каком-либо месте.

  2. Фиксируем объект с помощью fixed или GCHandle.

Другими словами, аллокатор ничего не знает о том, что создаваемый объект в будущем будет закреплен.

Вот почему вместе с Pinned Object Heap, появившейся в .NET 5, был представлен новый API для выделения памяти. Вместо использования оператора new, мы можем выделять массивы при помощи одного из двух методов:

GC.AllocateArray(arrayLength, pinned: true);
GC.AllocateUninitializedArray(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())
        {
            ThrowHelper.ThrowInvalidTypeWithPointersNotSupported(typeof(T));
        }
        ...

То есть мы сможем скомпилировать такой код:

var x = GC.AllocateArray(10, pinned: true);

однако во время исполнения возникнет исключение:

System.ArgumentException
Message=Cannot use type 'System.String'. Only value types without pointers or references are supported.

Вы можете спросить, почему проверка во время исполнения оказалась предпочтительнее, чем ограничение при компиляции? Очевидно, для этого потребовалось бы создать отдельный метод только для закрепления:

public static T[] AllocatePinnedArray(int length) where T : unmanaged

Однако, команда .NET планирует в будущем расширять AllocateArray дополнительными параметрами (например, указать, в каком поколении мы хотим выделять память), или по крайней мере не хочет себя ограничивать, предоставляя узкоспециализированные методы.

Продолжается обсуждение, нужно ли добавить метод GC.IsPinnedHeapObject(obj) для проверки, был ли объект выделен в POH. Решение пока не принято, т. к. такая проверка сопряжена с накладными расходами, которые, вероятно, перекроют преимущества от ее использования в реальных сценариях.

В заключение поговорим о различных сценариях использования массивов с помощью этого API. Скорее всего, нам понадобится указатель на выделенную область памяти, так что ключевое слово fixed все еще необходимо:

{
    var pinnedArray = GC.AllocateArray(128, pinned: true);
    fixed (byte* ptr = pinnedArray)
    {
        // Вызов fixed не добавляет накладных расходов
    }
}

Использование fixed здесь не добавляет накладных расходов, потому что оно применяется к объекту в POH, и поэтому уже никак не влияет на сборку мусора.

Примеры использования Pinned Object Heap в больших и известных проектах уже появились, например POH используется в MemoryPool в Kestrel.

© Habrahabr.ru