Тиск: «форматувальник звітів гальмує»
В 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 знає конкретний процесор і фактичний профіль. На довгоживучому сервісі це окупає відкладену компіляцію.
Опис
Схема слева направо. Узел «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, а не в базі?» — другий такий самий запит швидший, а лічильник рантайму показує, скільки методів скомпільовано.
Що забрати з юніта
- Roslyn видає IL і метадані, машинний код з'являється в рантаймі — крім Native AOT.
- F5 — це збирання і запуск; Debug вимикає оптимізації, заміри лише в Release без налагоджувача.
- CLR — набір служб: завантажувач, JIT, GC, типобезпека, винятки, потоки. P/Invoke — межа з некерованим кодом.
- JIT багаторівневий: tier0 під час першого виклику, tier1 для гарячих методів, OSR для довгих циклів, Dynamic PGO за замовчуванням з .NET 8.
- Перший замір вимірює JIT. Для замірів — BenchmarkDotNet.
- ReadyToRun прискорює старт і нічого не ламає; Native AOT прибирає JIT ціною динамічного завантаження, генерації коду і частини рефлексії.
- Короткоживучий процес — кандидат на AOT, довгоживучий сервіс — на JIT.
- Збірка — одиниця розгортання і межа
internal; простір імен — лише частина імені. - CTS — які типи бувають, CLS — які з них безпечно виставляти назовні для інших мов.
- Мета курсу — .NET 10 LTS (до листопада 2028); .NET 8 і 9 виходять з підтримки 10 листопада 2026.
