Платформа .NET
Из чего состоит платформа .NET и как код на C# становится работающей программой: версии и их поддержка, CLR и JIT, сборки и пакеты, способы компиляции. Это фундамент для всех остальных тем раздела.
Чем .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,dotnetCLI и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 — последняя версия, которая его поддерживает).
Компромиссы
Миграция «большим взрывом» рискованна, поэтому идут по шагам:
- Перевести проекты на SDK-style и
PackageReference. - Общие библиотеки собрать под
netstandard2.0или сразу под обе платформы. - Найти несовместимые API: platform compatibility analyzer, .NET Upgrade Assistant.
- Переносить хосты. Веб — постепенно: новое 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 NETFRAMEWORKConsole.WriteLine("Скомпилировано под .NET Framework");#elif NET5_0_OR_GREATERConsole.WriteLine("Скомпилировано под .NET 5+");#endif- Почему .NET 5, а не .NET Core 4? — Чтобы не путать с .NET Framework 4.x и показать, что это главная ветка.
- Можно ли запустить приложение на .NET 10 без установленного рантайма? — Да, через self-contained-публикацию или Native AOT.
- Чем заменить AppDomain при миграции? — Изоляцию — процессами или контейнерами, загрузку плагинов —
AssemblyLoadContext. - Как одну библиотеку использовать и во Framework, и в .NET? — Собрать её под
netstandard2.0или под обе платформы сразу. - Почему после миграции строки сортируются иначе? — .NET 5+ использует для глобализации ICU, а не NLS.
Что такое 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 и дальше) ставятся без изменения проекта.
Частая ошибка
Оставаться на версии без поддержки, «потому что работает».
Как устроено
- Каждый ноябрь выходит major-версия; треки LTS и STS чередуются.
- Почти каждый месяц выходит патч. Исправления безопасности — во второй вторник месяца (Patch Tuesday).
- Приложение без встроенного рантайма запускается на последнем установленном патче своей версии. На новую major-версию оно не переходит, пока не задан
RollForward(MajorилиLatestMajor). - 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 # установленные SDKdotnet --list-runtimes # установленные рантаймыdotnet sdk check # что устарело и что вышло из поддержки- Чем LTS отличается от STS по качеству? — Ничем: оба готовы к продакшену, разница только в сроке поддержки.
- Что будет с приложением на .NET 8 после 10 ноября 2026? — Продолжит работать, но перестанет получать исправления безопасности.
- Запустится ли приложение под
net8.0, если установлен только .NET 10? — По умолчанию нет; нуженRollForwardсо значениемMajorилиLatestMajor. - Зачем
global.json? — Зафиксировать версию SDK, чтобы сборка была одинаковой у всех разработчиков и в CI. - Почему в 2025 году STS продлили до 24 месяцев? — Чтобы пакеты из STS-релизов не сокращали поддержку LTS-приложений.
Как выполняется код на C#: IL, CLR, JIT?
Заголовок раздела «Как выполняется код на C#: IL, CLR, JIT?»Что это
- IL — промежуточный язык. Компилятор C# пишет его в сборку
.dllвместе с метаданными — описанием типов и методов. - CLR — среда выполнения: загружает сборки, управляет памятью, исключениями и потоками.
- JIT — часть CLR, которая переводит IL метода в машинный код.
Как это работает
dotnet build— Roslyn компилирует.cs-файлы в IL.dotnet run— запускается CLR и загружает сборку.- Перед первым вызовом метода JIT компилирует его в код для текущего процессора.
- Следующие вызовы идут сразу в готовый машинный код.
Частая ошибка
Думать, что C# интерпретируется, как Python. Процессор всегда выполняет машинный код, просто этот код появляется во время работы программы.
Как устроено
- Запускатель (
dotnet app.dllилиapp.exe) находитhostfxr. Тот читаетapp.runtimeconfig.jsonи выбирает версию рантайма. hostpolicyчитаетapp.deps.json, составляет список сборок приложения и запускает CoreCLR — CLR современного .NET.- CLR загружает сборку и находит точку входа.
- Каждый метод сначала вызывается через заглушку. Она запускает JIT и подменяет себя адресом готового кода.
- Метод, который ни разу не вызвали, не компилируется никогда.
Что ещё даёт 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- Что такое метаданные и зачем они нужны? — Описание типов и членов внутри сборки; на них работают рефлексия, сериализация и проверка типов.
- Компилируется ли вся программа при старте? — Нет, метод компилируется при первом вызове, невызванный — никогда.
- Чем CLR отличается от CoreCLR? — CLR — рантайм .NET Framework, CoreCLR — современного .NET.
- Можно ли в одном решении писать на C# и F#? — Да, оба компилируются в IL и используют общую систему типов.
- Почему первый запрос к сервису медленный? — JIT и загрузка типов; помогают tiered compilation, ReadyToRun и прогрев.
Что такое .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 }}- Чем
netstandard2.0лучшеnetstandard2.1? — 2.0 поддерживает .NET Framework, 2.1 — нет. - Можно ли сделать приложение под .NET Standard? — Нет, это только контракт для библиотек.
- Как NuGet выбирает сборку из пакета с несколькими TFM? — Берёт ближайший TFM, совместимый с проектом.
- Что такое
net10.0-windows? — TFM для конкретной ОС: общий API плюс Windows-only технологии. - Зачем библиотеке цель
net8.0, если уже естьnetstandard2.0? — Новые API, nullable-аннотации BCL, анализаторы совместимости с Native AOT.
Что такое сборка и как NuGet разрешает конфликты версий зависимостей?
Заголовок раздела «Что такое сборка и как NuGet разрешает конфликты версий зависимостей?»Что это
- Сборка — результат компиляции проекта:
MyLib.dllили приложение. Внутри IL-код, метаданные и манифест — имя, версия и список сборок, от которых она зависит. - NuGet-пакет — архив
.nupkgсо сборками под разные платформы (папкиlib/net8.0,lib/netstandard2.0) и списком зависимостей.
Как пользоваться
- Добавить пакет:
dotnet add package Serilog— в.csprojпоявится<PackageReference>. dotnet restore(его запускает иdotnet build) скачивает пакет и его зависимости в общий кеш~/.nuget/packages.- Проект при сборке ссылается на нужные
.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.
Под капотом
- NuGet при restore выбирает версию пакета.
- SDK записывает путь к сборке в
app.deps.json. AssemblyLoadContext.Defaultзагружает сборку по простому имени. Строгое имя в .NET не проверяется и в привязке не участвует.- Загруженная версия должна быть не ниже той, против которой компилировали, иначе —
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 # кто притащил пакет в проект- Чем
AssemblyVersionотличается от версии пакета? — Версия пакета нужна NuGet,AssemblyVersion— загрузчику; они могут не совпадать. - Почему NuGet берёт минимальную версию, а не последнюю? — Ради воспроизводимых сборок: граф не меняется с выходом новых версий.
- Что означает NU1605? — Понижение версии: проект указал версию ниже той, что требует зависимость.
- Нужны ли binding redirects в .NET 8? — Нет, это механизм .NET Framework; в .NET загружается версия из
deps.json, если она не ниже запрошенной. - Как загрузить две версии одной библиотеки? — Через отдельные
AssemblyLoadContext, как это делают плагины.
Что нового в .NET 8–10 и C# 12–14?
Заголовок раздела «Что нового в .NET 8–10 и C# 12–14?»Что это
Каждый релиз .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 expressionpublic 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); }}- Чем primary constructor класса отличается от
record? — У класса параметры не становятся публичными свойствами, аrecordгенерирует свойства, равенство иDeconstruct. - Что даёт
System.Threading.Lockпо сравнению сlockпоobject? — Отдельный тип с APIEnterScope; компилятор генерирует код через него, а не черезMonitor. - Что такое DATAS? — Режим Server GC, который подстраивает число и размер куч под нагрузку; включён по умолчанию с .NET 9.
- Почему удалили
BinaryFormatter? — Небезопасная десериализация; в .NET 9 встроенная реализация всегда бросает исключение. - Можно ли использовать C# 14 в проекте под
net8.0? — Официально нет: версия языка привязана к TFM, а часть фич требует новой BCL.
Что такое tiered compilation, ReadyToRun и Native AOT и когда что применять?
Заголовок раздела «Что такое tiered compilation, ReadyToRun и Native AOT и когда что применять?»Что это
- Tiered compilation — JIT сначала быстро компилирует метод без оптимизаций, а часто вызываемые методы потом перекомпилирует с оптимизациями. Включена по умолчанию с .NET Core 3.0.
- ReadyToRun — машинный код добавляется в сборки при публикации, JIT остаётся.
- Native AOT — всё приложение компилируется в один нативный файл; JIT и установленный .NET не нужны.
Как пользоваться
- ReadyToRun:
dotnet publish -c Release -r linux-x64 -p:PublishReadyToRun=true. - Native AOT:
<PublishAot>true</PublishAot>в.csprojиdotnet publish -c Release -r linux-x64.
Частая ошибка
Игнорировать предупреждения при AOT-публикации: код с рефлексией соберётся, но упадёт уже во время работы.
Как устроено
- Tier 0. Быстрый JIT без оптимизаций или готовый ReadyToRun-код.
- Подсчёт вызовов начинается, когда 100 мс подряд не было новой tier-0-компиляции.
- Dynamic PGO (по умолчанию с .NET 8). Горячий метод проходит через инструментированную версию, которая собирает профиль: какие типы приходят в виртуальные вызовы, какие ветки горячие.
- Tier 1. Метод, вызванный 30 раз, в фоне перекомпилируется с оптимизациями и профилем: девиртуализация, инлайнинг, раскладка кода.
- 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 # оба варианта требуют RIDusing 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 { }- Почему горячий метод не оптимизируется сразу? — Ждёт 30 вызовов и паузы в tier-0-компиляции, чтобы не тормозить старт.
- Что будет с
Reflection.Emitв Native AOT? — Не поддерживается:RuntimeFeature.IsDynamicCodeSupportedравноfalse. - Чем ReadyToRun отличается от Native AOT? — ReadyToRun добавляет машинный код к IL и оставляет JIT, Native AOT убирает JIT полностью.
- Зачем нужен OSR? — Чтобы метод с долгим циклом, вызванный один раз, не застревал в неоптимизированном коде.
- Когда Native AOT — плохой выбор? — Много рефлексии, плагины, MVC, генерация кода во время работы, библиотеки без поддержки AOT.