Skip to content
L-SYSTEM

Что на самом деле происходит, когда вы жмёте F5

Preview unit: this one is open without enrolling. The rest open after you enrol.

Давление: «форматтер отчётов тормозит»

В ENGAGE выгрузку отчётов клиенту делает отдельная консольная утилита. Планировщик запускает её на каждого клиента: новый процесс, один отчёт, выход. Разработчик заметил, что отчёт собирается медленно, и поставил замер:

// ENGAGE, Tools/ReportExport/Program.cs — код с дефектом, это учебный материал
// фрагмент: report загружен выше
var sw = Stopwatch.StartNew();
var html = ClientReportRenderer.Render(report);
sw.Stop();
Console.WriteLine($"Render: {sw.ElapsedMilliseconds} ms");

Цифра вышла большой. Команда уже обсуждает, как переписать рендерер. Но если вызвать Render второй раз в том же процессе, он отработает в разы быстрее. Код тот же, данные те же.

Замер измерил не рендерер. Он измерил компиляцию рендерера в машинный код, которая случилась в момент первого вызова. Вдобавок утилиту запускали через F5 в конфигурации Debug, где оптимизации выключены. Чтобы такие замеры читать правильно, а потом выбрать между JIT, ReadyToRun и Native AOT, нужна модель того, что происходит между .cs и процессором.

Что делает F5: сборка и запуск

F5 в Visual Studio (или dotnet run) — это два разных шага.

Сборка. MSBuild читает .csproj, разрешает пакеты и вызывает компилятор C# — Roslyn. В bin/Debug/net10.0/ появляются:

  • ReportExport.dll — сборка с вашим кодом: IL и метаданные;
  • ReportExport.exe (на Linux без расширения) — apphost, нативный запускатель без вашего кода;
  • ReportExport.runtimeconfig.json — какой рантайм нужен;
  • ReportExport.deps.json — от каких сборок зависит приложение.

Запуск. Apphost (или команда dotnet ReportExport.dll) находит установленный рантайм по runtimeconfig.json, загружает CLR, CLR загружает сборку и компилирует Main. Дальше каждый метод компилируется тогда, когда его впервые вызывают.

Debug и Release — разный код. В Debug компилятор выдаёт неоптимизированный IL и помечает сборку так, что JIT тоже не оптимизирует: ничего не встраивается, отладчик может встать на любой строке. Отладчик Visual Studio вдобавок умеет подавлять оптимизации JIT. dotnet run по умолчанию собирает Debug, dotnet publish начиная с .NET 8 — Release. Любой замер скорости делается в Release и без отладчика.

Почему промежуточный язык

Код на разных языках должен работать вместе, на разных ОС и процессорах, и быть безопасным по памяти. Прямая компиляция дала бы по компилятору на каждую пару «язык × процессор», интерпретация слишком медленна. Решение — промежуточный слой.

CIL (Common Intermediate Language, он же IL, он же MSIL) — система команд абстрактной стековой машины. Roslyn выдаёт именно её. Тот же IL выдают компиляторы F# и VB, поэтому библиотека на F# подключается к проекту на C# без обёрток.

Рядом с IL в сборке лежат метаданные — полное описание всех типов, членов, сигнатур и атрибутов. Это не отладочная информация, её нельзя выключить: на неё опирается сам рантайм, когда проверяет типы и компилирует методы. Из метаданных вырастают рефлексия, сериализаторы, DI-контейнеры, ORM и тестовые фреймворки. Из них же — декомпиляторы, которые восстанавливают почти исходный C# из .dll. Отладочные символы (.pdb) — отдельный файл, к декомпиляции они почти отношения не имеют.

CLR: что работает, пока живёт процесс

CLR (Common Language Runtime) — реализация абстрактного стандарта CLI. Это не интерпретатор и не песочница, а набор служб:

  • загрузчик сборок — находит и подгружает зависимости;
  • JIT-компилятор — превращает IL в машинный код по требованию;
  • менеджер памяти со сборщиком мусора — выделяет объекты и освобождает недостижимые (u03);
  • типобезопасность — через ссылку на Engagement нельзя прочитать чужую память, приведение типа проверяется;
  • единая модель исключений для всех языков;
  • потоки и синхронизация, пул потоков.

Код, который исполняется под этими службами, называется managed. Противоположность — unmanaged: функции из нативных библиотек ОС или C/C++. Мост между мирами — P/Invoke: метод с атрибутом [DllImport] или, с .NET 7, [LibraryImport], маршалинг для которого генерирует компилятор. На границе рантайм переводит строки, структуры и массивы между представлениями, а объекты, которые видит нативный код, GC двигать не может. Частый вызов дешёвой нативной функции может стоить больше её работы. Нативную память сборщик мусора не освобождает: ею управляют через SafeHandle и IDisposable.

CTS и CLS

CTS (Common Type System) отвечает на вопрос, какие типы вообще бывают: значимые и ссылочные, классы, интерфейсы, делегаты, перечисления; все значимые типы наследуются от System.ValueType. int в C# — псевдоним System.Int32, буквально тот же тип. Поэтому int.MaxValue и System.Int32.MaxValue — один и тот же член, а не «похожий».

CLS (Common Language Specification) — подмножество CTS, которое обязаны понимать все языки платформы. C# различает Rate и rate, Visual Basic — нет. Поэтому два публичных члена, различающиеся только регистром, корректны для CTS, но несовместимы с CLS. Беззнаковые типы в публичной сигнатуре тоже не CLS-совместимы. Атрибут [assembly: CLSCompliant(true)] включает проверку, и компилятор предупреждает о таких членах. Сегодня это важно в основном авторам библиотек для других языков.

Сборка, модуль, пространство имён, проект

  • Проект (.csproj) — единица в системе сборки MSBuild. Понятие времени разработки, рантайм о нём не знает.
  • Сборка (assembly) — то, что проект выдаёт: .dll. Единица развёртывания, версионирования и загрузки. У сборки есть имя, версия, культура, может быть подпись. Именно сборку грузит CLR, и именно границей сборки ограничен internal.
  • Модуль — файл с IL и метаданными внутри сборки. Формат позволяет многофайловые сборки, но современный .NET их не поддерживает: на практике одна сборка — один модуль.
  • Пространство имён — только часть полного имени типа. Не единица развёртывания и не граница доступа. Одна сборка содержит много пространств имён, одно пространство имён может быть размазано по нескольким сборкам.
// файл Rates/RateCache.cs, сборка Engage.Billing.dll
namespace Engage.Billing.Rates;

internal sealed class RateCache
{
    public decimal? Find(string engagementCode) => null;   // заглушка
}
// файл Invoices/InvoiceBuilder.cs, та же сборка Engage.Billing.dll
using Engage.Billing.Rates;

namespace Engage.Billing.Invoices;

public sealed class InvoiceBuilder
{
    private readonly RateCache _rates = new();   // компилируется: та же сборка
}

internal означает «внутри Engage.Billing.dll», а не «внутри Engage.Billing.Rates». Из Engage.Api.dll класс RateCache не виден, даже если там завести пространство имён с тем же именем. Исключение делается явно — атрибутом InternalsVisibleTo, обычно для сборки тестов.

JIT и многоуровневая компиляция

Метод компилируется в машинный код при первом вызове. Результат хранится в памяти процесса, следующие вызовы идут в готовый код. Отсюда холодный старт: первые запросы к свежему сервису и первый Render в утилите ENGAGE медленнее последующих.

Компилировать всё сразу с оптимизациями слишком долго для старта. Поэтому с .NET Core 3.0 по умолчанию включена многоуровневая компиляция (tiered compilation):

  • Tier0. Первый вызов компилируется быстро и почти без оптимизаций: без встраивания, без хитрого распределения регистров. Код работает, но медленнее, чем мог бы.
  • Подсчёт вызовов. Рантайм считает вызовы. Когда метод стал горячим (по умолчанию порядка 30 вызовов; порог и задержки — детали реализации), он ставится в очередь на перекомпиляцию.
  • Tier1. Фоновый поток компилирует метод заново, уже с полными оптимизациями, и следующие вызовы идут в новую версию. Основной поток не ждёт.
  • OSR (On-Stack Replacement). Метод, который вызван один раз, но крутит долгий цикл, по счётчику вызовов никогда не станет горячим. Для него рантайм умеет переключиться на оптимизированную версию прямо посреди цикла, не дожидаясь выхода. OSR включён по умолчанию с .NET 7 на x64 и Arm64.
  • Dynamic PGO (profile-guided optimization). Включён по умолчанию с .NET 8. Горячий метод сначала проходит через инструментированную версию, которая собирает профиль: какие ветки выполняются, какие типы на самом деле приходят в виртуальные и интерфейсные вызовы. Tier1 строится по этому профилю. Например, если в IRateProvider.GetRate почти всегда приходит SqlRateProvider, JIT вставит проверку типа и прямой встроенный вызов, а виртуальный оставит запасным путём (guarded devirtualization).

Этого не может статический компилятор: JIT знает конкретный процессор и фактический профиль. На долгоживущем сервисе это окупает отложенную компиляцию.

src Program.cs исходник C# rosl Roslyn (dotnet build) src->rosl время сборки asm MyApp.dll IL + метаданные rosl->asm t0 JIT tier0 быстро, без оптимизаций asm->t0 первый вызов метода r2r ReadyToRun готовый код внутри сборки asm->r2r dotnet publish PublishReadyToRun aot Native AOT весь код заранее asm->aot dotnet publish PublishAot t1 JIT tier1 оптимизации по профилю PGO t0->t1 метод горячий или долгий цикл (OSR) mc Машинный код в памяти процесса t0->mc t1->mc r2r->t1 горячие методы r2r->mc старт без JIT exe Нативный исполняемый файл без JIT и без IL aot->exe машинный код целиком
FigureПуть от исходника C# до машинного кода: JIT по уровням, ReadyToRun и Native AOT
Description

Схема слева направо. Узел «Program.cs, исходник C#» стрелкой «время сборки» ведёт к узлу «Roslyn (dotnet build)», от него стрелка к узлу «MyApp.dll, IL + метаданные». Из сборки расходятся три пути. Первый, обычный: стрелка «первый вызов метода» к узлу «JIT tier0, быстро, без оптимизаций»; от него стрелка «метод горячий или долгий цикл (OSR)» к узлу «JIT tier1, оптимизации по профилю PGO»; оба уровня JIT ведут к узлу «Машинный код в памяти процесса». Второй путь: стрелка «dotnet publish, PublishReadyToRun» к узлу «ReadyToRun, готовый код внутри сборки»; от него стрелка «старт без JIT» к машинному коду и пунктирная стрелка «горячие методы» к узлу tier1 — горячие методы всё равно перекомпилируются с оптимизациями. Третий путь: стрелка «dotnet publish, PublishAot» к узлу «Native AOT, весь код заранее», от него стрелка «машинный код целиком» к узлу «Нативный исполняемый файл без JIT и без IL». Смысл: машинный код появляется либо во время выполнения (JIT, в два уровня), либо заранее частично (ReadyToRun), либо заранее целиком (Native AOT).

Настройки есть (TieredCompilation, TieredPGO в .csproj; атрибут MethodImplOptions.AggressiveOptimization — сразу оптимизировать, но без PGO), но трогать их без замера — гадание.

Как мерить: прогрев

Вот что увидел разработчик ENGAGE, только в честном виде:

using System.Diagnostics;

decimal[] hours = [8m, 7.5m, 6m, 8m];

for (var i = 0; i < 3; i++)
{
    var start = Stopwatch.GetTimestamp();
    var total = Billing.Total(hours, 180m);
    var elapsed = Stopwatch.GetElapsedTime(start);
    Console.WriteLine($"{i}: {total}, {elapsed.TotalMicroseconds:F1} мкс");
}

static class Billing
{
    public static decimal Total(ReadOnlySpan<decimal> hours, decimal rate)
    {
        var sum = 0m;
        foreach (var h in hours) sum += h * rate;
        return sum;
    }
}

Сумма в трёх строках одинакова, а время первой заметно больше: в него вошла компиляция Total. Числа зависят от машины и версии рантайма, поэтому их здесь нет.

Первый замер меряет JIT. Три замера подряд — тоже не замер: tier1 ещё не подоспел, мешают частота процессора и фоновые потоки. Для честного измерения есть BenchmarkDotNet: он делает прогрев, ждёт установившегося режима, повторяет итерации, считает статистику и предупреждает о Debug.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<TotalBench>();

[MemoryDiagnoser]
public class TotalBench
{
    private readonly decimal[] _hours = [8m, 7.5m, 6m, 8m];

    [Benchmark]
    public decimal Total() => Billing.Total(_hours, 180m);
}

Запуск — dotnet run -c Release. [MemoryDiagnoser] добавляет к времени число аллокаций: пригодится уже в u02, когда речь пойдёт об упаковке. Подробно о замерах — в u28.

Для утилиты ENGAGE вывод другой: процесс живёт один отчёт, и главная его цена — старт и JIT. Это задача про способ компиляции, а не про алгоритм.

ReadyToRun и Native AOT

Режим Когда появляется машинный код Что получаете Чем платите
JIT (по умолчанию) при первом вызове, потом tier1 лучший код по профилю, полная рефлексия холодный старт
ReadyToRun заранее, при publish; горячие методы перекомпилируются быстрее старт, всё остальное как у JIT сборка крупнее, предкомпилированный код скромнее tier1
Native AOT целиком заранее, при publish старт без JIT, один нативный файл, меньше памяти нет динамической загрузки и генерации кода, рефлексия ограничена

ReadyToRun (<PublishReadyToRun>true</PublishReadyToRun>, нужен целевой RID: dotnet publish -c Release -r linux-x64) кладёт в сборку готовый машинный код рядом с IL. IL никуда не девается: если предкомпилированный код нельзя использовать, JIT скомпилирует метод как обычно, а горячие методы многоуровневая компиляция всё равно переведёт в tier1. Библиотеки самого .NET поставляются в формате ReadyToRun, поэтому старт пустого приложения и так не компилирует весь фреймворк. ReadyToRun — безопасный шаг: совместимость полная, теряется только размер.

Native AOT (<PublishAot>true</PublishAot>) компилирует всё приложение вместе с нужной частью рантайма в один нативный исполняемый файл под конкретную ОС и архитектуру. JIT в нём нет, IL нет. Отсюда ограничения:

  • Нет динамической загрузки сборок. Assembly.LoadFrom для плагинов не работает: всё, что будет исполняться, должно быть известно при публикации.
  • Нет генерации кода во время выполнения. Reflection.Emit и всё, что на нём построено, недоступно.
  • Рефлексия ограничена. AOT выкидывает неиспользуемый код (trimming). Тип, на который ссылаются только через строку или рефлексию, может просто не попасть в файл. Компилятор предупреждает об опасных местах (IL2026, IL3050), и эти предупреждения нельзя игнорировать.
  • Библиотеки должны быть совместимы. Сериализатор, который ищет свойства рефлексией, и DI-контейнер со сканированием сборок под AOT ломаются. Выход — генераторы исходного кода: например, System.Text.Json с JsonSerializerContext.
[JsonSerializable(typeof(ClientReport))]
internal partial class ReportJsonContext : JsonSerializerContext { }

// вместо JsonSerializer.Serialize(report):
var json = JsonSerializer.Serialize(report, ReportJsonContext.Default.ClientReport);

Как устроены рефлексия и генераторы и почему они конфликтуют с AOT, разберём в u31.

Как выбирать. Разделяющий признак — время жизни процесса.

  • Утилита командной строки, serverless-функция, задача на каждый файл: процесс живёт секунды, прогрев JIT не окупается, холодный старт — главная метрика. Кандидат на Native AOT, если зависимости совместимы; иначе ReadyToRun.
  • Долгоживущий веб-сервис или обработчик очереди: процесс живёт сутками, JIT успевает выйти на tier1 с PGO. Обычный JIT, при желании ReadyToRun. Native AOT здесь даст меньше памяти и образ контейнера, но отнимет рефлексию и PGO — решают по всем критериям, а не по старту.

Утилита экспорта ENGAGE — первый случай.

.NET Framework, .NET, Mono и сроки поддержки

  • .NET Framework (последняя версия 4.8.1) — исходная реализация, только Windows. Поддерживается вместе с Windows, но не развивается: новых версий C# и библиотек для него не будет.
  • .NET (до версии 5 — .NET Core) — кроссплатформенная реализация, живая ветка. Всё в этом курсе — про неё. Цифру 4 пропустили, чтобы не путать с Framework 4.x.
  • Mono — третья реализация CLI. Её код живёт в репозитории .NET и служит рантаймом для мобильных платформ и WebAssembly; у Unity своя ветка Mono.

Версии .NET выходят каждый ноябрь. Чётные — LTS, три года поддержки; нечётные — STS, два года. .NET 10 вышел в ноябре 2025 года, это LTS с поддержкой до ноября 2028-го. .NET 9 и .NET 8 выходят из поддержки 10 ноября 2026 года. Поэтому курс целится в .NET 10 и C# 14, а возможности C# 13 и .NET 9 помечает отдельно: вы встретите их в коде, но новые проекты на .NET 9 начинать уже незачем. «.NET Framework 4.7» в вакансии — скорее всего, поддержка старой системы; стоит спросить про план миграции.

Как запускать примеры курса

Материал читается без запуска кода, но если хочется проверить, что выведет пример, удобно держать под рукой учебный шаблон ENGAGE: https://github.com/AndreyKuralekh/engage (.NET 10). Там заготовлены типы из примеров. Это удобство, а не требование: любой пример можно вставить в пустой dotnet new console, дописав недостающие типы. Замеры, как сказано выше, — только -c Release.

Цена

  • JIT платит стартом. Каждый первый вызов — компиляция; первые запросы после деплоя медленнее. Плата разовая, и долгоживущий процесс её возвращает оптимизациями по профилю.
  • Многоуровневая компиляция делает время «плавающим»: сначала неоптимизированный код, потом метод тихо меняется на быстрый. Наивный замер врёт.
  • ReadyToRun платит размером сборки и немного качеством кода до перехода на tier1.
  • Native AOT платит возможностями: нет динамической загрузки, нет генерации кода, рефлексия только по тому, что известно при публикации, сборка под каждую ОС и архитектуру отдельно, нет Dynamic PGO. Часть привычных библиотек придётся заменить или настроить.
  • Метаданные платят открытостью: сборка декомпилируется почти до исходника.
  • Граница managed/unmanaged платит маршалингом. Частые мелкие вызовы через P/Invoke дороже, чем кажутся, а нативную память надо освобождать самим.

Как это звучит на интервью

«Что происходит между тем, как вы нажали Build, и тем, как код выполнился?» Слабый ответ: «компилируется в IL, потом JIT». Сильный — та же цепочка с причинами: Roslyn выдаёт IL и метаданные, потому что платформа общая для языков и не привязана к процессору; хост находит рантайм по runtimeconfig.json; CLR загружает сборку и компилирует метод при первом вызове — сначала быстро (tier0), потом для горячих методов с оптимизациями по профилю (tier1, Dynamic PGO). Поэтому холодный старт дороже, а долгий процесс быстрее. Если старт критичен — ReadyToRun или Native AOT, и за AOT платят рефлексией. Встречный вопрос: «почему тогда не компилировать всё заранее всегда?» — потому что теряется профиль выполнения и динамика: загрузка плагинов, генерация кода, полная рефлексия.

«Что такое CLR?» Образец: «Среда выполнения, которая компилирует IL в машинный код на целевой машине, следит за типобезопасностью и владеет памятью процесса через сборщик мусора. Ещё в ней загрузчик сборок, единая модель исключений и пул потоков. Для меня как разработчика следствие такое: я не управляю временем жизни объектов напрямую, зато получаю рефлексию, DI по метаданным и исключения, общие для всех языков. Граница — P/Invoke: нативный код живёт вне этих гарантий.» Перечисление аббревиатур без следствий не засчитывается. Встречный вопрос почти наверняка будет про GC — это u03.

«Сервис после деплоя первые секунды отвечает медленнее. Почему и что делать?» JIT: методы компилируются при первом вызове, и сначала в неоптимизированный tier0. Средства: ReadyToRun при публикации, прогревочные запросы до включения в балансировщик, для коротко живущих процессов — Native AOT. Встречный вопрос: «как докажете, что дело в JIT, а не в базе?» — второй такой же запрос быстрее, а счётчик рантайма показывает, сколько методов скомпилировано.

Что забрать из юнита

  1. Roslyn выдаёт IL и метаданные, машинный код появляется в рантайме — кроме Native AOT.
  2. F5 — это сборка и запуск; Debug выключает оптимизации, замеры только в Release без отладчика.
  3. CLR — набор служб: загрузчик, JIT, GC, типобезопасность, исключения, потоки. P/Invoke — граница с неуправляемым кодом.
  4. JIT многоуровневый: tier0 при первом вызове, tier1 для горячих методов, OSR для долгих циклов, Dynamic PGO по умолчанию с .NET 8.
  5. Первый замер меряет JIT. Для замеров — BenchmarkDotNet.
  6. ReadyToRun ускоряет старт и ничего не ломает; Native AOT убирает JIT ценой динамической загрузки, генерации кода и части рефлексии.
  7. Короткоживущий процесс — кандидат на AOT, долгоживущий сервис — на JIT.
  8. Сборка — единица развёртывания и граница internal; пространство имён — только часть имени.
  9. CTS — какие типы бывают, CLS — какие из них безопасно выставлять наружу для других языков.
  10. Цель курса — .NET 10 LTS (до ноября 2028); .NET 8 и 9 выходят из поддержки 10 ноября 2026.

Practice

Checking here changes nothing: answers start counting once you enrol on the course.

  1. Что физически лежит внутри собранного (обычным dotnet build) Engage.Billing.dll?

  2. Метод `Billing.Total` вызывается в цикле миллион раз (Release, без отладчика). Первый вызов занял в сотни раз больше времени, чем каждый из следующих. Что произошло?

  3. Как устроена многоуровневая компиляция в .NET 10 по умолчанию?

  4. Метод вызван один раз, но внутри у него цикл на десять миллионов итераций. По счётчику вызовов он горячим не станет. Механизм, который переключает такой метод на оптимизированный код прямо посреди цикла, называется (аббревиатура).

  5. Сервис ENGAGE опубликовали с PublishReadyToRun. Что теперь верно?

  6. Утилиту экспорта ENGAGE публикуют с PublishAot. Разложите, что в ней будет работать, а что нет.

    1. Загрузка плагина-форматтера через Assembly.LoadFrom из папки plugins
    2. JsonSerializer.Serialize с JsonSerializerContext из генератора исходного кода
    3. Генерация сериализатора на лету через Reflection.Emit
    4. Вызов функции ОС через [LibraryImport]
    5. Обычные интерфейсы, виртуальные вызовы и обобщения со значимыми типами
  7. Класс `internal sealed class RateCache` объявлен в namespace `Engage.Billing.Rates` сборки Engage.Billing.dll. Откуда он виден (без InternalsVisibleTo)?

  8. Заполните пропуск: в C# ключевое слово `int` — это псевдоним типа из общей системы типов.

  9. Отнесите каждое правило или факт к CTS или к CLS.

    1. Все значимые типы наследуются от System.ValueType
    2. Нельзя выставлять наружу два публичных члена, различающихся только регистром
    3. int в C# — это System.Int32
    4. Беззнаковый uint в публичной сигнатуре библиотеки нежелателен для других языков
  10. Отметьте каждое утверждение: верно или неверно.

    1. .NET 10 — LTS-версия с поддержкой до ноября 2028 года
    2. В конфигурации Debug JIT оптимизирует код так же, как в Release
    3. Dynamic PGO включён по умолчанию начиная с .NET 8
    4. Пространство имён ограничивает видимость internal-классов
    5. Отключив .pdb, можно помешать декомпиляции сборки
    6. Mono сегодня используется как рантайм для мобильных платформ и WebAssembly
  11. В ENGAGE две задачи: (1) HTTP-сервис биллинга, который работает сутками под нагрузкой; (2) утилита экспорта отчёта, которую планировщик запускает на каждого клиента, — живёт пару секунд. Утилита использует System.Text.Json с рефлексией и DI-контейнер со сканированием сборок. Для чего вы предложите Native AOT, для чего нет, и что придётся поменять?

    This one is judged by the author’s rubric — your assistant does that, not the site.

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

    This one is judged by the author’s rubric — your assistant does that, not the site.