Придерживаясь великой цитаты "правила созданы для того, чтобы их нарушать", давайте нарушим какие-то основополагающие правила 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)
mvv-rus
26.04.2024 10:58+1Это всё интересно, но что будет, если такой вот экземпляр "класса" будет содержать поле ссылочного типа, а не как в статье - только поля значимых типов? Как это будет жить со сборкой мусора (поймет ли GC, что это поле - корень: не сочтет ли он, что экземпляр по ссылке - мусор, если других ссылок не будет, что что произойдет если GC переместит экземпляр по ссылке в другое место, и так далее). А если учесть, что GC может захотеть поработать в любой момент, то меня тут терзают смутные сомнения, что этим экземпляром со ссылочным типом вообще можно будет хоть как-то пользоваться (закреплять то, что по ссылке, разве что).
Ruins007
26.04.2024 10:58Какая хорошая техническая статья, на самом деле весь интероп огромная боль, а GC вообще необузданный монстр. Каждый раз при попытке хоть как-то контролировать освобождение памяти вылазит просто уйма нюансов и костылей в виде ObjectPooling. А здесь даже мимикрия под настоящий класс, ну просто красота.
Почему
objectHeader
находится по адресу -8? Зачем так сделано? Хз. Даже майкрософт говорили, что просто так исторически сложилось и никакого скрытого смысла здесь нет.Смысл здесь логический, так как классы управляемые, objectHeader - это как раз обёртка, а по указателю что должно лежать? правильно мякотка, и никому кроме обработчика в частных случаях обёртка не нужна. Да и регулярно добавлять +8 к любому обращению к классу, который в жизни обработкой заниматься не будет - такое себе. В то время как та же таблица методов уже является частью реализации и жертвой постоянных обращений от всех владельцев ссылки.
maksim_sitnikov
26.04.2024 10:58Кайф, импорт системных библиотек, инвоки в ассортименте, всякий треш в сторонних либах, etc.
DrewUnknown
статью не стал читать. когда не увидел в начале объяснение. а зачем это делать?
Rietmon Автор
Для общего развития, чтобы понимать, с чем ты работаешь, хотя бы на один уровень ниже.
Какое продакшен-применение можно найти для статьи, которая в самом начале говорит "давайте нарушим правила"?
badryuner
Ну например, чтобы не тратить много наносекунд на аллокацию object[] для использования в MethodInfo.Invoke().
https://gist.github.com/BadRyuner/602a94b1f3b4e054258dbec165f24d68
Prikalel
например если ты разрабатываешь игру на юньке или Годот с использованием с# и хочешь получить лишние кадры в секунду