Перейти к содержимому

Платформа .NET

Из чего состоит платформа .NET и как код на C# становится работающей программой: версии и их поддержка, CLR и JIT, сборки и пакеты, способы компиляции. Это фундамент для всех остальных тем раздела.

Junior+

Чем .NET Framework отличается от .NET Core и современного .NET?

Заголовок раздела «Чем .NET Framework отличается от .NET Core и современного .NET?»
Ответ

Что это

  • .NET Framework — исходная платформа .NET (с 2002 года) только для Windows. Последняя версия, 4.8.1, вышла в 2022 году.
  • .NET Core — кроссплатформенная платформа с открытым кодом (с 2016 года). С версии 5 (2020) она называется просто .NET.

Чем отличаются

.NET Framework .NET 5+
ОС только Windows Windows, Linux, macOS
Версии на машине одна 4.x, часть ОС несколько рядом
Развитие только исправления новая версия каждый ноябрь

Частая ошибка

Считать .NET 8 новой версией .NET Framework. Это разные продукты: номер 4 пропустили, чтобы их не путали, а слово Core убрали, потому что это теперь основная ветка.

Как устроено

  • Установка. Framework — общий компонент Windows: обновление ОС меняет поведение сразу всех приложений. Приложение на .NET привязано к своей major-версии (net8.0, net10.0), а self-contained-публикация несёт рантайм с собой.
  • Проекты. SDK-style .csproj, dotnet CLI и PackageReference. У Framework исторически многословные проекты и packages.config.
  • Конфигурация и веб. Вместо web.config — appsettings.json, вместо HttpModule — middleware ASP.NET Core.

Чего нет в .NET

Технология Framework Замена в .NET
Дополнительные AppDomain Процессы и контейнеры; для плагинов — AssemblyLoadContext
.NET Remoting gRPC, HTTP API
Code Access Security Границы ОС: контейнеры, учётные записи
ASP.NET Web Forms ASP.NET Core: Razor Pages, Blazor
Windows Workflow Foundation Встроенной нет, есть сторонний CoreWF

Ловушки

  • WinForms и WPF в .NET есть, но работают только на Windows (целевая платформа вида net10.0-windows).
  • Удалённые технологии часто всплывают не в своём коде, а глубоко в зависимостях.

Правило

Новый код — только на современном .NET. Framework оставляют легаси, которое держится на Web Forms, WCF-сервере или WF и которое дороже переписать, чем поддерживать.

Под капотом

  • Framework обновляется на месте: 4.8 заменяет 4.7.2 для всех приложений на машине. Любое изменение поведения рискует сломать чужой код, поэтому развитие упёрлось в совместимость.
  • Современный .NET ставится side-by-side: каждое приложение выбирает свою версию, а major-релиз может менять поведение. Это и позволило ежегодно переписывать JIT, GC и библиотеки.

Цена

  • С .NET 5 глобализация на Windows идёт через ICU, а не NLS: сравнение и сортировка строк с культурой могут дать другой результат.
  • System.Drawing.Common с .NET 6 поддерживается только на Windows, а пути в Linux чувствительны к регистру.
  • Экосистема уходит от Framework: EF Core с версии 5.0 на нём не работает (3.1 — последняя версия, которая его поддерживает).

Компромиссы

Миграция «большим взрывом» рискованна, поэтому идут по шагам:

  1. Перевести проекты на SDK-style и PackageReference.
  2. Общие библиотеки собрать под netstandard2.0 или сразу под обе платформы.
  3. Найти несовместимые API: platform compatibility analyzer, .NET Upgrade Assistant.
  4. Переносить хосты. Веб — постепенно: новое ASP.NET Core-приложение с YARP встаёт перед старым и забирает маршруты по одному.

Как проверить в проде

Прогонять одни и те же интеграционные и нагрузочные тесты на старой и новой версии и сравнивать ответы, ошибки и задержки до переключения трафика.

Итог: Framework застыл из-за обязательной совместимости, и путь вперёд — поэтапная миграция с проверкой поведения, а не переписывание с нуля.

Пример
using System.Runtime.InteropServices;
// Какой рантайм и какая ОС выполняют код
Console.WriteLine(RuntimeInformation.FrameworkDescription);
// .NET Framework: ".NET Framework 4.8.xxxx"
// .NET 10: ".NET 10.0.x"
Console.WriteLine(RuntimeInformation.OSDescription);
Console.WriteLine(RuntimeInformation.ProcessArchitecture); // X64, Arm64
#if NETFRAMEWORK
Console.WriteLine("Скомпилировано под .NET Framework");
#elif NET5_0_OR_GREATER
Console.WriteLine("Скомпилировано под .NET 5+");
#endif
Junior+

Что такое LTS и STS и как выбрать версию .NET для проекта?

Заголовок раздела «Что такое LTS и STS и как выбрать версию .NET для проекта?»
Ответ

Что это

  • LTS (Long Term Support) — чётные версии, поддержка три года.
  • STS (Standard Term Support) — нечётные версии, поддержка два года; до .NET 9 было 18 месяцев.
  • Поддержка — это выход патчей, в том числе исправлений уязвимостей. После её окончания патчей нет.

Версии на октябрь 2026

Версия Тип Поддержка до
.NET 8 LTS 10 ноября 2026
.NET 9 STS 10 ноября 2026
.NET 10 LTS 14 ноября 2028
.NET 11 STS выходит в ноябре 2026

Как пользоваться

Версию задаёт <TargetFramework>net10.0</TargetFramework> в .csproj. Патчи (10.0.1, 10.0.2 и дальше) ставятся без изменения проекта.

Частая ошибка

Оставаться на версии без поддержки, «потому что работает».

Как устроено

  1. Каждый ноябрь выходит major-версия; треки LTS и STS чередуются.
  2. Почти каждый месяц выходит патч. Исправления безопасности — во второй вторник месяца (Patch Tuesday).
  3. Приложение без встроенного рантайма запускается на последнем установленном патче своей версии. На новую major-версию оно не переходит, пока не задан RollForward (Major или LatestMajor).
  4. SDK версионируется отдельно, по feature bands (10.0.1xx, 10.0.2xx). Свежий SDK собирает проекты и под старые версии .NET.

Когда что выбирать

Проект Версия
Сервис с частыми релизами Последняя, LTS или STS
Продукт, который обновляется редко LTS
Инструменты сборки Последний SDK, даже из STS

Ловушки

  • Out-of-band-пакеты из STS-релиза попадали в LTS-приложения через зависимости и сокращали их поддержку. Поэтому в 2025 году STS продлили до 24 месяцев.
  • Без global.json разработчики и CI собирают проект разными SDK.

Правило

Обновляться раз в год на ближайшую поддерживаемую версию, не дожидаясь конца поддержки.

Под капотом

  • Хост (dotnet или запускатель приложения) читает runtimeconfig.json и выбирает рантайм по политике roll-forward. По умолчанию это Minor: последний патч своей версии.
  • Self-contained-публикация фиксирует рантайм на момент сборки: патчи приходят только с пересборкой.

Цена

  • Каждая major-версия — это список breaking changes. Прыжок через три версии собирает их все сразу, поэтому ежегодный апгрейд дешевле.
  • 10 ноября 2026 одновременно заканчивается поддержка .NET 8 и .NET 9. Мигрировать сразу на .NET 11 — значит брать релиз, вышедший в тот же месяц.

Компромиссы

  • ASP.NET Core, EF Core и Microsoft.Extensions.* версионируются вместе с .NET, и их новые major-версии часто требуют нового .NET. Рантайм и зависимости обновляют вместе.
  • Образ контейнера с тегом major.minor (mcr.microsoft.com/dotnet/aspnet:10.0) подтягивает патчи при пересборке. Точный тег даёт воспроизводимость, но патчи придётся поднимать вручную или через Dependabot и Renovate.

Как проверить в проде

  • dotnet --list-runtimes и dotnet sdk check показывают установленные версии и то, что вышло из поддержки.
  • RuntimeInformation.FrameworkDescription в логе старта показывает, на каком патче реально работает сервис.

Итог: версию .NET выбирают по графику поддержки и обновляют по плану, а не когда «сломается».

Пример
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- .NET 10 — LTS, поддержка до ноября 2028 -->
<TargetFramework>net10.0</TargetFramework>
<!-- Разрешить запуск на более новой major-версии, если нужной нет -->
<RollForward>Major</RollForward>
</PropertyGroup>
</Project>
Окно терминала
dotnet --list-sdks # установленные SDK
dotnet --list-runtimes # установленные рантаймы
dotnet sdk check # что устарело и что вышло из поддержки
Middle+
Ответ

Что это

  • IL — промежуточный язык. Компилятор C# пишет его в сборку .dll вместе с метаданными — описанием типов и методов.
  • CLR — среда выполнения: загружает сборки, управляет памятью, исключениями и потоками.
  • JIT — часть CLR, которая переводит IL метода в машинный код.

Как это работает

  1. dotnet build — Roslyn компилирует .cs-файлы в IL.
  2. dotnet run — запускается CLR и загружает сборку.
  3. Перед первым вызовом метода JIT компилирует его в код для текущего процессора.
  4. Следующие вызовы идут сразу в готовый машинный код.

Частая ошибка

Думать, что C# интерпретируется, как Python. Процессор всегда выполняет машинный код, просто этот код появляется во время работы программы.

Как устроено

  1. Запускатель (dotnet app.dll или app.exe) находит hostfxr. Тот читает app.runtimeconfig.json и выбирает версию рантайма.
  2. hostpolicy читает app.deps.json, составляет список сборок приложения и запускает CoreCLR — CLR современного .NET.
  3. CLR загружает сборку и находит точку входа.
  4. Каждый метод сначала вызывается через заглушку. Она запускает JIT и подменяет себя адресом готового кода.
  5. Метод, который ни разу не вызвали, не компилируется никогда.

Что ещё даёт CLR

  • Сборку мусора, исключения, загрузку типов, потоки, вызовы нативного кода (P/Invoke).
  • CTS — общую систему типов: C#, F# и VB.NET компилируются в один IL и используют типы друг друга.

Ловушки

  • Generic-код JIT компилирует отдельно для каждого значимого типа-аргумента и один раз для всех ссылочных. Много инстанциаций со структурами — больше машинного кода.
  • Первые запросы к сервису медленнее из-за JIT и загрузки типов.

Правило

Не путать медленный старт с медленным кодом: производительность замеряют после прогрева.

Под капотом

  • IL не зависит от процессора, поэтому JIT генерирует код под реальную машину: AVX2, AVX-512 или Arm64-инструкции.
  • В оптимизированном коде JIT знает значения static readonly-полей уже инициализированных классов: подставляет их как константы и выкидывает мёртвые ветки.
  • Сначала JIT делает быстрый неоптимизированный код, а горячие методы потом перекомпилирует с оптимизациями в фоне (tiered compilation).

Цена

  • Время старта, CPU на компиляцию и память под код.
  • В контейнере с лимитом в одно ядро первые секунды после старта сервис заметно медленнее, и это бьёт по автомасштабированию.

Компромиссы

Вариант Старт Ограничения
JIT медленнее нет
ReadyToRun: машинный код плюс IL быстрее сборки больше
Native AOT: без JIT самый быстрый нет Reflection.Emit и динамической загрузки

Как проверить в проде

  • dotnet-counters monitor -p <pid> --counters System.Runtime — число скомпилированных JIT методов и время в JIT.
  • dotnet-trace с JIT-событиями показывает, какие методы компилируются на старте.
  • DOTNET_JitDisasm="*Add*" печатает ассемблер (с .NET 7 — в обычных сборках рантайма); в BenchmarkDotNet для этого есть [DisassemblyDiagnoser].

Итог: JIT меняет время старта на машинный код, оптимизированный под реальную машину.

Пример
Console.WriteLine(Add(2, 3)); // 5
static int Add(int a, int b) => a + b;
// IL метода Add в сборке (видно в ILSpy или на sharplab.io):
// ldarg.0 // положить a на стек вычислений
// ldarg.1 // положить b
// add // снять два значения, положить сумму
// ret // вернуть вершину стека
//
// Оптимизированный машинный код от JIT (Windows x64):
// lea eax, [rcx+rdx]
// ret
//
// Посмотреть самому:
// DOTNET_JitDisasm="*Add*" dotnet run -c Release
Middle+

Что такое .NET Standard и зачем multi-targeting (TargetFrameworks)?

Заголовок раздела «Что такое .NET Standard и зачем multi-targeting (TargetFrameworks)?»
Ответ

Что это

.NET Standard — пронумерованный набор API (от 1.0 до 2.1), который обязаны реализовать платформы .NET. Это контракт для библиотек, а не отдельный рантайм.

Зачем

Раньше были .NET Framework, .NET Core, Xamarin, Mono, UWP — с разными API. Библиотеке под .NET Standard не нужно знать, на какой из них она запустится.

Как пользоваться

Кто будет использовать библиотеку Цель
В том числе .NET Framework netstandard2.0
Только современный .NET net8.0 или net10.0

Частая ошибка

Брать netstandard для нового кода, которому .NET Framework не нужен: теряются новые API без всякой выгоды.

Как устроено

  • Кто реализует. netstandard2.0 — .NET Framework 4.6.1+ (Microsoft советует 4.7.2+: в 4.6.1 есть проблемы), .NET Core 2.0+, .NET 5+, Mono, Unity. netstandard2.1 — .NET Core 3.0+ и .NET 5+, без .NET Framework.
  • TFM в .NET 5+. net10.0 — кроссплатформенный API, net10.0-windows — плюс Windows-специфика: WinForms, WPF.
  • Multi-targeting. <TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks> (во множественном числе): проект собирается дважды, а dotnet pack кладёт в пакет lib/netstandard2.0 и lib/net8.0.
  • Различия в коде — через символы NETSTANDARD2_0, NETFRAMEWORK, NET8_0_OR_GREATER и условные PackageReference.

Когда что выбирать

Библиотека Цели
Внутренняя, для одного приложения Тот же TFM, что у приложения
Публичная, нужен .NET Framework netstandard2.0 и net8.0
Публичная, только современный .NET net8.0; netstandard2.1 Microsoft советует пропустить

Ловушки

Проект на netstandard2.0 может сослаться на сборку .NET Framework (режим совместимости). Компиляция пройдёт, а в рантайме возможны TypeLoadException или MissingMethodException.

Правило

Одна цель, пока нет реальных потребителей на другой платформе.

Под капотом

  • Спецификация отставала от реализации на годы: API появлялся в .NET Core, потом попадал в стандарт, потом стандарт должны были реализовать все платформы.
  • Платформенно-зависимые API внутри стандарта бросали PlatformNotSupportedException уже в рантайме. В .NET 5+ спецификация и реализация совпадают, а неподдерживаемые на ОС API ловит platform compatibility analyzer при сборке.
  • Из пакета с netstandard2.0 и net8.0 проект на net10.0 получает net8.0 — ближайший совместимый TFM.

Цена

  • Матрица CI: тесты на каждой цели, для net48 — Windows-агент.
  • Полифилы (System.Memory, Microsoft.Bcl.AsyncInterfaces) и условные зависимости для старой цели.
  • #if-ветки в коде.

Компромиссы

Современная цель даёт перегрузки на Span<T>, nullable-аннотации BCL и анализаторы совместимости с Native AOT — они работают только для net8.0 и выше. Но публичный API во всех целях должен совпадать. Иначе промежуточная библиотека, собранная против netstandard2.0-версии, упадёт с MissingMethodException, когда приложение на net8.0 подтянет другую сборку.

Как проверить

Package validation (EnablePackageValidation) при dotnet pack проверяет, что код, собранный против одной цели пакета, работает с остальными, а с PackageValidationBaselineVersion — ещё и совместимость с прошлым релизом.

Итог: держите netstandard2.0, пока есть живые потребители на .NET Framework, и удаляйте цель, когда последний мигрирует.

Пример
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<!-- Полифил нужен только старой цели -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Memory" Version="4.5.5" />
</ItemGroup>
</Project>
public static class Guard
{
public static void NotNull(object? value, string name)
{
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(value, name); // API из .NET 6+
#else
if (value is null) throw new ArgumentNullException(name); // для netstandard2.0
#endif
}
}
Middle+

Что такое сборка и как NuGet разрешает конфликты версий зависимостей?

Заголовок раздела «Что такое сборка и как NuGet разрешает конфликты версий зависимостей?»
Ответ

Что это

  • Сборка — результат компиляции проекта: MyLib.dll или приложение. Внутри IL-код, метаданные и манифест — имя, версия и список сборок, от которых она зависит.
  • NuGet-пакет — архив .nupkg со сборками под разные платформы (папки lib/net8.0, lib/netstandard2.0) и списком зависимостей.

Как пользоваться

  1. Добавить пакет: dotnet add package Serilog — в .csproj появится <PackageReference>.
  2. dotnet restore (его запускает и dotnet build) скачивает пакет и его зависимости в общий кеш ~/.nuget/packages.
  3. Проект при сборке ссылается на нужные .dll из этого кеша.

Частая ошибка

Скопировать .dll в репозиторий и сослаться на файл. Так теряются зависимости этой сборки, обновления и проверка уязвимостей.

Как устроено

Сборка — единица развёртывания и версионирования. Её идентичность — простое имя, AssemblyVersion, культура и токен публичного ключа. Тип неотделим от сборки: MyType из двух разных сборок — два разных типа.

Как NuGet выбирает версию

Правило Что делает
Lowest applicable version >= 2.1 даёт минимальную доступную версию от 2.1
Direct dependency wins версия из проекта побеждает транзитивную
Cousin dependencies >= 1.0 и >= 2.0 в разных ветках дают 2.0

Результат записывается в obj/project.assets.json.

Ловушки

  • Версия в проекте ниже той, что требует зависимость, — это понижение, предупреждение NU1605. .NET SDK считает его ошибкой.
  • Точные версии [1.0] и [2.0] в разных ветках не совместить — ошибка NU1107.

Правило

Конфликт решают прямой ссылкой на выбранную версию, обычно на более высокую. Кто притащил пакет, показывают dotnet nuget why <пакет> (SDK 8.0.4xx+) и dotnet list package --include-transitive.

Под капотом

  1. NuGet при restore выбирает версию пакета.
  2. SDK записывает путь к сборке в app.deps.json.
  3. AssemblyLoadContext.Default загружает сборку по простому имени. Строгое имя в .NET не проверяется и в привязке не участвует.
  4. Загруженная версия должна быть не ниже той, против которой компилировали, иначе — FileLoadException.

Цена

  • Минимальная версия ради воспроизводимости означает, что уязвимые транзитивные версии «застревают». NuGet Audit предупреждает о них при restore.
  • NuGet проверяет диапазоны версий, а не бинарную совместимость. Пакет A собран против Lib 1.x, граф выбрал Lib 2.0 с breaking change — сборка проходит, а в рантайме MissingMethodException.

Компромиссы

  • Central Package Management задаёт версии в одном Directory.Packages.props. Transitive pinning (CentralPackageTransitivePinningEnabled) поднимает и транзитивные зависимости, но понижать их не даёт (NU1109).
  • В .NET Framework расхождения версий закрывают binding redirects в app.config (AutoGenerateBindingRedirects); без них — FileLoadException.
  • Две версии одной библиотеки в процессе возможны только в отдельных AssemblyLoadContext, как у плагинов.

Как проверить в проде

  • dotnet nuget why и obj/project.assets.json — откуда взялась версия.
  • DOTNET_HOST_TRACE=1 — какие файлы хост включил в список сборок.
  • dotnet-dump или AppDomain.CurrentDomain.GetAssemblies() в диагностическом эндпоинте — что реально загружено.

Итог: версия выбирается при restore и фиксируется в deps.json, а несовместимость версий всплывает только в рантайме — её ловят тестами.

Пример
<!-- Directory.Packages.props в корне репозитория -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!-- Поднимать до этих версий и транзитивные зависимости -->
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Serilog" Version="4.0.0" />
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
</Project>
Окно терминала
dotnet list package --include-transitive # весь граф с версиями
dotnet nuget why System.Text.Json # кто притащил пакет в проект
Middle+
Ответ

Что это

Каждый релиз .NET выходит со своей версией C#, и TFM определяет её по умолчанию: net8.0 — C# 12, net9.0 — C# 13, net10.0 — C# 14.

Как пользоваться

Версия Что появилось Пример
C# 12 primary constructor, collection expressions class Service(ILogger<Service> log), int[] a = [1, 2, 3];
C# 13 params для любых коллекций, тип Lock void Log(params ReadOnlySpan<string> parts)
C# 14 field, ?. слева от присваивания customer?.Order = order;
.NET 10 SDK запуск одного файла без проекта dotnet run app.cs

Частая ошибка

Поднять LangVersion в старом проекте вместо перехода на новый .NET. Часть фич требует типов из новой BCL, и такая конфигурация официально не поддерживается.

Что появилось

Релиз Платформа Язык
.NET 8, LTS Native AOT для ASP.NET Core, keyed DI, TimeProvider, FrozenDictionary C# 12: primary constructors, collection expressions
.NET 9, STS Task.WhenEach, CountBy, Guid.CreateVersion7, удаление BinaryFormatter C# 13: params-коллекции, Lock
.NET 10, LTS file-based apps, OpenAPI 3.1, валидация в minimal API C# 14: field, extension members, ?. в присваивании

Что спрашивают подробнее

  • Keyed DI. AddKeyedSingleton<ICache, BigCache>("big") и [FromKeyedServices("big")] — несколько реализаций одного интерфейса по ключу.
  • TimeProvider — абстракция текущего времени и таймеров, чтобы тестировать код, зависящий от времени.
  • System.Threading.Lock. lock по такому объекту компилируется в вызовы его API, а не Monitor.
  • File-based apps. Директивы #:package и #:project в начале .cs-файла; публикация по умолчанию в Native AOT.

Ловушки

  • BinaryFormatter в .NET 9 всегда бросает исключение, и легаси-сериализация на нём ломается.
  • Параметры primary constructor не readonly: их можно случайно переприсвоить.

Правило

Читать список breaking changes до апгрейда и переходить с LTS на следующую LTS не позже конца поддержки.

Под капотом

  • Dynamic PGO (.NET 8, по умолчанию): JIT собирает профиль горячих путей и типов во время работы и использует его при перекомпиляции. По данным Microsoft — в среднем около +15% производительности.
  • DATAS (.NET 9, по умолчанию при Server GC): GC подстраивает число куч и размер кучи под нагрузку, а не под число ядер.
  • Escape analysis (.NET 10): объекты, которые не покидают метод, — маленькие массивы, делегаты — JIT размещает на стеке.

Цена

DATAS уменьшает память ценой части пропускной способности. В бенчмарках Microsoft на 48 ядрах — минус 2–3% RPS при working set меньше более чем на 80%. Если важнее RPS, его выключают: DOTNET_GCDynamicAdaptationMode=0.

Компромиссы

  • Primary constructors. Параметр, использованный в методах или свойствах, становится скрытым изменяемым полем: удобно для DI, плохо для инвариантов.
  • Collection expressions. IEnumerable<int> x = [1, 2]; даёт сгенерированный компилятором read-only тип; приведение к List<int> бросит InvalidCastException.
  • params ReadOnlySpan<T> убирает аллокацию массива, но новые перегрузки BCL при перекомпиляции могут изменить выбранный метод. Для управления этим есть OverloadResolutionPriority.
  • field затеняет поле с именем field внутри аксессоров; нужен @field или this.field.

Как проверить в проде

До переключения трафика — dotnet-counters (GC, память, JIT) и нагрузочный тест на старой и новой версии, BenchmarkDotNet — на критичных путях.

Итог: апгрейд .NET даёт бесплатную производительность с изменённым профилем, и её подтверждают замерами.

Пример
using Microsoft.Extensions.Logging;
// C# 12: primary constructor и collection expression
public class OrderService(ILogger<OrderService> logger)
{
private readonly List<string> _tags = ["new"];
// C# 13: params-коллекция без аллокации массива
public void AddTags(params ReadOnlySpan<string> tags)
{
foreach (var tag in tags) _tags.Add(tag);
logger.LogInformation("Тегов: {Count}", _tags.Count);
}
// C# 14: field вместо явного поля
public string Name { get; set => field = value.Trim(); } = "";
}
// C# 14: extension-свойство
public static class StringExtensions
{
extension(string s)
{
public bool IsBlank => string.IsNullOrWhiteSpace(s);
}
}
Senior+

Что такое tiered compilation, ReadyToRun и Native AOT и когда что применять?

Заголовок раздела «Что такое tiered compilation, ReadyToRun и Native AOT и когда что применять?»
Ответ

Что это

  • Tiered compilation — JIT сначала быстро компилирует метод без оптимизаций, а часто вызываемые методы потом перекомпилирует с оптимизациями. Включена по умолчанию с .NET Core 3.0.
  • ReadyToRun — машинный код добавляется в сборки при публикации, JIT остаётся.
  • Native AOT — всё приложение компилируется в один нативный файл; JIT и установленный .NET не нужны.

Как пользоваться

  1. ReadyToRun: dotnet publish -c Release -r linux-x64 -p:PublishReadyToRun=true.
  2. Native AOT: <PublishAot>true</PublishAot> в .csproj и dotnet publish -c Release -r linux-x64.

Частая ошибка

Игнорировать предупреждения при AOT-публикации: код с рефлексией соберётся, но упадёт уже во время работы.

Как устроено

  1. Tier 0. Быстрый JIT без оптимизаций или готовый ReadyToRun-код.
  2. Подсчёт вызовов начинается, когда 100 мс подряд не было новой tier-0-компиляции.
  3. Dynamic PGO (по умолчанию с .NET 8). Горячий метод проходит через инструментированную версию, которая собирает профиль: какие типы приходят в виртуальные вызовы, какие ветки горячие.
  4. Tier 1. Метод, вызванный 30 раз, в фоне перекомпилируется с оптимизациями и профилем: девиртуализация, инлайнинг, раскладка кода.
  5. OSR (с .NET 7 на x64 и Arm64). Метод с долгим циклом, вызванный один раз, переключается на оптимизированный код посреди цикла.

Когда что выбирать

Ситуация Вариант
Обычный долгоживущий сервис JIT с настройками по умолчанию
Важен холодный старт, много кода, есть рефлексия ReadyToRun
Минимальные старт и память, нет динамики Native AOT

Ловушки

[MethodImpl(MethodImplOptions.AggressiveOptimization)] сразу даёт оптимизированный код, но выключает для метода tiering и Dynamic PGO.

Правило

Не трогать настройки tiering без замеров: значения по умолчанию подходят почти всем.

Под капотом

  • ReadyToRun-код оптимизирован хуже JIT: он не знает точного процессора и ограниченно инлайнит между сборками. Поэтому горячие методы всё равно уходят в tier 1.
  • Native AOT при публикации обрезает приложение и компилирует IL всех оставшихся сборок. В бинарнике — урезанный рантайм без JIT.

Цена

  • Native AOT. Нет Assembly.LoadFile, Reflection.Emit, C++/CLI и встроенного COM; System.Linq.Expressions только интерпретируются. Generic-инстанциации со структурами генерируются заранее и раздувают бинарник. В ASP.NET Core не поддерживаются MVC, Blazor Server и сессии.
  • Сборка AOT. Под конкретную ОС и архитектуру (RID) нативным тулчейном: MSVC или clang. Бинарник, собранный на Linux, работает на той же или более новой версии дистрибутива.
  • ReadyToRun. Сборки в 2–3 раза больше; composite-режим даёт лучший старт, но ещё сильнее растит размер.

Компромиссы

AOT-код не переоптимизируется под реальную нагрузку, поэтому на долгоживущем сервисе пиковая пропускная способность у JIT с Dynamic PGO обычно не ниже. Если выбран AOT — с первого дня: PublishAot в проекте (анализаторы работают уже при сборке), JSON через source generator, DI без сканирования сборок, библиотеки с IsAotCompatible.

Как проверить в проде

Замерить JIT, ReadyToRun и Native AOT: время до первого ответа, working set, RPS после прогрева, размер образа. dotnet-counters показывает время в JIT, а для AOT-бинарника нужны символы (.pdb или .dbg), иначе дампы не разобрать.

Итог: ReadyToRun — дешёвый выигрыш старта, Native AOT — сильный выигрыш ценой ограничений на весь код, и выбор подтверждают замерами.

Пример
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<!-- Вариант 1: ReadyToRun — быстрее старт, JIT остаётся -->
<PublishReadyToRun>true</PublishReadyToRun>
<!-- Вариант 2: Native AOT вместо ReadyToRun — без JIT -->
<!-- <PublishAot>true</PublishAot> -->
</PropertyGroup>
</Project>
Окно терминала
dotnet publish -c Release -r linux-x64 # оба варианта требуют RID
Код, совместимый с Native AOT
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
// true с JIT, false в Native AOT: генерации кода во время работы нет
Console.WriteLine(RuntimeFeature.IsDynamicCodeSupported);
var order = new Order(1, 99.5m);
// Сериализатор сгенерирован при компиляции — рефлексия не нужна
Console.WriteLine(JsonSerializer.Serialize(order, AppJson.Default.Order));
// {"Id":1,"Total":99.5}
record Order(int Id, decimal Total);
[JsonSerializable(typeof(Order))]
partial class AppJson : JsonSerializerContext { }