Что такое .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
  • С каких приложений спокойнее всего начинать

Содержание

  1. Сначала вывод (коротко)
  2. Сначала — сводные таблицы
    • 2.1. Термины вокруг Native AOT
    • 2.2. Различия JIT, ReadyToRun и Native AOT
  3. Общая картина Native AOT (схема)
  4. Что даёт Native AOT
    • 4.1. Запуск обычно становится быстрее
    • 4.2. Не нужно рассчитывать на предустановленную среду выполнения
    • 4.3. Подходит для ограниченных сред выполнения
  5. В чём сложности Native AOT
    • 5.1. Рефлексия и динамическая генерация кода
    • 5.2. Нужно с самого начала думать о trimming
    • 5.3. Публикация под каждую платформу отдельно
    • 5.4. Windows-десктоп и COM требуют особой осторожности
  6. Минимальные шаги
    • 6.1. csproj
    • 6.2. Публикация (publish)
    • 6.3. Как писать JSON
  7. Случаи, где это подходит
  8. Случаи, где это не подходит
  9. Типичные ловушки
  10. Итог
  11. Источники

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 схематично, получится вот так.

Обычное выполнениеdotnet publish + PublishAotИсходный код C# / .NETIL-сборкиJIT во время выполненияВыполнение приложенияAOT- / trim-анализУдаление неиспользуемого кодаГенерация нативного кодаИсполняемый файл для конкретного 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-приложение от динамической модели выполнения к модели распространения, легко определяемой статически.

Вот пять моментов, на которые стоит обратить внимание.

  1. Native AOT заранее компилирует код в нативный на этапе publish
  2. Хорошо работает для запуска, памяти и распространения
  3. Взамен строг к рефлексии, динамической генерации кода, встроенному COM и коду без поддержки trimming
  4. В качестве первой цели console / worker / небольшой API спокойнее, чем тело десктопного приложения
  5. Важно устранять предупреждения и проверять всё через publish как можно раньше

Native AOT — не стандартный переключатель, который стоит включать для любого .NET-приложения. Но там, где важен запуск, хочется облегчить распространение и сократить предпосылки к среде выполнения, это весьма мощное оружие.

И наоборот, в насыщенном мире WPF / WinForms / COM во многих случаях разумнее пока оставаться на обычном .NET. Если научиться различать эти ситуации, Native AOT перестаёт быть «сложной новой функцией» и становится вариантом с чётко очерченной областью применения.

11. Источники

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что такое 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог