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

70c2da4234da009b42f29403a4667a68

Придерживаясь великой цитаты «правила созданы для того, чтобы их нарушать», давайте нарушим какие-то основополагающие правила 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((ulong*)buffer, (void**)Unsafe.AsPointer(ref res)); // Передаем указатель на память и указатель на переменную  
   Console.WriteLine(res.GetType());  
   var res2 = UnsafeAllocateInstance();  
   Console.WriteLine(res2.GetType());  
}  
  
public static T UnsafeAllocateInstance()  
{
   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((ulong*)buffer, (void**)Unsafe.AsPointer(ref res)); // Передаем указатель на память и указатель на переменную  
   return res; // Возвращаем переменную  
}  
  
public static void UnsafeInitializeInstance(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 и дает на контроль над тем, сколько будет жить объект и возможность его переиспользовать под те же, или даже другие цели.

© Habrahabr.ru