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

Комментарии (9)


  1. DistortNeo
    06.12.2021 12:17
    +2

    Хотелось бы понять, есть ли у POH преимущество? Я вот набросал простенький бенчмарк с выделением-освобождением памяти и получил следующее:

    |        Method | BlockSize |      Mean |    Error |   StdDev |
    |-------------- |---------- |----------:|---------:|---------:|
    |  MarshalAlloc |        32 |  34.78 us | 0.056 us | 0.046 us |
    |   PinnedAlloc |        32 | 129.22 us | 0.565 us | 0.529 us |
    | GCHandleAlloc |        32 | 111.00 us | 0.394 us | 0.368 us |
    |  MarshalAlloc |        64 |  35.38 us | 0.057 us | 0.053 us |
    |   PinnedAlloc |        64 | 130.89 us | 0.768 us | 0.718 us |
    | GCHandleAlloc |        64 | 113.60 us | 0.357 us | 0.334 us |
    |  MarshalAlloc |       128 |  54.59 us | 0.415 us | 0.388 us |
    |   PinnedAlloc |       128 | 139.64 us | 2.678 us | 2.865 us |
    | GCHandleAlloc |       128 | 119.83 us | 1.366 us | 1.140 us |
    |  MarshalAlloc |       256 |  54.71 us | 1.020 us | 0.954 us |
    |   PinnedAlloc |       256 | 145.44 us | 2.837 us | 3.036 us |
    | GCHandleAlloc |       256 | 127.97 us | 0.454 us | 0.354 us |
    |  MarshalAlloc |       512 |  54.50 us | 0.132 us | 0.117 us |
    |   PinnedAlloc |       512 | 160.99 us | 3.141 us | 3.739 us |
    | GCHandleAlloc |       512 | 148.42 us | 2.279 us | 2.239 us |
    |  MarshalAlloc |      1024 |  76.02 us | 0.481 us | 0.427 us |
    |   PinnedAlloc |      1024 | 203.36 us | 0.596 us | 0.497 us |
    | GCHandleAlloc |      1024 | 191.79 us | 3.412 us | 4.555 us |
    Код
        [Benchmark]
        public void MarshalAlloc()
        {
            for (int i = 0; i < 1024; i++)
                ptrs[i] = Marshal.AllocHGlobal(BlockSize);
    
            for (int i = 0; i < 1024; i++)
                Marshal.FreeHGlobal(ptrs[i]);
        }
    
        [Benchmark]
        public unsafe void PinnedAlloc()
        {
            for (int i = 0; i < 1024; i++)
            {
                arr[i] = GC.AllocateUninitializedArray<byte>(BlockSize, pinned: true);
    
                fixed (byte* ptr = arr[i])
                    ptrs[i] = new IntPtr(ptr);
            }
            
            for (int i = 0; i < 1024; i++)
            {
                arr[i] = null;
                ptrs[i] = IntPtr.Zero;
            }
        }
    
        [Benchmark]
        public void GCHandleAlloc()
        {
            for (int i = 0; i < 1024; i++)
            {
                arr[i] = new byte[BlockSize];
                gchs[i] = GCHandle.Alloc(arr[i], GCHandleType.Pinned);
            }
            
            for (int i = 0; i < 1024; i++)
            {
                arr[i] = null;
                gchs[i].Free();
            }
        }
    

    Понятное дело, что здесь не рассматривались аспекты фрагментирования кучи, сложности с ручным освобождением памяти, но вот преимущество у нативного выделения в разы мне кажется немного странным.

    Также с POH есть второй неочевидный момент: передача указателя на буфер в вызов, инициирующий асинхронную операцию. То есть по выходу из функции буфер должен продолжать жить. Но если мы не знаем, был ли выделен в POH, то мы не имеем права использовать fixed, а должны использовать медленный GCHandle, что сводит преимущество POH на нет.


    1. commanderkid
      06.12.2021 15:13

      Не совсем понятно по тексту статьи (мне). Мы fixed если используем - элемент не в POH будет, а в SOH/LOG?


    1. Sing
      06.12.2021 17:13
      +1

      Понятное дело, что здесь не рассматривались аспекты фрагментирования кучи
      Вы сами же отвечаете на свой вопрос. Эти аспекты, которые здесь не рассматривались — и есть предназначение POH. Короче говоря, бенчмарк не говорит ничего о преимуществах/недостатках POH.
      преимущество у нативного выделения в разы мне кажется немного странным
      Оно потому и быстрее, что нативное, не? )
      Также с POH есть второй неочевидный момент: передача указателя на буфер в вызов, инициирующий асинхронную операцию.
      Так в асинхронных операциях нельзя использовать указатели. Либо я не понял, что имеется в виду, скиньте код.


      1. DistortNeo
        06.12.2021 17:47

        Оно потому и быстрее, что нативное, не? )

        Не. Выделение управляемой памяти в GC-языках обычно более быстрое. Тут львиная доля времени уходит на fixed/GCHandle.Alloc, а не на выделение.

        Так в асинхронных операциях нельзя использовать указатели. Либо я не понял, что имеется в виду, скиньте код.

        Я имею в виду нативные вызовы типа WSASend, куда вы передаёте указатель на буфер, а данные туда пишутся в фоне.

        Вы можете вызывать эту функцию явно, написав свою библиотеку для работы с сокетами, а можете неявно, просто используя асинхронные методы работы с библиотечным Socket. И единственный способ зафиксировать буфер здесь — это GCHandle.Alloc, а не fixed.

        Резюме: я бы рассматривал POH как альтернативу выделения неуправляемой памяти, но с поддержкой GC.


        1. Sing
          06.12.2021 18:29

          Не. Выделение управляемой памяти в GC-языках обычно более быстрое. Тут львиная доля времени уходит на fixed/GCHandle.Alloc, а не на выделение.
          Да? Как-то контринтуитивно. Подскажете, где можно почитать про это?
          Вы можете вызывать эту функцию явно, написав свою библиотеку для работы с сокетами
          Силюсь понять, для чего может быть нужна своя библиотека для работы с сокетами — и не получается.
          И единственный способ зафиксировать буфер здесь — это GCHandle.Alloc, а не fixed.
          А зачем вообще тут использовать именно неуправляемый буфер, а не, скажем, Memory{T}?


          1. DistortNeo
            06.12.2021 19:09

            Да? Как-то контринтуитивно. Подскажете, где можно почитать про это?

            В учебниках. Это основы.

            А если не верите — напишите бенчмарк и убедитесь в этом самостоятельно. Я вот прямо сейчас проверил: у меня на небольших объектах (64 байта) скорость выделения управляемой памяти оказалась в 3 раза выше, чем неуправляемой (выделение + удаление).

            Силюсь понять, для чего может быть нужна своя библиотека для работы с сокетами — и не получается.

            Ну, например, для работы с io_uring вместо epoll.

            И причём, тут, собственно, своя библиотека? Можно подумать, когда вы используете системную библиотеку, под капотом оно работает как-то по-другому.

            А зачем вообще тут использовать именно неуправляемый буфер, а не, скажем, Memory{T}?

            Потому что RTFM. Это просто обёртка над управляемым массивом.


            1. Sing
              06.12.2021 19:56

              В учебниках. Это основы.
              Так вы просветите меня, основ не знающего — в каких именно учебниках? Желательно с ссылками на главы.
              Я вот прямо сейчас проверил: у меня на небольших объектах (64 байта) скорость выделения управляемой памяти оказалась в 3 раза выше, чем неуправляемой (выделение + удаление).
              Так вы про выделение говорите или про выделение + удаление? Как замеряли удаление в управляемой памяти?
              Ну, например, для работы с io_uring вместо epoll.
              Так это же работа с файлами. Я спрашивал про сокеты.
              И причём, тут, собственно, своя библиотека?
              Так это же вы написали про свою библиотеку)
              Потому что RTFM. Это просто обёртка над управляемым массивом.
              Memory — это «не обёртка над управляемым массивом». Так что, действительно, RTFM.


              1. DistortNeo
                06.12.2021 20:46

                Так вы просветите меня, основ не знающего — в каких именно учебниках? Желательно с ссылками на главы.

                https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals

                Так вы про выделение говорите или про выделение + удаление? Как замеряли удаление в управляемой памяти?

                Читайте основы. Управляемую память не нужно освобождать. Это сделает сборщик мусора в фоновом потоке. Да, возможны ситуации, когда он может не успевать, но это редкий сценарий.

                Так это же работа с файлами. Я спрашивал про сокеты.

                man io_uring
                man socket

                Так это же вы написали про свою библиотеку)

                Нет. Вы просто не дочитали фразу до конца: своя библиотека или библиотечный Socket. А под капотом всё все равно сводится к вызову API операционной системы.

                Memory — это «не обёртка над управляемым массивом».

                Да, ошибся. Аргументом Memory<T> может быть не только массив:
                https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/shared/System/Memory.cs#L174
                но и ещё строка и MemoryManager<T>. А вот через последний и можно реализовать нативные буферы. Но в целом, это натягивание совы на глобус.


    1. Kolonist Автор
      06.12.2021 22:03

      Я думаю, можно посмотреть у Майкрософт, как они заменили GCHandle.Alloc на GC.AllocateUninitializedArray: https://github.com/dotnet/aspnetcore/pull/21614/files