Эта статья скорее всего будет полезна тем, кто продолжает разбираться или работать на платформах .NET . Предупреждаю! Здесь будет далекое плавание в разные места, и с точки ОС будет упомянуто достаточно много!
На просторах Хабра есть несколько хороших статей про устройство и поведение .NET. Мне хотелось бы стать "чем-то по связующим" между ними.
Содержание
Введение
1 Обзор: Как схематично устроен .NET?
1.1 Фреймворки .NET (
FX)1.2 Среда общего назначения
CLR
2 .NET Проект после компиляции. Небольшое плавание внутрь проекта.
2.1 Microsoft
IL2.2
CLRметаданные2.3 Немного о сбощике мусора
2.4
P/Invoke(импорт в рантайм)2.5 Подпись сильным именем
3 Как можно собрать проект?
3.1
FDD3.2
SCD
4 CLR и небезопасный контекст
4.1 Маршаллинг и
unsafe-контекст4.2 Внутренние вызовы (
InternalCalls)
5 Запуск собранного проекта
5.1
dotnet.exe5.2
hostfxr.dll+hostpolicy.dll5.3
coreclr.dll5.4 Сборщик мусора CLR в действии
Итоги
Введение
Обычно под .NET ��а момент написания статьи по умолчанию считают платформу .NET Core, которая основывается на .NET Framework и располагает возможностью написания кроссплатформенных приложений. Про нее в основном и пойдет речь.
1. База | Как (схематично) устроен .NET Core
Поскольку погружение только начинается, для простоты
.NET Core можно с легкостью разделить логически на две большие части:
librariesилиCore FX- (для наглядности: Frameworks=>FrameworX=>FX)runtimesилиCore CLR- Common Language Runtime
FX или CoreFX это модули, подключаемые к проекту (System.Generic.Collections, System.Text.Json, и другие). Ранее для .NET Framework это называлось FCL (сокр. "Foundation Classes Library").
CLR или CoreCLR это комплекс различных инструментов, ответственных за выполнение и поддержку жизненного цикла приложения. Во времена .NET Framework это называлось идентично (тоже CLR). Об этом в следующих разделах.
Чтобы более детально разобраться в работе, придется как-то по-скорее перейти к от самой платформы скорее к проектам/приложениям, работающим на ней.
1.1 Core Frameworks (Core FX)
Сама по себе платформа .NET максимально модульна. Это видно из того, что все ветвления System
(имеется ввиду System.IO, System.Text, System.Management и другие подразделы) в файловой системе
представляются в виде отдельных объектов/библиотек, и, что немало важно - NuGet пакетов.
Стало вполне реально обновить одну библиотеку, не обновляя весь набор инструментов за ним.
Так же вполне реально ненужные модули вовсе убрать из зависимостей, и не тянуть за собой львиную долю неиспользуемых библиотек.
Субъективно
Сколько я знаю, все компоненты FCL .NET Framework поставляются вместе с .NET Framework, и обновить одну
из FCL библиотек просто так нельзя. Надо обновлять версию набора инструментов.
Но можно попытаться скачать NuGet пакет, если такой будет доступен для целевой платформы.
1.2 Общее средство выполнения языков (Core CLR)
CLR или Common Language Runtime - это среда выполнения приложений, которая состоит из:
GCСборщика Мусора (сокр. "Garbage Collector");JITКомпиляции в реальном времени (сокр. "Just-In-Time compilation");Native AOT (.NET 7.0+)- Компиляции "заранее" (сокр. "Native Ahead of Time compilation");
Хочется обратить внимание на то, что CLR это концептуально виртуальная машина, в отличае от JVM (сокр. "Java Virtual Machine"). Правильно всегда говорить, что это среда выполнения.
Сборщик мусора или Garbage Collector управляет выделением и освобождением памяти для приложения.
Каждый раз, когда создается новый объект, Core CLR выделяет память для этого объекта из управляемой кучи.
Пока адресное пространство доступно в управляемой куче, среда выполнения продолжает выделять пространство для новых объектов. Поскольку память не является бесконечной, сборщик мусора должен выполнить сбор, чтобы освободить некоторую память. Модуль оптимизации сборщика мусора определяет оптимальное время для проведения уборки, исходя из выделенных ресурсов.
Сборщиком мусора можно управлять, используя явные деструкторы классов (Finalizers), статический класс
System.GC.
Компиляция налету: JIT или AOT в понимании .NET Core это механизм перевода промежуточного кода
в платформозависимый (ассемблер), затем его выполнение.
2. Сборка
То, что было разделами выше - это небольшой экскурс...
Теперь пришло время очень серьезно поговорить о самой проектной единице.
То есть о том, что получится в итоге после компиляции.
2.1 Мелочи жизни Core CLR | Microsoft IL
Я полагаю, что в мире .NET лучше все-таки говорить терминами .NET. Поэтому первый термин, который
активно здесь будет использован - Сборка.
Сборка (англ. "Assembly") или (dotnet.
System.Reflection.Assembly) - это скомпилированный код
проекта, который представляет собой динамическую библиотеку.DLL. Это самая маленькая часть
любого проекта на .NET.
Как было написано ранее, сборка содержит в себе байткод и метаданные. Байткод, который используется
вместо нативного называется разными именами. Самое старое из них - Microsoft IL (сокр. "Intermediate Language"). Со временем появились вариации CIL (сокр. "Common Intermediate Language") или просто IL.
Хранится в managed-секциях (.text или .managed). То есть отмеченных правом на чтение+выполнение.
Заранее обращу внимание. Для демонстрации и объяснений я пользуюсь Roslyn:
А Roslyn очень умный и хитрый компилятор.
Он допускает три интерпретации (диз)ассемблированного
кода
IL
Низкоуровневый - Остаются все синтетические методы/переменные,
$Типы, код существует как был в IL с незначительными изменениями/адаптациями под язык;Высокоуровненвый - Доля методов компилятора исчезает, (заметьте, что не все). Возвращается синтаксический сахар.
Теперь к низкоуровневым баранам: MS-IL выглядит следующим образом:
.class public abstract sealed auto ansi Filter.Program extends [System.Runtime]System.Object { .custom instance void [FSharp.Core]Microsoft.FSharp.Core.CompilationMappingAttribute::.ctor(valuetype [FSharp.Core]Microsoft.FSharp.Core.SourceConstructFlags) = (01 00 07 00 00 00 00 00 ) // ........ // int32(7) // 0x00000007 .class nested assembly sealed auto ansi serializable beforefieldinit o1@46 extends class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<float64, float64> { .field assembly static initonly class Filter.Program/o1@46 @_instance .method public strict virtual instance float64 Invoke( float64 omega ) cil managed { .maxstack 8 IL_0000: ldarg.1 // omega IL_0001: call float64 Filter.Program::k(float64) IL_0006: ret } // end of method o1@46::Invoke } }
На самом деле, MS-IL (или CIL, IL) легко переводится назад в более привычные языки программирования.
Просто при дизассемблировании сборки, возникают синтетические методы и переменные, которые вставляются на этапе компиляции. Можно сказать в этот момент компилятор дописывает часть кода за разработчика, чтобы явно определить поведение объектов.
Минимум, который компилятор точно переписывает это:
Конструкторы классов;
Абсолютно все лямбда-выражения (превращаются в настоящие функции);
Явные методы свойств вместо
get/init/set;Использование
IDisposable-производных классов (usingконструкции);
Для дизассемблирования сборок есть инструменты: Microsoft ILDASM, DnSpy, DotPeek и сам Roslyn, если его хорошо попросить.
Лямбды функции (конструкции вида () => { }) переводятся в выражения вида '<>c'::'<>9__15_1'.
Это не кошка села за ноутбук. Это обращение к члену синтетического класса 'c методу 9__15_1.
Дизассемблер не анонимен.
Конструкторы классов Roslyn записывает как ..ctor содержащие методы. Пример:
// Всеми знакомый статический класс App у оконных приложений, public App : Application { /* ... */ } // После компиляции иногда имеет при себе несколько конструкторов разного уровня доступа // Roslyn подсказал мне, что будет как минимум такой: public App() => base..ctor(); // base..ctor() просит инициализироваться Application, который в свою очередь // тоже превратится нечто подобное
Свойства и в особенности автоматические свойства тоже резко изменяются во время компиляции.
По правилам, возможные права превращаются в get_Имя и set_Имя полноценные функции.
То есть:
// Мной выдуманное Автосвойство Count в высокоуровневом C# выглядит так: public long Count { get; set; } // Roslyn же видит это свойство куда глубже. [Nullable(2)] public static long Count { [NullableContext(2)] [CompilerGenerated] get { return MyList.<Count>k__BackingField; } [NullableContext(2)] [ComplilerGenerated] set { MyList.<Count>k__BackingField = value; } }
Мало того, сам _backingField - это что-то от Roslyn. (я полагаю так). Он сам помогает и дописывает на более высокоуровневый язык.
Если посмотреть в IL, то нет никакого _backingField уже. Зато появляются те самые get_Count и set_Count.
.property string Count() { .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(unsigned int8) = (01 00 02 00 00 ) // ..... // unsigned int8(2) // 0x02 .get string TomLauncher.MyList::get_Count() .set void TomLauncher.MyList::set_Count(long) } // end of property App::Count
А теперь ещё держите в уме, что это лишь часть того, что делает компилятор.
Формирование логики высвобождения ресурсов я показывать не буду.
И без неё предстоит зверская работа по прочтению этого лонгпоста.
К сожалению пришло время углубиться еще дальше:
2.2 Мелочи жизни CLR | Метаданные
Платформа .NET (любая) очень богата в рефлекию. Я бы сказал ОЧЕНЬ богата.
Рефлексия в .NET это волшебный инструмент, который невероятно необходим всем инструментам и языкам.
Пора объяснить что же это:
Рефлексия (англ. "Reflection") - это механизм, который позволяет исследовать и манипулировать типами, методами, свойствами и другими элементами программы во время её исполнения.
Другими словами, рефлексия это механизм который помогает заглянуть внутрь "чего угодно"
и узнать структуру этого "чего-угодно".
Откуда же такие глубочайшие знания? А главное, где их найти?
Самое время об этом написать.
Метаданные - это данные в двоичном формате с описанием программы, хранящиеся либо в PE слинкованном файле CLR, либо в памяти.
При компиляции кода в PE-файл метаданные вставляются в одну часть файла, и код преобразуется в CIL и вставляется в другую часть файла.
В метаданных описываются все типы и члены, определенные или используемые в модуле или сборке.
Это было кратко. Теперь ПОДРОБНЕЕ.
У любого исполняемого файла Windows есть физическое разделение, зафиксированное в таблице секций (IMAGE_SECTION_TABLE) и логическое представление этого файла - таблица директорий.
Обычно принято считать, что она принадлежит опциональному заголовку (IMAGE_OPTIONAL_HEADER(32)).
И у каждой .NET сборки в таблице директорий заполняется директория #15 - LOAD_COM_DESCRIPTOR.
Виртуальный адрес директории LOAD_COM_DESCRIPTOR указывает на расположение главной структуры CLR в памяти процесса.
Главная структура CLR называется IMAGE_COR20_HEADER, (System.Reflection.CorHeader), или CLR заголовок.
Именно она является путеводным камнем в MS-IL метаданные.
Выглядит CLR заголовок таким образом:
public struct Cor20Header { public uint SizeOfHead; // Всегда равен 72 (0x48) public ushort MajorRuntimeVersion; public ushort MinorRuntimeVersion; public PeDirectory Metadata; // * public uint LinkerFlags; public uint EntryPointRva; public uint EntryPointToken; public PeDirectory Resources; public PeDirectory StrongName; public PeDirectory CodeManager; public PeDirectory VTDirectory; public PeDirectory Exports; public PeDirectory ManagedNativeHeader; }
Держим интуитивно, что PeDirectory это IMAGE_DATA_DIRECTORY. То есть буквально упакованный кортеж из RVA и размера директории. А RVA это виртуальный адрес, не зависящий от базы загружаемого образа (программы/библиотеки).
Директория Metadata содержит виртуальный адрес на таблицы потоков, и
наконец-то, сами метаданные. У .NET метаданные организовываются в 5 потоков.
#~- поток метаданных, содержит типы, классы, методы;#String- имена (пространства имен) типов данных, объектов;#US(сокр. "User Strings Heap") - все строки используемые в коде;#Blob- содержит чистые двоичные данные;#GUID- поток с GUID сборки;
Поверхностно разобрались. Правда все-же вынужден сообщить,
что основной поток #~, который чаще всего встречается при анализе - на самом деле сжатый поток.
Есть его несжатая версия, записываемая в таблице потоков как #-. Но это вряд-ли нужно продолжающим инженерам.
2.3 Управление памятью от CLR | Сборщик мусора
Вообще, сборщик мусора это очень нужный и очень важный пункт.
И к сожалению о нем я расскажу не так много.
Сборщик мусора любит все классифицировать. В его понимании, распологающиеся
в куче (полн. managed heap, это важно!), объекты живут свое время.
Поэтому объекты в управляемой куче распределяются на поколения
Молодое поколение (Gen 0);
Поколение постарше (Gen 1);
Старшее поколение (Gen 2);
Очень большие объекты
LOH(англ. Long-objects);
И все. Их только четыре. Больше не нужно.
Когда заканчивается место управляемой кучи, сборщик мусора решает, что пора
пройтись и разобраться, какие объекты уже давно не нужны. Объекты, которые
не используются - умирают, а все-ещё используемые попадают в следующее поколение (0 -> 1).
В большинстве случаев, сборщик проверяет первое поколение и области памяти в которых могут быть ссылки на первое поколение. Но я не смогу уместить здесь подробности,
как это работает.
Помните ли вы, что у объектов есть финализаторы? Они сейчас крайне необходимы!
Создавая класс, вы для своего удобства создаете конструктор класса, и среда CLR на вас не ругается. Не ругается она потому, если даже вы забыли написать финал (деструктор) объекта, заранее его уже знает.
// Попросим Roslyn, чтобы он показал происходящее под капотом // А он ничего не покажет, даже в IL. Потому что компилятор это не дописывает .class public auto ansi beforefieldinit SunFlower.Windows.App extends [PresentationFramework]System.Windows.Application { .field private bool _contentLoaded .method public hidebysig instance void InitializeComponent() cil managed { // ... // Внутренности WPF вам не нужны } // end of method App::InitializeComponent .method public hidebysig static void Main() cil managed { .entrypoint // ... // Как запускается WPF приложение, вам тоже не нужно } // end of method App::Main .method public hidebysig specialname rtspecialname instance void .ctor() cil managed { .maxstack 8 IL_0000: ldarg.0 // this IL_0001: call instance void [PresentationFramework]System.Windows.Application::.ctor() IL_0006: nop IL_0007: ret } // end of method App::.ctor } // end of class SunFlower.Windows.App
Почему-то я кучу назвал управляемой. Почему же? В разделе 4, будет речь
о низкоуровневых возможностях среды CLR, и будет очень обсуждаться маршаллинг.
Все, чем орудует CLR во время проекта, связанное с неуправляемой средой,
помещается в неуправляемую кучку (англ. unmanaged heap). А именно:
Ресурсы, выделяемые вне полномочий CLR (
Marshal.AllocHGlobal);Дескрипторы (спец. указатели);
Когда вы используете IDisposable-производные,
они не находятся в неуправляемой куче. Тем не менее, вы вынуждены явно указывать
область жизни таких классов.
// Вспомним, что за долго до .NET Core и C# 8.0 это выглядело так: using(var stream = new FileStream()) { // Здесь stream видно, и только в этой области он имеет право на жизнь } // будет удален сразу же, по завершению using (вызывается base.Dispose)
А ещё, вы имеете право управлять сборщиком мусора.
Платформа .NET дает вам право на такое деяние. Поэтому существует класс System.GC
Очень жаль, что это я тоже не смогу уместить здесь.
2.4 Чудеса CLR | P/Invoke
Многие знают, что .NET позволяет вызывать сторонний функционал не только
из сборок .NET, но и безопасно вызвать на-лету стороннюю не .NET библиотеку.
С точки зрения высокоуровневого языка (например C#), это происходит за счет наложения
на внешнюю функцию DllImportAttribute.
[DllImport("ntdll.dll")] public static extern IntPtr RtlSetProcessCritical(uint v1, uint v2, uint v3);
С точки зрения CLR происходит это за счет вызова Win32 API, если целевая платформа
приложения - Windows и установлен соответствующий набор инструментов.
Интуитивно наверное в голове рисуются LoadLibraryW, GetProcAddress и другие
функции, используемые отладчиками.
С точки зрения полученного файла сборки, который будет запущен или через dotnet exec
или через удобно созданный .EXE файл-загрузчик, ничего не происходит.
Хотя, если вы знакомы с импортами в рантайме, скорее всего могли бы держать в голове мысль "А где упоминание функции в таблицах импорта?"
И ведь действительно... Почему у такой сложной операции, упоминания о ней нет нигде в файле?
Хотелось бы верить, что все-же упоминания есть. И это на самом деле так.
При компиляции в IL, вместе со всеми функциями переносятся все их аттрибуты. Атрибуты в некоторых кругах принято называть метаданными типов, не смотря на то, что метаданные, которые вы привыкли видеть, вы получаете используя функционал компилятора: typeof(T) или .GetType()
При выполнении программы, все атрибуты типов/модулей/методов и остальной природы,
CoreCLR их обязательно учитывает и разбирается с требованиями DllImportAttribute соответствующим образом.
Нет прсто необходимости вешать на ОС таблицы импортов и TLS, которые загрузчик будет пытаться разрешать и выполнять. С этим запросто справляется и CLR.
2.5 Безопасность? | .snk-ключи
Очень интересный раздел, который интересовал меня чуть-ли не с начал, когда я только
знакомился с C# 4.0 по книге.
Помните ли вы структуру заголовка CLR? Там было упоминание некого StrongName.
Наконец-то речь дошла и до него. Я считаю это важной деталью, так как вы сами знаете:
дизассемблировать неподготовленную заранее сборку крайне просто.
Причем, есть ещё один риск, связанный с этим.
Дизассемблированную сборку очень просто
собрать назад, поскольку вы не трогаете и не будете трогать синтетические методы/типы.
Для защиты своего горячо-любимого продукта прибегают к разным мерам, если есть повод скрывать детали реализации. Даже если их нет.
Одна из мер защиты - это применение сильного имени. (или сильного ключа... или строгого ключа).
Суть применения сильного ключа заключается в том, что до компиляции, вшитый ключ гарантирует иммутабельность сборки (то есть невозможность изменить уже скомилированный код).
Строгое имя состоит из
простого текстового имени;
номера версии и информации о культуре (если она предоставлена);
открытого ключа и цифровой подписи;
D:\dotnet\...> ./sn.exe -k <key_file_name>
И вы получаете готовый .snk, который после вы руками через IDE/свойства сборки привяжете.
Поскольку закрытая часть ключа ни в коем случае не должна упомянуться в манифесте,
сборка происходит несколько сложнее. Когда нужно проверить целостность сборки со строгим именем, создают хэш содержимого сборки и используют открытый ключ из сборки для расшифровки хэша, поставляемого со сборкой.
Если два хэша совпадают, проверка сборки проходит.
Если сборка была изменена, эти значения будут другими, и пользователь сборки будет знать, что имеющаяся сборка не является той, которую предоставили.
В процессе линковки, когда будет формироваться заголовок CLR и таблица директорий,
факт наличия .snk ключа будет обязательно упомянут. LinkerFlags будет хранить значение 2 (HAS_STRONG_NAME).
Внимание. Я ставлю вопрос в разделе не спроста. Технически, использование сильного имени
не гарантирует безопасность как таковую, а лишь дает надежды, что ваш продукт
не изменится после пересборки третьими лицами.
3 Свобода сбора | Кто такие SCD и FDD?
Тяжелый разговор из второго раздела подошел к концу. Но это было нужно.
Теперь вернувшись в мир проводника и самого проекта, пришло время упомянуть то, о чем я, например, нигде не слышал и ни в какой книге не видел... А тут внезапно узнал недавно.
Мало того, что .NET умеет выполнять ваш код налету, давать управление памятью вам прямо в коде самой сборки, управлять этой памятью сам, ведать глубокие знания о каждом типе вашей сборки.
Платформа дает вам выбор, будет ли ваш продукт зависим от установленного в системе SDK или рантайма или будет самостоятельным?
Крайне советую к прочтению хотябы эту статью
3.1 Frameworks Dependent Deployment | FDD
И так, первый и самый распространенный в мире начинающих/продолжающих разработчиков
вариант сборки проекта - зависимый.
Я не буду углубляться в его смысл, но исходя из названия, он уже имеет ряд преимуществ.
Ваша папка вывода проекта (.../bin/Release) будет
Небольшой по размеру
Небольшой по количеству объектов файловой системы
В папке такого приложения обязательно лежит файл <имя сборки>.runtimeconfig.json. В нём указаны имя и версия фреймворка, которые должны быть использованы для выполнения переносного приложения. Например:
{ "runtimeOptions": { "tfm": "net6.0", "frameworks": [ { "name": "Microsoft.NETCore.App", "version": "6.0.0" }, { "name": "Microsoft.WindowsDesktop.App", "version": "6.0.0" } ] } }
Я взял этот пример из своего WPF приложения, поэтому в фреймворках вы видите
не только сам .net6.0, но и windows7.0-net6.0.
Осторожно, опасно
На моем устройстве установлен .NET 8, и
приложение все равно будет требовать вас установить рантайм для .NET 6.0.
Но вам достаточно открыть этот файл конфигурации, изменить версии! Ошибки выполнения
больше не будет.
Все действия будут на ваш страх риск. Поэтому меняя манифест, я руководствовался правилом совместимости. (Все, что запускалось на .NET 6.0 - запустится на старших версиях). Но есть обратная сторона медали:
3.2 Self-Containing Development | SCD
В протипопоставление предыдущему варианту сборки, существует самостоятельный способ.
Его преимущества состоят в том, что вместе со всем вашим приложением будет перенесён
и сам рантайм. Исполняемый dotnet.exe будет переименован в имя_сборки.exe.
Преимуществ у данного типа приложений куда больше, чем можно подумать.
Они освящаются в другой статье, которую я крайне рекомендую прочесть. (+ ссылки ниже)
4. Мощь CLR | Не/Безопасный контекст
Не смотря на то, что .NET это платформа, которая по-умолчанию отгораживает вас
от системного программирования и низкоуровневых познаний, все-же
системное программирование здесь присутствует.
4.1 Мы и такое производим? | unsafe и маршаллинг
Думаю, мало для кого секрет, что компилятор может работать с проектами, содержащими небезопасный код. Мне очень хочется сказать "плохо управляемый" код, так как все
небезопасные возможности высокоуровневых языков .NET ложат ответственность за происходящее строго на разработчика. Приходится биться за жизнь ресурсов как за свою. Да и указатели C# работают как СИшные.
Напомню, что я хочу писать для продолжающих .NET разработчиков.
Возможно никогда не имевших дело с увесистыми проектами на системных языках в виде C/++.
А в больших проектах с многопоточностью, накладывается и страх создать лишние блокировки из-за заимствований или разыменований.
Тем не менее, пример небезопасного кода куда ближе чем вы думаете.
Он вас окружает, и вы его иногда вживую видите, пусть и краем глаза, не обращая
на это особого внимания.
[CLSCompliant(false)] [NullableContext(0)] [DynamicDependency("Ctor(System.Char*)")] [MethodImpl(MethodImplOptions.InternalCall)] // * public unsafe extern String(char* value); private unsafe string Ctor(char* ptr) { if (ptr == null) { return string.Empty; } int num = string.wcslen(ptr); if (num == 0) { return string.Empty; } string text = string.FastAllocateString(num); UIntPtr elementCount = (UIntPtr)text.Length; Buffer.Memmove<char>(ref text._firstChar, ref *ptr, elementCount); return text; }
Это реализация одного из конструкторов примитива string. И это не низкоуровневый C#.
Второй момент, на который хочется обратить внимание это маршаллинг.
Маршаллинг (или Маршаллирование) - это процесс преобразования типов, когда они должны пересекать управляемый и машинный код.
Сейчас здесь рассматриваются довольно тяжелые вещи для понимания, на самом деле.
Без примера сложно обойтись в таких случаях.
Поэтому для удобства, я дальше и в следующих разделах буду приводить различные примеры.
Положим вы работаете с каким-то двоичным форматом данных, который вам
ужасно необходим. Вы хотите получить результат в структуру данных.
Вы вполне себе можете вызвать поток FileStream или MemoryStream, взависимости
откуда вы хотите этот файл достать.
Используя маршаллинг и сборщик мусора, можно договориться и написать логику
реинтерпретации байт в нужный тип данных.
private static T Fill<T>(BinaryReader reader) where T : struct { var bytes = reader.ReadBytes(Marshal.SizeOf(typeof(T))); var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); var result = Marshal.PtrToStructure<T>(handle.AddrOfPinnedObject()); handle.Free(); return result; } using var stream = new FileStream("card.bin", FileMode.Read); using var reader = new BinaryReader(stream); var header = Fill<Card>(reader); // После чтения, указатель стрима сдвинулся на <Marshal.SizeOf<Card>>
Здесь нет как такового небезопасного кода, но сам по себе этот участок кода может быть
опасен. Опасен тем, что структура, которую вы хотите передать может быть выровнена или иметь внезаные заполнения, которые вы врядли ожидаете.
Что со стороны вашего кода, тип struct который вы объявили может иметь выравнивание,
что сама структура данных со стороны внешнего файла может иметь сюрпризы.
Вы должны быть уверены в своих намерениях.
Цель примера была показать, что вы можете распоряжаться неуправляемыми ресурсами
внутри типобезопасной среды. Правда делать это с лишней осторожностью.
4.2 Internal Calls | тоже unsafe?
Очень неожиданная и низкоуровневая тема, которую думаю стоит упомянуть
для продолжающих разработчиков, это внутренние вызовы.
Когда среда выполнения обнаруживает метод с InternalCall отметкой, она ищет имя функции в своей базе данных пар, созданной с помощью кода на C или C++, и вызывает адрес функции
Это тоже довольно тяжелый разговор, применения которому сразу не видно.
Вы же сами читаете и думаете: "Зачем же нужны эти InternalCalls?!"
Так, что опять приведу бытовой пример...
Давайте, ответим себе на такой вопрос: "Почему sizeof() небезопасен?"
4.2.1 sizeof и Marshal.SizeOf
Думаете это просто? Тогда я попробую ответить.
Оператор
sizeof()возвращает число байт, выделяемых средойCLRв управляемой памяти.
Использование sizeof для примитивных типов значений не требует unsafe.
Это правило появилось в .NET Core 2.0, и до сих пор действует.
Как вы знаете, маршаллинг механизм работы с неуправляемой средой.
Или оттуда получают данные, или наоборот передают туда данные.
По своей сути, самый частый повод использования размеров типов - это
танцы с бубном и неуправляемой средой. Поэтому для гарантии того, "Во что же превратится структура?", используют не размер выделяемый CLR, а размер за пределами CLR. Поэтому используют Marshal.SizeOf
Теперь время посмотреть на нашу жертву.
public static int SizeOf<[Nullable(2)] T>(T structure) { if (structure == null) { throw new ArgumentNullException(nameof(structure)); } return Marshal.SizeOfHelper(structure.GetType(), true); }
Как вы видите, ничего он не делает. А всю работу на себя берет некий Marshal.SizeOfHelper. А он где? А он стоит рядышком, и выглядит таким образом
[MethodImpl(MethodImplOptions.InternalCall)] internal static extern int SizeOfHelper(Type t, bool throwIfNotMarshalable);
И реализацию SizeOfHelper в самих декомпилированных файлах не найти. Это функция runtime, а не frameworks.
Вот вам и пример использования внутренних вызовов! Правда, выглядит он все-равно плохо.
Отбиваю два вопроса:
Пользуются
InternalCalls когда нужен функционал именно CLR среды;sizeofс размерами выделяемыми средой CLR, в основном применяются для арифметики указателей, поэтому и требуетсяunsafe-контекст;
5. Проект во время выполнения
Теперь время поговорить о том, что происходит во время выполнения сборки.
Сквозь разделы мы прошли через
Набор инструментов
Внутренности сборки
Возможности сборки
Среду выполнения
И подошли к первому запуску проекта. Сейчас мы стоим на стыке двух миров, на самом деле.
Зная то, что .NET открывает возможность кроссплатформенности, типобезопасности,
асинхронного API, и ещё много чего, стоит согласиться, что запуск .NET приложения
это довольно трудно.
5.1 dotnet.exe | Начало начал
Сам по себе dotnet.exe это консольная утилита, которая выполняет достаточно
приличный сегмент задач. С его помощью вы можете не только запускать проекты,
но и
Создавать проекты;
Скачивать/настраивать шаблоны;
Управлять NuGet пакетами;
Восстанавливать сборку;
Собирать сборку;
Пытаться в диагностику сборки;
Список сумасшедший, и это только часть того, что умеет эта утилита.
Для запуска сборки, dotnet существует только в роли владельца процесса.
В ОС Windows это называется ...host.
Когда вы запускаете командную строку Windows, ОС создает владельца сессии,
который связывается с драйвером консоли. Поэтому в диспетчере задач вы видите дерево:
Коммандная строка --> conhost.exe
Будем честны, это уже сложно. Чего говорить про .NET.
Сам dotnet ничего со сборкой не делает. Он передает управление дальше.
5.2 hostfxr.dll/hostpolicy.dll | Режим и поиск рантайма
Пришло время вспомнить про способы сборки приложения. Их два вида
Зависимый от рантайма (FDD)
Самостоятельный (SCD)
Внутри они различаются, а не только содержимым папки. За распознание отличий и дальнейшую логику отвечает hostfxr.dll. Это сокращение от Host Frameworks Resolver,
потому что у FDD приложений привязка к установленному .NET глобально на вашей машине,
а у самостоятельных SCD приложений он свой и со своими нюансами.
После того, как разрешитель фреймворков (их много, поэтому что FX сокращение не с проста взялось), пора бы разобраться с зависимостями и настройками среды.
За это в ответе другая динамическая библиотека: hostpolicy.dll.
Переводить с английского на английский название не буду, здесь всё более-менее прозрачно.
Важный момент. Когда hostpolicy.dll проделывает этот этап работы, наступает неожиданно
второй этап. Используя Win32 API, она вызывает coreclr.dll. (Через типичный вызов LoadLibraryA). И по названию этой библиотеки вы сами знаете, кого на самом деле вызывают.
5.3 Определяемся с типами компиляции
Когда-то в начале этой статьи я очень грязно и неопрятно поставил JIT компиляцию
и AOT рядом, без всяких объяснений. Это плохо. Поэтому исправляюсь.
Когда вы собираете приложение, у вас нет как такового выбора каким образом
оно будет компилироваться. Это часть технологии создания приложения.
До .NET 7.0 всю жизнь компиляция была Just-in-Time.
Для конечного пользователя это ничего не означало, а для поведения процесса
это означало то, что при вызове конструктора какого-либо класса, он переписывался с IL на ассемблер буквально на глазах у ОС. Вызов какого-либо виртуального метода
означал ещё один проход переписывания IL в ассемблер. Попытка дёрнуть MemoryStream, например, обозначала переписывание IL в ассемблер этого MemoryStream, а ведь MemoryStream тоже что-то заимствует, ведь так?
С выходом .NET 7.0 подход изменился. Появилось понятие R2R и Native AOT.
Native AOT - это полная компиляция в нативный код без JIT, а R2R (полн. "Ready To Run") - это предварительная компиляция.
5.4 coreclr.dll | CLR идёт разбираться
Наконец-то запускается ядро CLR среды. Подготавливаются домены и загружаются зависимости.
Удивительно, что CLR это и сборщик мусора, и переводчик кода сборки, и сама среда
обитания безопасных типов данных, и целая библиотека функций, которые у неё можно
позаимствовать в проект.
С запуском среды, создается AppDomain, если говорить про старшие версии.
Это место похоже на многоквартирный дом. В его квартиры загружаются фреймворки,
подготавливаются зависимости проекта и тоже заполняют квартиры, и наконец-то
заезжает в квартиру наша сборка.
Вызывается импортируемая процедура __CorExeMain.
И как только сборка поместилась, и начинают работать дальшейшие инструменты:
Переводчик в ассемблер;
Сборщик мусора;
5.5 coreclr.dll | Полномочия сборщика мусора
Обойти сборщик мусора нельзя. В разделе 2.3 было небольшое разделение, на то, где GC
велик и могуч, и где заканчивается его вмешательство.
Вспомните этот раздел чуть-чуть. То, что имеется ввиду под объектами здесь -
это ссылочные типы данных.
Проведите аналогию с C++. С помощью оператора new в куче для хранения объекта CLR выделяет участок памяти. А в стек добавляет адрес на этот участок памяти.
После того, как объект отработает, место в стеке очищается, а сборщик мусора очищает ранее выделенный под хранение объекта участок памяти.
Объекты считаются огромными (хранятся в LOH), если их размер 85+ килобайт.
А если объект не огромный, то все происходит по дальнейшему сценарию.
При нехватке памяти в управляемой куче, сборщик мусора
Сканирует всё, что есть (размечает по поколениям);
Убирает объекты 0-го поколения;
Объекты, которые ещё используются становятся объектами 1 поколения. (
0 -> 1);Дефрагментирует память. (разбросанные по кучи объекты выстраиваются в один блок памяти);
Если еще необходима дополнительная память, то сборщик мусора приступает к объектам из поколения 1. Сверяет, не используются ли объекты 1 поколения и нет ли случаем ссылок на эти объекты 1 поколения.
Те объекты, на которые уже нет ссылок, уничтожаются. Используемые становятся 2ым поколением (1 -> 2).
Помните про дефрагментацию памяти? По-хорошему это обозначает то, что адреса на объекты
должны измениться. Поэтому не переживайте, после того, как сборка мусора прошла, происходит обновление ссылок, чтобы они правильно указывали на новые адреса объектов.
Процесс раздачи поколений (англ. "Marking") происходит за счет таблицы Card Table.
Именно там сборщик мусора держит свои метаданные (помнит кто к чему относится).
Сборщик мусора это компонент CLR. А теперь ответьте на вопрос?
А почему тогда возможно обратиться к тему из проекта? (использовать System.GC).
Все благодаря возможности использовать InternalCalls. Весь класс сборщика мусора,
поставляемый с фреймворком .NET, использует вызовы среды CLR, и состоит из двух частей
Внутренних вызовов;
Оберток над вызовами;
Все, что вы видите в System.GC это обертка над вызовами. Передать данные в неуправляемую область без лишних обработок вам никто не даст!
Поэтому вы можете
AddMemoryPressure- предупредить CLR о большом объеме неуправляемой памятиRemoveMemoryPressure- успокоить CLRCollect- постучаться сборщику мусора, чтобы он сейчас же все проверил и убралGetGeneration- узнать поколение объекта изCardTableWaitForPendingFinalizers- остановить поток и ждать выполнение деструкторов всех объектов, для которых будет произведена сборка мусора.
Итоги
Я рассказал все что мог. Для продолжающих копаться в .NET, полагаю будет очень неплохо
всячески столкнуться тем, что было.
Конечно же в рамках ознакомления и тестирования своими руками.
Платформа .NET, как говорили на конференциях Microsoft: "стала платформой на которой можно сделать всё что угодно". И это потрясающий опыт.
Весь материал я писал сам, и передал сюда то, что я знал.
Но чтобы никого не обмануть, я перепроверял себя и закономерно освежил знания.
Прилагаю ссылки ниже
