Отправляем GC в отпуск и создаем эксзепляры классов .NET сами

—

от автора

Придерживаясь великой цитаты «правила созданы для того, чтобы их нарушать», давайте нарушим какие-то основополагающие правила CLR. Как на счет того, чтобы послать GC с его работой в отставку и самим заняться размещением в памяти экземпляров классов? Заодно разберемся, как все это работает где-то там под капотом CLR.

Начнем с того, что мы сразу откинем правило «все экземепляры должны создаваться через ключевое слово new в управляемой куче благодаря GC«. Сначала разберемся, что вообще такое есть экземпляр класса и ссылка.

Когда мы создаем экземпляр через ключевое слово new, мы аллоцируем где-то в куче sizeof(class) + 16 и возвращаем pointer + 8. Теперь остановимся подробнее. То, чем мы оперируем в контексте работы с классом — это всего лишь указатель на некую область памяти. То есть любую ссылку можно представить как IntPtr или void* и смысла она не поменяет. А если любая ссылка это просто указатель, значит мы может откинуть оковы безопасного кода и поработать напрямую с указателями… Насколько это позволяет C#. 

Но для этого, сначала, надо понять, как работает память у классов. Помимо размещения полей класса, CLR так же добавляет для себя еще 16 байт (или 8 для 32bit систем), это так называемый object header и указатель на virtualMethodTable. Первое содержит себе разные данные для правильной работы GC и CLR, а указатель помогает определить, с каким типом мы работаем. Для простоты понимания можно сказать, что это поле отвечает за тип класса, а соответственно, и за его переопределенные методы, и другие ООП вещи.

Размещается он в памяти как…

public unsafe struct UnmanagedClass  {    public ulong objectHeader; -8    public void* methodTable; 0 < сюда будет указывать наш указатель  // Далее уже идут наши поля    public int value0; 8    public ulong value1; 12 }

…аналогичная структура. Почему objectHeader находится по адресу -8? Зачем так сделано? Хз. Даже майкрософт говорили, что просто так исторически сложилось и никакого скрытого смысла здесь нет. 

Значит теперь мы можем представлять классы в виде структур и работать с указателями? Верно.

Попробуем…

public class TestClass {    public int value0;    public ulong value1;    public string value2; }  // Будет равно public struct UnmanagedTestClass {    public void* methodTable;    public int value0;    public ulong value1;    public void* value2; // Все managed поля должны быть unmanaged.    // Либо создаем свой вариант строки в виде структуры с таким же расположением полей.    // Либо забиваем и используем void* или IntPtr. Я выбрал этот вариант <3 }

… и теперь если мы сделаем…

public static void Test() {    var instance = new TestClass();      var pInstance = *(UnmanagedTestClass**)Unsafe.AsPointer(ref instance); }

…мы получим указатель на наш класс. Почему *(UnmanagedTestClass**) — объясняю, мы взяли указатель на переменную на стеке, которая, как я говорил, является указателем на экземпляр класса. Соответственно, это указатель на указатель, поэтому его надо разыменовать.

Поздравляю. Уже на этом этапе мы послали на 3 буквы очень много правил безопасности C# и CLR, продолжаем. Теперь нам надо избавится от ключевого слова new. Если мы загуглим, как это сделать, то получим ответ в духе «нельзя так делать, нужно только new и бла-бла-бла», но мы сделаем.

Мы уже разобрались, как в памяти располагается экземпляр, теперь мы можем его воссоздать.

Для этого нам надо где-то аллоцировать память. Для этого можно использовать 3 варианта:

1. Использовать malloc();

2. Использовать stackalloc;

3. Использовать кастомный аллокатор.

Первый и второй вариант самые простые, которое готовы прямо из коробки, поэтому будем использовать их. Первый и третий вариант схожи по своей сути и я настоятельно рекомендую использовать 3й.

Что же нам надо сделать? Аллоцировать память sizeof(class) + 16. Заполнить первые 8 байт нулями, вторые 8 байт заполнить указателем на virtualMethodTable, записать указатель на эту область память в переменную на стеке… И все.

Теперь попробуем это сделать и аллоцируем экземпляр на стеке…

public static void Test()   {      var memory = stackalloc ulong[8]; // Аллоцируем память на стеке 64 байта. Взято с запасом      Unsafe.InitBlock(memory, 0, 8 * 8); // Очищаем память, ибо она грязная      TestClass res = null; // Создаем на стеке переменную под ссылку на экземпляр    var pRes = (ulong**)Unsafe.AsPointer(ref res); // Получаем указатель на переменную на стеке      *memory = 0x0; // Заполняем первые 8 байт нулями, ибо это заголовок, а он нам не нужен    *++memory = (ulong)typeof(TestClass).TypeHandle.Value.ToPointer(); // Заполняем вторые 8 байт указателем на метод таблицу      // В вашем случае вместо TestClass должен быть указан ваш класс    *pRes = memory; // Передаем указатель на память в переменную      // Теперь переменную res можно использовать в этой функции.    // Как толкьо мы выйдем из функции, ссылка станет невалидной и ее использование недопустимо }

…и мы получим работоспособный экземпляр. Для получения точного размера класса нам нужно немного напрячься и написать структуру VirtualMethodTable, выглядит она так…

[StructLayout(LayoutKind.Explicit)] public struct VirtualMethodTable {     [FieldOffset(0)] public uint flags;     [FieldOffset(4)] public uint size; // Отсюда нам надо только это поле, остальные можно игнорировать     [FieldOffset(8)] public uint flags2;     [FieldOffset(12)] public ushort numVirtuals;      [FieldOffset(14)] public ushort numInterfaces;     [FieldOffset(16)] public void* pParentMethodTable;     [FieldOffset(24)] public void* pModule;     [FieldOffset(32)] public void* pAuxiliaryData;     // Эти два поля находятся по одному и тому же адресу     [FieldOffset(40)] public void* pPerInstInfo;     [FieldOffset(40)] public void* pElementTypeHnd;     [FieldOffset(48)] public void* pInterfaceMap; }

…в поле size будет выравненный размер класса в байтах.

В примере указан вариант со аллокацией на стеке, но его можно заменить на malloc() или Marshal.AllocHGlobal(). В таком случае надо не забыть потом вызвать Free() для указателя. Однако для глобальной аллокации намного лучше использовать кастомные аллокаторы с кэшем памяти по причинам, как я и говорил выше.

Если напрячься и сделать для всего этого API, то выйдет нечто такое:

public static void Main()   {      var size = (int)((VirtualMethodTable*)typeof(TestClass).TypeHandle.Value)->size + 16; // Вычисляем размер класса    var buffer = stackalloc byte[size]; // Аллоцируем память для класса на стеке    Unsafe.InitBlock(buffer, 0, (uint)size); // Очищаем память, ибо она грязная      TestClass res = null; // Резервуем на стеке переменную под ссылку      UnsafeInitializeInstance<TestClass>((ulong*)buffer, (void**)Unsafe.AsPointer(ref res)); // Передаем указатель на память и указатель на переменную      Console.WriteLine(res.GetType());      var res2 = UnsafeAllocateInstance<TestClass>();      Console.WriteLine(res2.GetType());   }      public static T UnsafeAllocateInstance<T>()   {    var size = (int)((VirtualMethodTable*)typeof(TestClass).TypeHandle.Value)->size + 16; // Вычисляем размер класса      var buffer = Marshal.AllocHGlobal(size  ).ToPointer(); // Аллоцируем память для класса. Взято с запасом      Unsafe.InitBlock(buffer, 0, (uint)size  ); // Очищаем память, ибо она грязная      T res = default; // Резервуем на стеке переменную под ссылку      UnsafeInitializeInstance<T>((ulong*)buffer, (void**)Unsafe.AsPointer(ref res)); // Передаем указатель на память и указатель на переменную      return res; // Возвращаем переменную   }      public static void UnsafeInitializeInstance<T>(ulong* ptr, void** stackPointer)   {      *ptr = 0x0; // Заполняем первые 8 байт нулями, ибо это заголовок      *++ptr = (ulong)typeof(T).TypeHandle.Value.ToPointer(); // Заполняем вторые 8 байт указателем на метод таблицу      *stackPointer = ptr; // Передаем указатель на память в переменную   }

Поздравляю, вы нарушили так много правил безопасности в C#, что заслужили пинок под зад от Хайльсберга. Этот код ходит по грани UB и строго не рекомендуется к использовании без веских причин, ибо малейшая ошибка или изменение могут поломать всю логику и вызвать «Segmentation fault», а так же понос и выпадение волос.

Однако теперь вы знаете, что происходит за невинным словом `new` (спойлер: это даже не половина того, что происходит, в CLR эта часть написана на ассемблере и там еще много логики для выравнивания и оптимизации) и почему структуры намного предпочтительнее классов в случаях, когда их использование возможно. Спасибо за внимание.

P.S. Если правильно организовать управление памятью, такой способ аллокации экземпляров может быть на ~20% быстрее, чем через new :D. Не говоря уж о том, что это снимает нагрузку с GC и дает на контроль над тем, сколько будет жить объект и возможность его переиспользовать под те же, или даже другие цели.


ссылка на оригинал статьи https://habr.com/ru/articles/810689/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *