Придерживаясь великой цитаты "правила созданы для того, чтобы их нарушать", давайте нарушим какие-то основополагающие правила 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 и дает на контроль над тем, сколько будет жить объект и возможность его переиспользовать под те же, или даже другие цели.

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


  1. DrewUnknown
    26.04.2024 10:58

    статью не стал читать. когда не увидел в начале объяснение. а зачем это делать?


    1. Rietmon Автор
      26.04.2024 10:58
      +6

      Для общего развития, чтобы понимать, с чем ты работаешь, хотя бы на один уровень ниже.
      Какое продакшен-применение можно найти для статьи, которая в самом начале говорит "давайте нарушим правила"?


    1. badryuner
      26.04.2024 10:58

      Ну например, чтобы не тратить много наносекунд на аллокацию object[] для использования в MethodInfo.Invoke().

      https://gist.github.com/BadRyuner/602a94b1f3b4e054258dbec165f24d68


    1. Prikalel
      26.04.2024 10:58

      например если ты разрабатываешь игру на юньке или Годот с использованием с# и хочешь получить лишние кадры в секунду


  1. Fitbie
    26.04.2024 10:58
    +1

    Спасибо за статью, у меня как раз неделя структур/классов и их кухни


  1. SadOcean
    26.04.2024 10:58

    Мсье знает толк...
    Мое уважение


  1. YellowFive
    26.04.2024 10:58


  1. troepolik
    26.04.2024 10:58

    Спасибо за статью!


  1. mvv-rus
    26.04.2024 10:58
    +1

    Это всё интересно, но что будет, если такой вот экземпляр "класса" будет содержать поле ссылочного типа, а не как в статье - только поля значимых типов? Как это будет жить со сборкой мусора (поймет ли GC, что это поле - корень: не сочтет ли он, что экземпляр по ссылке - мусор, если других ссылок не будет, что что произойдет если GC переместит экземпляр по ссылке в другое место, и так далее). А если учесть, что GC может захотеть поработать в любой момент, то меня тут терзают смутные сомнения, что этим экземпляром со ссылочным типом вообще можно будет хоть как-то пользоваться (закреплять то, что по ссылке, разве что).


  1. Ruins007
    26.04.2024 10:58

    Какая хорошая техническая статья, на самом деле весь интероп огромная боль, а GC вообще необузданный монстр. Каждый раз при попытке хоть как-то контролировать освобождение памяти вылазит просто уйма нюансов и костылей в виде ObjectPooling. А здесь даже мимикрия под настоящий класс, ну просто красота.

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

    Смысл здесь логический, так как классы управляемые, objectHeader - это как раз обёртка, а по указателю что должно лежать? правильно мякотка, и никому кроме обработчика в частных случаях обёртка не нужна. Да и регулярно добавлять +8 к любому обращению к классу, который в жизни обработкой заниматься не будет - такое себе. В то время как та же таблица методов уже является частью реализации и жертвой постоянных обращений от всех владельцев ссылки.


  1. a-tk
    26.04.2024 10:58

    Уж лучше Кокосу почитайте...


  1. maksim_sitnikov
    26.04.2024 10:58

    Кайф, импорт системных библиотек, инвоки в ассортименте, всякий треш в сторонних либах, etc.