CLR, IL, JIT, AOT, tiered compilation

↑ 2.2 .NET runtime: CLR, память, GC · → Следующая

Обязательно~5 мин чтенияУровень: middle

Зачем это на собесе

«Что происходит между dotnet run и выполнением вашего метода?» — вопрос на понимание платформы. Ждут цепочку: компилятор → IL и метаданные → загрузка сборок CLR → JIT → машинный код, а также осознанный выбор между JIT и Native AOT.

Объяснение

Путь от C# до машинного кода

  1. Компилятор Roslyn превращает C# в IL (Intermediate Language, CIL) и метаданные. Результат — сборка (.dll/.exe), не зависящая от процессора и ОС.
  2. CLR (Common Language Runtime) при запуске загружает сборки, проверяет метаданные, разрешает типы.
  3. JIT-компилятор (Just-In-Time) при первом вызове метода переводит его IL в машинный код текущего процессора и кэширует результат в памяти процесса.
  4. Среда выполнения предоставляет GC, потоки, исключения, безопасность типов, взаимодействие с нативным кодом.
C# → (Roslyn) → IL + метаданные → (CLR загружает) → (JIT) → машинный код → выполнение

Один и тот же IL работает на Windows, Linux, macOS, x64 и ARM: платформенно-зависима только среда выполнения.

IL и метаданные

  • IL — стековая байткод-подобная инструкция (ldarg, add, call, box, newobj).
  • Метаданные описывают типы, члены, ссылки на другие сборки; рефлексия читает именно их.
  • Посмотреть IL: ildasm, ILSpy, dotPeek, sharplab.io. Полезно, чтобы увидеть box, замыкания, state machine async.

JIT и tiered compilation

JIT должен быть быстрым при старте, но давать быстрый код в долгой работе. Решение — многоуровневая компиляция (tiered compilation, включена по умолчанию):

  • Tier 0: быстрая компиляция без тяжёлых оптимизаций (быстрый старт).
  • Tier 1: горячие методы (вызвано ~30 раз) перекомпилируются с полными оптимизациями в фоновом потоке.
  • Dynamic PGO (.NET 8 по умолчанию): рантайм собирает профиль (какие ветки, какие типы в virtual-вызовах) в tier 0 и использует его при оптимизации tier 1: девиртуализация, inlining, выбор горячих веток.
  • OSR (on-stack replacement): метод с долгим циклом переключается на оптимизированную версию «на лету».
  • ReadyToRun (R2R): часть кода предкомпилируется в сборку заранее (быстрее старт, чуть менее оптимально; затем tier 1 перекомпилирует горячее).
<PropertyGroup>
  <TieredPGO>true</TieredPGO>
  <TieredCompilation>true</TieredCompilation>
  <PublishReadyToRun>true</PublishReadyToRun>   <!-- ускорить холодный старт -->
</PropertyGroup>

Native AOT

Компиляция заранее в один нативный исполняемый файл без JIT и без установленного runtime.

<PublishAot>true</PublishAot>
JIT (по умолчанию)Native AOT
Время стартасотни мсдесятки мс
Памятьбольшеменьше
Пиковая производительностьвыше (PGO, динамическая оптимизация)ниже или равна
Размернужен runtime, файлы большеодин файл, trimming
Ограничениянетнет динамической загрузки, ограниченная рефлексия, нужны source generators
Подходит для CLI-утилит, serverless, сервисов с частым холодным стартом; для обычных долгоживущих API чаще достаточно JIT + ReadyToRun.

Полезные факты

  • Первый вызов метода дороже: JIT-компиляция (эффект «холодного старта»). Прогрев (warm-up) запросов перед приёмом трафика уменьшает первые долгие ответы.
  • Inlining: JIT встраивает маленькие методы; [MethodImpl(MethodImplOptions.AggressiveInlining)] подсказка, но не гарантия.
  • Bounds check elimination, девиртуализация sealed-классов — причины, по которым sealed бывает быстрее.
  • System.Runtime.InteropServices / P/Invoke: вызовы нативного кода; маршалинг стоит времени.

Нюансы и подводные камни

  • Медленные первые запросы после деплоя — JIT + холодные кэши. Лечится ReadyToRun, прогревом, tiered PGO.
  • Бенчмарки на холодном процессе врут: используйте BenchmarkDotNet, который прогревает и делает статистику.
  • Native AOT ломает код с рефлексией, динамической загрузкой сборок и Expression.Compile; библиотеки должны быть AOT-совместимы.
  • TieredCompilation=false ускоряет пиковую производительность в некоторых случаях, но замедляет старт.
  • Отладочная сборка (Debug) отключает многие оптимизации JIT: измеряйте только Release.

Практика

  • Сравнить время старта простого API на JIT, ReadyToRun и Native AOT.
  • Посмотреть IL метода с async и с замыканием на sharplab.io и найти state machine и display class.
  • Измерить первый и сотый вызов метода через Stopwatch.

Вопросы с ответами

Связанные темы