Что такое .NET Native AOT — чем он отличается от JIT и trimming
· Го Комура · C#, .NET, Native AOT, Публикация, Проектирование
В статье Как превратить C# в нативную DLL с помощью Native AOT — вызов из C/C++ через UnmanagedCallersOnly мы уже рассказывали, как с помощью Native AOT вызывать C# из C/C++. Но, если честно, было бы правильнее сначала рассказать, что такое Native AOT как таковой. Порядок статей получился слегка перепутанным.
Разговор о Native AOT почти всегда начинается с путаницы в терминах.
- Это про отказ от JIT?
- Чем это отличается от self-contained и single-file?
- Это то же семейство, что и ReadyToRun?
- Что вообще происходит, когда сыпется куча предупреждений trimming?
- Можно ли одинаково спокойно использовать это в WPF / WinForms / ASP.NET Core?
Если всё это свалить в одну кучу, Native AOT начинает казаться то «магией, которая просто всё ускоряет», то, наоборот, «пугающей штукой, полной ограничений». И то, и другое — упрощение.
В этой статье, ориентируясь в основном на практику .NET 8 и более поздних версий, мы сначала разберём четыре вещи.
- Что такое Native AOT на самом деле
- Что он даёт и в чём сложности
- Чем он отличается от ReadyToRun и trimming
- С каких приложений спокойнее всего начинать
Содержание
- Сначала вывод (коротко)
- Сначала — сводные таблицы
- 2.1. Термины вокруг Native AOT
- 2.2. Различия JIT, ReadyToRun и Native AOT
- Общая картина Native AOT (схема)
- Что даёт Native AOT
- 4.1. Запуск обычно становится быстрее
- 4.2. Не нужно рассчитывать на предустановленную среду выполнения
- 4.3. Подходит для ограниченных сред выполнения
- В чём сложности Native AOT
- 5.1. Рефлексия и динамическая генерация кода
- 5.2. Нужно с самого начала думать о trimming
- 5.3. Публикация под каждую платформу отдельно
- 5.4. Windows-десктоп и COM требуют особой осторожности
- Минимальные шаги
- 6.1.
csproj - 6.2. Публикация (publish)
- 6.3. Как писать JSON
- 6.1.
- Случаи, где это подходит
- Случаи, где это не подходит
- Типичные ловушки
- Итог
- Источники
1. Сначала вывод (коротко)
- Native AOT — это способ публикации .NET-приложения, при котором код заранее компилируется в нативный код на этапе publish.
- Поскольку JIT во время выполнения не используется, время запуска и потребление памяти обычно улучшаются, а приложение легче распространять в окружениях без установленной среды выполнения .NET.
- Однако при этом ухудшается совместимость со свободной рефлексией, динамической генерацией кода, встроенным COM и библиотеками без поддержки trimming.
- Иными словами, это не волшебная кнопка ускорения, а модель публикации, которая ради удобства запуска, распространения и особенностей среды выполнения слегка отказывается от динамического мира в пользу статического.
Native AOT — это «механизм распространения .NET в виде, похожем на нативное приложение», а не просто галочка для ускорения компиляции.
2. Сначала — сводные таблицы
2.1. Термины вокруг Native AOT
Если сразу разделить эти понятия, дальше будет проще.
| Термин | Что делает | Связь с Native AOT |
|---|---|---|
| JIT | Генерирует нативный код из IL во время выполнения | Native AOT выполняет эту работу заранее |
| self-contained | Распространяет вместе с приложением весь необходимый для работы набор .NET | Native AOT относится к этому же семейству подходов |
| single-file | Собирает дистрибутив в один файл | Не относится к сути Native AOT напрямую, но по внешнему виду результат часто похож |
| trimming | Удаляет неиспользуемый код | В Native AOT практически обязательное условие |
| ReadyToRun | Сохраняет IL, но частично выполняет работу JIT заранее | Похоже по названию, но по сути другая вещь |
| source generator | Переносит динамическую обработку из времени выполнения в генерацию кода на этапе сборки | Хорошо сочетается с Native AOT |
Путаница чаще всего возникает из-за того, что Native AOT — это не отдельная функция, а модель публикации, которая работает в связке с self-contained, trimming, source generation и публикацией с фиксированным RID.
2.2. Различия JIT, ReadyToRun и Native AOT
И здесь быстрее всего разобраться по одной таблице.
| Критерий | Обычное выполнение с JIT | ReadyToRun | Native AOT |
|---|---|---|---|
| JIT во время выполнения | Используется | Иногда всё ещё используется | Не используется |
| Содержимое дистрибутива | В основном IL | IL + заранее сгенерированный код | В основном нативный исполняемый файл |
| Запуск | Базовый уровень | Легко улучшить | Улучшается очень сильно |
| Совместимость | Самая широкая | Широкая | Сильно ограничена |
| Динамические функции | Легко использовать | В целом легко использовать | Много ограничений |
| Для какой цели подходит | Обычная разработка на .NET в целом | Первый шаг к улучшению запуска | Целенаправленная работа над запуском, распространением и ограниченными средами |
Если ReadyToRun движется в сторону «немного облегчить работу JIT», то Native AOT — в сторону «вообще не полагаться на JIT во время выполнения». Хотя в названии обоих фигурирует AOT, по сути настрой у них совсем разный.
3. Общая картина Native AOT (схема)
Если грубо изобразить Native AOT схематично, получится вот так.
flowchart LR
Src["Исходный код C# / .NET"] --> IL["IL-сборки"]
IL -->|Обычное выполнение| JIT["JIT во время выполнения"]
JIT --> Run1["Выполнение приложения"]
IL -->|dotnet publish + PublishAot| Analyze["AOT- / trim-анализ"]
Analyze --> Trim["Удаление неиспользуемого кода"]
Trim --> AOT["Генерация нативного кода"]
AOT --> Run2["Исполняемый файл для конкретного RID"]
Обычный .NET сначала создаёт IL, а затем во время выполнения JIT-компилирует только то, что нужно. Native AOT переносит значительную часть этой последующей стадии на этап publish.
Здесь важно то, что уже на этапе publish инструментарию нужно «знать практически весь код, который потребуется во время выполнения». Именно тут меняется вся атмосфера.
- Искать типы во время выполнения
- Порождать код во время выполнения
- Загружать сборки (Assembly) во время выполнения
- Откладывать разрешение зависимостей во время выполнения в духе «как-нибудь да решится»
Код, написанный в таком стиле, внезапно перестаёт ладить с Native AOT.
4. Что даёт Native AOT
4.1. Запуск обычно становится быстрее
Самый очевидный эффект от Native AOT — это, конечно же, запуск.
- CLI-инструменты
- Короткоживущие процессы
- Запуск в духе serverless
- Запуск и замена контейнеров
- Инструменты мониторинга и небольшие резидентные процессы
В таких сценариях стоимость JIT хорошо заметна, и поскольку Native AOT переносит её заранее, самый первый момент запуска становится легче.
Потребление памяти тоже обычно улучшается, что выгодно, когда нужно уместить как можно больше экземпляров на том же оборудовании. Особенно заметно это накапливается в облаке, где одновременно поднимается множество копий одного и того же процесса.
4.2. Не нужно рассчитывать на предустановленную среду выполнения
Приложение, опубликованное через Native AOT, легче запускать в окружениях без установленной среды выполнения .NET.
Это неброское, но весьма значимое преимущество.
- Не хочется говорить получателю: «сначала установите .NET 9 Runtime»
- Хочется сделать образ контейнера компактнее
- Хочется просто положить один небольшой инструмент и запустить его
- Не хочется разрешать JIT в среде выполнения — или это вообще запрещено
В таких случаях само отсутствие предпосылки «нужно отдельно подготовить среду выполнения» сильно упрощает жизнь.
«Среда выполнения не нужна» здесь означает, что получателю не требуется отдельно устанавливать .NET. Это не значит, что эквивалент runtime полностью исчезает из самого приложения.
4.3. Подходит для ограниченных сред выполнения
Поскольку Native AOT не использует JIT во время выполнения, его проще запускать и в средах, где JIT не разрешён.
Этот эффект сильнее проявляется в облаке, контейнерах и мобильных сценариях, чем на десктопе. Тем не менее и в контексте разработки под Windows это ощутимый плюс — в смысле сокращения лишних предпосылок на стороне получателя.
5. В чём сложности Native AOT
5.1. Рефлексия и динамическая генерация кода
Именно здесь находится главное ограничение Native AOT.
- Динамическая загрузка вроде
Assembly.LoadFile - Генерация кода во время выполнения вроде
System.Reflection.Emit - Рефлексия, которая без ограничений обходит типы во время выполнения
- Код, который свободно собирает generic-типы во время выполнения
Такой код трудно однозначно определить на этапе publish, поэтому именно он становится источником предупреждений AOT.
Разумеется, дело не настолько просто, что одна-единственная строка рефлексии сразу всё ломает. Но тенденция неизменна: чем сильнее дизайн склоняется к принципу «посмотрим во время выполнения и решим», тем тяжелее становится.
Чаще всего среди имён предупреждений встречаются относящиеся к семейству RequiresDynamicCode.
Это означает «данный вызов может сломаться под AOT», поэтому безопаснее не подавлять такие предупреждения бездумно.
При работе с Native AOT удобно держать в голове простое правило: уменьшать «сообразительность во время выполнения» и увеличивать «явность на этапе сборки».
5.2. Нужно с самого начала думать о trimming
Native AOT тесно связан с trimming. Здесь легко упустить, что значение имеет не только ваш собственный код, но и то, как написаны используемые библиотеки-зависимости.
Особого внимания заслуживают следующие вещи.
- Сериализаторы на основе рефлексии
- DI-контейнеры и плагинные архитектуры, собирающие типы через сканирование во время выполнения
- Механизмы, которые находят тип по строковому имени и создают его экземпляр
- Библиотеки, полагающиеся на динамические прокси или генерацию IL
Если здесь появляются предупреждения, а вы решаете «раз publish прошёл, значит, всё в порядке», позже это может выйти боком. В Native AOT предупреждения почти всегда стоит читать всерьёз.
5.3. Публикация под каждую платформу отдельно
Native AOT публикуется с фиксированным RID (Runtime Identifier).
То есть собранное для win-x64 нельзя просто взять и запустить на linux-x64.
- Windows x64
- Windows Arm64
- Linux x64
- Linux Arm64
- macOS Arm64
Предполагается, что для каждой целевой платформы нужно готовить отдельный публикуемый артефакт.
В этом смысле ощущения гораздо ближе к «настоящему нативному приложению», чем при обычной framework-dependent публикации .NET.
5.4. Windows-десктоп и COM требуют особой осторожности
В контексте работы KomuraSoft этот момент особенно важен.
В Windows у Native AOT нет встроенной поддержки COM. Более того, WPF плохо ладит с trimming, а WinForms сильно зависит от встроенного COM-marshalling, поэтому по крайней мере на данный момент к обоим стоит относиться очень осторожно как к «первому кандидату на Native AOT».
Иными словами,
- Сразу переводить на Native AOT само тело приложения WPF / WinForms
- Переносить COM interop как есть, с привычными по обычному .NET подходами
Такие шаги обычно даются тяжело.
И наоборот,
- Консольные приложения
- Worker-приложения
- Небольшие веб-API
- Компоненты для взаимодействия с нативным кодом, которые легко свести к границе C-функций
куда более естественные точки входа.
Если COM необходим, в некоторых случаях разумнее остаться на JIT либо перепроектировать решение с расчётом на ComWrappers / source-generated COM.
6. Минимальные шаги
6.1. csproj
Сначала добавим PublishAot в файл проекта.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PublishAot>true</PublishAot>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Для примера достаточно net8.0. Сам подход почти не меняется и в .NET 9 / 10.
Важно не добавлять это временно только в командную строку dotnet publish, а
держать флаг в проекте постоянно, чтобы регулярно видеть анализ на этапе build / publish.
При этом добавление <PublishAot>true</PublishAot> не превращает в Native AOT сразу и обычные локальные запуски.
Повседневный dotnet run и обычное выполнение по-прежнему работают через JIT, а настоящая компиляция Native AOT происходит именно на этапе publish.
6.2. Публикация (publish)
Например, для Windows x64 это выглядит так.
dotnet publish -c Release -r win-x64
Для Linux x64 — вот так.
dotnet publish -c Release -r linux-x64
Результат публикации привязан к конкретному RID. Восприятие смещается от «одной DLL для .NET, которая работает везде» к «исполняемому файлу, собранному под конкретные ОС и архитектуру».
Если заходите со стороны веб-API, проще всего начать с шаблона, изначально рассчитанного на Native AOT.
dotnet new webapiaot -o MyFirstAotWebApi
Для worker — вот этот.
dotnet new worker -o WorkerWithAot --aot
6.3. Как писать JSON
Незаметно частая точка столкновения с Native AOT — это JSON.
При обычном использовании System.Text.Json легко скатывается к рефлексии, поэтому спокойнее опираться на source generation.
using System.Text.Json;
using System.Text.Json.Serialization;
[JsonSerializable(typeof(AppConfig))]
internal partial class AppJsonContext : JsonSerializerContext
{
}
public sealed class AppConfig
{
public string? Name { get; init; }
public int RetryCount { get; init; }
}
var config = new AppConfig
{
Name = "sample",
RetryCount = 3
};
string json = JsonSerializer.Serialize(config, AppJsonContext.Default.AppConfig);
На практике проще запомнить не как «сделать код совместимым с Native AOT», а как ориентир: «не заставлять среду выполнения искать типы» — это редко подводит.
7. Случаи, где это подходит
Native AOT особенно приятно ложится в такие сценарии.
- CLI-инструменты, где главное — запуск
- Небольшие API, разворачиваемые в больших количествах в контейнерах
- Worker’ы / фоновые службы
- Serverless-сценарии и короткоживущие процессы
- Небольшие .NET-компоненты, встраиваемые в нативные приложения
- Ситуации, где не хочется требовать предустановки среды выполнения .NET
Общее у них то, что границы относительно чёткие, и динамические механизмы легко сократить.
8. Случаи, где это не подходит
И наоборот, есть чёткие случаи, где Native AOT изначально не стоит делать основной ставкой.
- Тело существующего крупного приложения на WPF / WinForms
- Архитектуры, построенные на встроенном COM interop
- Приложения, где ключевую роль играет загрузка плагинов во время выполнения
- Конфигурации с сильной зависимостью от фреймворков, ищущих типы через рефлексию
- Библиотеки, для которых
System.Reflection.Emitили динамические прокси — обычное дело - Архитектуры с C++/CLI посередине
В таких случаях разумнее остаться на обычном .NET с JIT, использовать ReadyToRun или пересмотреть границы архитектуры.
9. Типичные ловушки
Напоследок соберём ловушки, в которые легко попасть при первом знакомстве с Native AOT.
- Относиться к предупреждениям publish несерьёзно
- Как уже говорилось, предупреждения Native AOT нужно читать всерьёз.
- Build проходит, а publish ломается
- На этапе publish анализ по-настоящему охватывает и библиотеки-зависимости, поэтому некоторые вещи становятся видны только здесь.
- Относиться к ReadyToRun и Native AOT одинаково
- Названия похожи, но сила ограничений сильно различается.
- Начинать сразу с тела десктопного приложения
- Спокойнее начать с консольного приложения / worker’а / небольшого API.
- Писать JSON или привязку конфигурации в привычном стиле
- Код, рассчитанный на рефлексию, аукнется позже.
- Распространять артефакт так, будто он платформонезависим
- Дистрибутив Native AOT привязан к конкретному RID.
- Считать, что «Native AOT = всё становится быстрее»
- Главные герои здесь — запуск, распространение и среда выполнения. Если упустить это, ожидания разойдутся с реальностью.
В Native AOT dotnet publish, а не dotnet build, играет роль настоящего судьи.
Если начать регулярно его запускать пораньше, проблем на поздних этапах будет заметно меньше.
10. Итог
Если сказать одной фразой, Native AOT — это механизм, который смещает .NET-приложение от динамической модели выполнения к модели распространения, легко определяемой статически.
Вот пять моментов, на которые стоит обратить внимание.
- Native AOT заранее компилирует код в нативный на этапе publish
- Хорошо работает для запуска, памяти и распространения
- Взамен строг к рефлексии, динамической генерации кода, встроенному COM и коду без поддержки trimming
- В качестве первой цели console / worker / небольшой API спокойнее, чем тело десктопного приложения
- Важно устранять предупреждения и проверять всё через publish как можно раньше
Native AOT — не стандартный переключатель, который стоит включать для любого .NET-приложения. Но там, где важен запуск, хочется облегчить распространение и сократить предпосылки к среде выполнения, это весьма мощное оружие.
И наоборот, в насыщенном мире WPF / WinForms / COM во многих случаях разумнее пока оставаться на обычном .NET. Если научиться различать эти ситуации, Native AOT перестаёт быть «сложной новой функцией» и становится вариантом с чётко очерченной областью применения.
11. Источники
- Native AOT deployment overview - .NET
- Native AOT deployment overview - .NET (на японском)
- Introduction to AOT warnings - .NET
- Prepare .NET libraries for trimming - .NET
- Known trimming incompatibilities - .NET
- How to use source generation in System.Text.Json - .NET
- ASP.NET Core support for Native AOT
- ReadyToRun deployment overview - .NET
- Building native libraries - .NET
- ComWrappers source generation - .NET
- Похожая статья: Как превратить C# в нативную DLL с помощью Native AOT — вызов из C/C++ через UnmanagedCallersOnly
- Похожая статья: Почему C++/CLI-обёртка — сильный выбор для использования нативных DLL из C# — сравнение с P/Invoke
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Как выбрать межпроцессное взаимодействие в Windows — таблица решений: именованные каналы / TCP / gRPC / разделяемая память / COM
Разбираем, как выбрать способ взаимодействия Windows-приложений друг с другом: сильные стороны и ловушки именованных каналов, локального ...
Как выбрать место хранения данных Windows-приложения — таблица решений для SQLite / JSON / реестра / Access
Где и в каком формате хранить данные Windows-приложения. Разбираем выбор между AppData и ProgramData, а также сильные стороны и подводные...
Что такое .NET Generic Host — основа для DI, конфигурации и логирования
Разбираем роль Generic Host через связь DI, конфигурации, логирования, IHostedService и BackgroundService — и с практической точки зрения...
Как выбирать между тремя таймерами .NET - PeriodicTimer/Timer/DispatcherTimer
Разбираем различия между PeriodicTimer, System.Threading.Timer и DispatcherTimer и то, как выбирать между ними для async-обработки, callb...
Как вызвать C# Native AOT DLL из C/C++
Разбираем, как публиковать библиотеку классов C# в виде нативной DLL через Native AOT и вызывать точки входа UnmanagedCallersOnly из C/C+...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Совместимость 32 и 64 бит
Совместимость 32/64 бит, нативные границы и решения по проектированию Windows.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Сопровождение и модернизация ПО Windows
Безопасно добавляем функции, сопровождаем и поэтапно модернизируем существующее ПО Windows.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Что такое Native AOT?
- Это способ публикации .NET-приложения, при котором код заранее компилируется в нативный код на этапе publish и распространяется в таком виде. Поскольку JIT во время выполнения не используется, время запуска и потребление памяти обычно улучшаются, а приложение легче распространять в окружениях без установленной среды выполнения .NET. Взамен ухудшается совместимость со свободной рефлексией, динамической генерацией кода, встроенным COM и библиотеками без поддержки trimming. Это не волшебная кнопка ускорения, а модель публикации, которая ради удобства запуска, распространения и особенностей среды выполнения слегка отказывается от динамического мира в пользу статического.
- Чем Native AOT отличается от ReadyToRun?
- ReadyToRun сохраняет IL и лишь частично переносит работу JIT на более ранний этап, поэтому в некоторых случаях JIT во время выполнения всё равно используется. Совместимость остаётся широкой, а динамические функции по-прежнему в основном удобны в использовании. Native AOT же вообще не рассчитывает на JIT во время выполнения: дистрибутив состоит в основном из нативного исполняемого файла, запуск улучшается значительно сильнее, но зато и ограничения становятся жёстче. Хотя в обоих названиях фигурирует AOT, направление у них разное: ReadyToRun движется в сторону «немного облегчить работу JIT», а Native AOT — в сторону «не рассчитывать на JIT вообще», и по духу они сильно различаются.
- Можно ли использовать Native AOT в приложениях на WPF или WinForms?
- На данный момент к этому стоит относиться очень осторожно. У Native AOT в Windows нет встроенной поддержки COM, WPF плохо ладит с trimming, а WinForms сильно зависит от встроенного COM-marshalling, поэтому оба варианта плохо подходят в качестве первого кандидата на Native AOT. Если COM необходим, в некоторых случаях разумнее остаться на JIT либо перепроектировать решение с расчётом на ComWrappers / source-generated COM. В качестве точки входа куда естественнее подходят консольные приложения, worker'ы и небольшие веб-API.
- Для каких приложений подходит Native AOT?
- Он хорошо подходит для CLI-инструментов, где главное — запуск, небольших API, разворачиваемых в больших количествах в контейнерах, worker'ов и фоновых служб, serverless-сценариев и короткоживущих процессов, небольших .NET-компонентов, встраиваемых в нативные приложения, а также ситуаций, где не хочется требовать предустановки среды выполнения .NET. Общее у них то, что границы относительно чёткие и динамические механизмы легко сократить. И наоборот, он не подходит для приложений, где ключевую роль играет загрузка плагинов во время выполнения, или для конфигураций с сильной зависимостью от фреймворков, ищущих типы через рефлексию.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки