Что такое .NET Native AOT — чем он отличается от JIT и trimming
· Обновлено: · Го Комура · C#, .NET, Native AOT, Публикация, Проектирование
История изменений (1 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619675)
Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.
Го Комура (2026). Что такое .NET Native AOT — чем он отличается от JIT и trimming. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619675 https://comcomponent.com/ru/blog/2026/03/13/001-dotnet-native-aot-what-is/
- DOI (последняя версия)
- 10.5281/zenodo.21619675
- DOI (эта версия)
- 10.5281/zenodo.21619676
В статье Как вызвать C# Native AOT DLL из C/C++ уже разобрано, как с помощью Native AOT вызывать C# из C/C++. Но по-честному сначала стоило объяснить, что такое Native AOT. Порядок статей получился слегка перевёрнутым.
Разговор о Native AOT почти всегда начинается с путаницы в терминах.
- Это про отказ от JIT?
- Чем это отличается от self-contained и single-file?
- Это то же семейство, что и ReadyToRun?
- Что происходит, когда сыплется куча предупреждений trimming?
- Можно ли с тем же спокойствием использовать это в WPF / WinForms / ASP.NET Core?
Если свалить всё в одну кучу, Native AOT кажется то «магией, которая просто ускоряет», то «страшилкой из одних ограничений». Оба взгляда слишком грубые.
flowchart TB
accTitle: Как путаница в терминах порождает неверную картину
accDescr: Если смешать JIT, self-contained, ReadyToRun и trimming, Native AOT кажется либо магией ускорения, либо страшилкой из ограничений; оба взгляда слишком грубые.
mix["Термины свалены в одну кучу"] --> m1["Кажется магией ускорения"]
mix --> m2["Кажется страшилкой из ограничений"]
sort["Сначала разделить слова"] --> fair["Становится выбором с понятным местом"]
Рис. 1: Источник путаницы — смешение терминов. Начинать нужно с того, чтобы развести слова.
В этой статье, опираясь в основном на текущую практику .NET 8 и новее, сначала разберём четыре вещи.
- Что такое Native AOT на самом деле
- Что он даёт и где становится жёстким
- Чем он отличается от ReadyToRun и trimming
- С каких приложений спокойнее начинать
Содержание
- Сначала вывод (коротко)
- Сначала — сводные таблицы
- 2.1. Термины вокруг Native AOT
- 2.2. Различия JIT, ReadyToRun и Native AOT
- Общая картина Native AOT (схема)
- Что даёт Native AOT
- 4.1. Запуск обычно становится легче
- 4.2. Не нужно рассчитывать на заранее установленный runtime
- 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.
- Где это уместно
- Где это не стоит
- Типичные ловушки
- Итог
- Источники
Карта знаний этой статьи
.NET Native AOT — это способ публикации, при котором приложение заранее компилируется в нативный код на этапе publish и не использует JIT во время выполнения. Так проще сократить время запуска и потребление памяти и проще распространять приложение в среды без установленного runtime .NET. ReadyToRun оставляет IL и лишь частично переносит работу JIT на более ранний этап; Native AOT на JIT во время выполнения вообще не рассчитывает, поэтому trimming почти обязателен, а свободная рефлексия, динамическая генерация кода и built-in COM сочетаются с ним плохо. В Windows у Native AOT нет built-in COM: WPF плохо совместим с trimming, WinForms сильно зависит от built-in COM marshalling, и в обоих случаях поддержка trimming отключена в .NET SDK, поэтому как первый кандидат на Native AOT их стоит рассматривать осторожно. Если COM нужен, сильный вариант — перепроектировать решение вокруг ComWrappers.
flowchart LR
accTitle: Карта знаний .NET Native AOT
accDescr: Схема, которая показывает, чем Native AOT отличается от JIT и ReadyToRun, как он сталкивается с trimming, рефлексией и built-in COM, и почему WPF и WinForms требуют осторожности
native_aot["Native AOT"]
jit_compilation["JIT-компиляция (Just-In-Time)"]
readytorun["ReadyToRun"]
trimming["trimming (обрезка)"]
reflection_dotnet["рефлексия (.NET)"]
dynamic_code_generation["динамическая генерация кода (Reflection.Emit и др.)"]
builtin_com_interop["built-in COM interop"]
comwrappers["ComWrappers / source-generated COM"]
wpf["WPF"]
windows_forms["Windows Forms"]
source_generator["source generator"]
system_text_json_source_gen["source generation в System.Text.Json"]
self_contained_deployment["self-contained публикация"]
single_file_deployment["single-file публикация"]
dotnet[".NET (начиная с Core)"]
native_aot -.->|"несовместимо с"| jit_compilation
readytorun -.->|"требует"| jit_compilation
native_aot -->|"требует"| trimming
native_aot -.->|"несовместимо с"| reflection_dotnet
native_aot -->|"несовместимо с"| dynamic_code_generation
native_aot -->|"несовместимо с"| builtin_com_interop
comwrappers -->|"рекомендуется для"| native_aot
wpf -->|"несовместимо с"| trimming
windows_forms -->|"требует"| builtin_com_interop
wpf -->|"не рекомендуется"| native_aot
windows_forms -->|"не рекомендуется"| native_aot
native_aot -->|"использует"| source_generator
system_text_json_source_gen -->|"снижает"| reflection_dotnet
native_aot -->|"требует"| self_contained_deployment
native_aot -.->|"использует"| single_file_deployment
native_aot -->|"требует"| dotnet
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 16, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
1. Сначала вывод (коротко)
- Native AOT — способ публикации .NET-приложения: на этапе publish код заранее компилируется в нативный и уже так распространяется.
- JIT во время выполнения не используется, поэтому время запуска и потребление памяти обычно улучшаются, а приложение проще раздавать в среды без установленного runtime .NET.
- Но свободная рефлексия, динамическая генерация кода, built-in COM и библиотеки без поддержки trimming сочетаются с ним плохо.
- То есть это не волшебная кнопка ускорения, а модель публикации, которая ради запуска, распространения и среды выполнения чуть отступает от динамического мира в сторону статического.
Native AOT — это механизм, чтобы раздавать .NET в виде, близком к нативному приложению, а не галочка «ускорить компиляцию».
flowchart TB
accTitle: Что получаем и от чего отказываемся
accDescr: Native AOT за счёт предварительной компиляции на этапе publish даёт более лёгкий запуск, меньшее потребление памяти и публикацию без отдельного runtime, но взамен отказывается от свободной рефлексии, динамической генерации кода и built-in COM.
aot["Native AOT"] --> gain["Что получаем"]
aot --> lose["От чего отказываемся"]
gain --> g1["Лёгкий запуск, память и распространение"]
lose --> l1["Свободная рефлексия"]
lose --> l2["Динамическая генерация кода и built-in COM"]
Рис. 2: Это не магия ускорения, а обмен: чуть отойти от динамического мира в сторону статического.
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 и publish с фиксированным RID.
flowchart TB
accTitle: Механизмы, с которыми Native AOT работает вместе
accDescr: Native AOT — не одна функция, а модель публикации, которая работает вместе с self-contained, trimming, source generation и publish с фиксированным RID.
aot["Native AOT (модель публикации)"] --> s1["Семейство self-contained"]
aot --> s2["trimming почти обязателен"]
aot --> s3["Хорошо сочетается с source generator"]
aot --> s4["publish с фиксированным RID"]
Рис. 3: Native AOT — не отдельная функция, а модель публикации в связке с соседними механизмами.
2.2. Различия JIT, ReadyToRun и Native AOT
И здесь быстрее один раз посмотреть таблицу.
| Критерий | Обычное выполнение с JIT | ReadyToRun | Native AOT |
|---|---|---|---|
| JIT во время выполнения | Используется | Иногда всё ещё используется | Не используется |
| Содержимое дистрибутива | В основном IL | IL + заранее сгенерированный код | В основном нативный исполняемый файл |
| Запуск | Базовый уровень | Легко улучшить | Улучшается очень заметно |
| Совместимость | Самая широкая | Широкая | Ограничения жёсткие |
| Динамические возможности | Легко использовать | В целом легко использовать | Много ограничений |
| Для какой цели подходит | Обычная разработка на .NET в целом | Сначала улучшить запуск | Целенаправленно взять запуск, распространение и ограниченные среды |
Если ReadyToRun — это «чуть разгрузить JIT», то Native AOT — «вообще не рассчитывать на JIT во время выполнения». Хотя в обоих названиях есть AOT, по сути это совсем разные вещи.
flowchart TB
accTitle: Разные направления ReadyToRun и Native AOT
accDescr: ReadyToRun оставляет IL и чуть переносит работу JIT на более ранний этап, Native AOT на JIT во время выполнения не рассчитывает; одно и то же слово AOT скрывает совсем разный подход.
q{"Что делать с JIT?"}
q -->|"Чуть разгрузить"| r2r["ReadyToRun (IL остаётся)"]
q -->|"Не рассчитывать на него"| aot["Native AOT (нативный код в центре)"]
r2r --> soft["Совместимость остаётся широкой"]
aot --> hard["Запуск быстрый, ограничения жёсткие"]
Рис. 4: Даже с одним словом AOT «разгрузить JIT» и «не рассчитывать на JIT» — разные вещи.
Если поверх этого свести «какую форму публикации выбрать» на одну схему, получится так.
flowchart TD
accTitle: Как выбрать форму публикации
accDescr: Сначала — можно ли поставить runtime .NET на стороне получателя, затем — насколько можно сократить динамические механизмы.
S["Нужно выбрать форму публикации"] --> Q1{"Можно ли поставить runtime .NET у получателя?"}
Q1 -- "Можно" --> Q2{"Нужно ли поджать время запуска?"}
Q2 -- "Не настолько" --> P1["framework-dependent<br/>обычная публикация"]
Q2 -- "Нужно" --> P2["ReadyToRun<br/>улучшить запуск, сохранив совместимость"]
Q1 -- "Не хочется / нельзя" --> Q3{"Есть зависимость от reflection, динамической генерации кода, built-in COM?"}
Q3 -- "Есть" --> P3["self-contained<br/>при необходимости собрать в single-file"]
Q3 -- "Нет" --> Q4{"Цель — console / worker / небольшой API?"}
Q4 -- "Да" --> P4["Native AOT"]
Q4 -- "Нет" --> P5["Сначала self-contained<br/>сократить динамические зависимости и вернуться к вопросу"]
Рис. 5: Как выбирать форму публикации. Сначала — можно ли поставить runtime, затем — можно ли сократить динамические механизмы.
Первая развилка — можно ли поставить runtime на стороне получателя, следующая — насколько можно сократить динамические механизмы. self-contained и single-file — про «как раздавать», ReadyToRun и Native AOT — про «когда строить нативный код», поэтому на практике их комбинируют.
3. Общая картина Native AOT (схема)
Если грубо нарисовать Native AOT, получится так.
flowchart LR
accTitle: Общая схема Native AOT
accDescr: Обычное выполнение делает JIT во время работы, а Native AOT переносит анализ, удаление лишнего кода и генерацию нативного кода на этап publish.
Src["Исходный код C# / .NET"] --> IL["IL-сборки"]
IL -->|Обычное выполнение| JIT["JIT во время выполнения"]
JIT --> Run1["Выполнение приложения"]
IL -->|dotnet publish + PublishAot| Analyze["AOT- / trim-анализ"]
Analyze --> Trim["Удаление неиспользуемого кода"]
Trim --> AOT["Генерация нативного кода"]
AOT --> Run2["Исполняемый файл для конкретного RID"]
Рис. 6: Обычное выполнение делает JIT во время работы, Native AOT переносит анализ, обрезку и генерацию нативного кода на этап publish.
Обычный .NET сначала строит IL, а во время выполнения JIT-компилирует только нужное. Native AOT переносит большую часть этой поздней стадии на этап publish.
Здесь важно, что уже на этапе publish нужно знать почти весь код, который понадобится во время выполнения. Именно здесь меняются допустимые предпосылки к коду.
- Находить типы во время выполнения
- Порождать код во время выполнения
- Загружать сборки (Assembly) во время выполнения
- Откладывать разрешение «как-нибудь потом разберёмся»
Такой стиль внезапно перестаёт сочетаться с Native AOT.
flowchart TB
accTitle: На этапе publish нужно знать почти всё
accDescr: Native AOT на этапе publish должен знать почти весь код, который понадобится во время выполнения, поэтому поиск типов, порождение кода, загрузка Assembly и отложенное разрешение сочетаются с ним плохо.
need["На publish зафиксировать нужный код"] --> ng1["Искать типы во время выполнения"]
need --> ng2["Порождать код во время выполнения"]
need --> ng3["Читать Assembly во время выполнения"]
need --> ng4["Обходиться отложенным разрешением"]
ng1 -.-> bad["Всё это плохо сочетается с AOT"]
Рис. 7: Главная смена предпосылки. Чем сильнее код «решает во время выполнения», тем сильнее он сталкивается с Native AOT.
4. Что даёт Native AOT
4.1. Запуск обычно становится легче
Самый заметный эффект Native AOT — всё же запуск.
- CLI-инструменты
- Короткоживущие процессы
- Запуск в духе serverless
- Запуск и замена контейнеров
- Инструменты мониторинга и небольшие постоянно живущие процессы
В таких сценариях стоимость JIT хорошо видна, и Native AOT переносит её заранее, поэтому первый момент запуска становится легче.
Потребление памяти тоже обычно улучшается — это выгодно, когда на том же железе нужно уместить больше экземпляров. Особенно разница постепенно накапливается в облаке, где одновременно поднимается много копий одного процесса.
4.2. Не нужно рассчитывать на заранее установленный runtime
Приложение, опубликованное через Native AOT, проще запускать в средах без установленного runtime .NET.
Это скромно звучит, но на практике это большой плюс.
- Не хочется говорить получателю: «сначала поставьте .NET 9 Runtime»
- Хочется сделать образ контейнера тоньше
- Хочется положить один небольшой инструмент и просто запустить
- Не хочется разрешать JIT в среде выполнения — или это запрещено
В таких случаях уже само отсутствие предпосылки «runtime нужно готовить отдельно» сильно упрощает разговор.
«Runtime не нужен» здесь значит: получателю не требуется отдельно ставить .NET. Это не значит, что эквивалент runtime полностью исчезает из самого приложения.
flowchart TB
accTitle: Что на самом деле значит «runtime не нужен»
accDescr: «Runtime не нужен» для Native AOT значит, что получателю не нужно отдельно ставить .NET, а не то, что эквивалент runtime полностью исчезает из приложения.
word["Слова «runtime не нужен»"] --> ok["Получателю не нужно ставить .NET"]
word -.-> ngx["Эквивалент runtime из приложения не исчезает"]
ok --> merit["Меньше предпосылок к распространению и запуску"]
Рис. 8: «Runtime не нужен» — про сторону получателя. Эквивалент 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 удобно держать в голове простое правило: меньше «сообразительности во время выполнения», больше «явности на этапе сборки».
flowchart TB
accTitle: От сообразительности во время выполнения к явности на сборке
accDescr: Динамическая загрузка, генерация кода во время выполнения и неограниченная рефлексия становятся рассадником предупреждений AOT, поэтому дизайн сдвигают от решений во время выполнения к явному описанию на этапе сборки.
dynamicway["Дизайн, который полагается на решения во время выполнения"] --> warn["Предупреждения семейства RequiresDynamicCode"]
warn --> shift["Увеличить явность на этапе сборки"]
shift --> calm["На publish нужный код зафиксирован"]
warn -.-> nosup["Не подавлять без разбора"]
Рис. 9: К главному ограничению один ход: «решать во время выполнения» заменить на «явно описать на сборке».
5.2. Нужно сразу думать в терминах trimming
Native AOT тесно связан с trimming. Здесь легко упустить, что значение имеет не только ваш код, но и то, как написаны зависимости.
Особого внимания заслуживают такие вещи.
- Сериализаторы на основе рефлексии
- DI и плагинные схемы, которые собирают типы сканированием во время выполнения
- Механизмы, которые ищут тип по строковому имени и создают экземпляр
- Библиотеки, которые опираются на динамические прокси или генерацию IL
Если здесь есть предупреждения, а вы решаете «publish прошёл — значит всё хорошо», позже это выйдет боком. В Native AOT предупреждения почти всегда стоит читать всерьёз.
flowchart TB
accTitle: Успех trimming решает не только ваш код
accDescr: На trimming влияет не только ваш код, но и зависимости: сериализаторы на рефлексии, DI со сканированием во время выполнения, поиск типа по имени и библиотеки с динамическими прокси требуют внимания.
trim["Успех или провал trimming"] --> own["Как написан ваш код"]
trim --> dep["Как написаны зависимости"]
dep -.-> ex["Рефлексия и сканирование во время выполнения"]
dep --> care["Читать предупреждения всерьёз и проверять"]
Рис. 10: Легко упустить сторону зависимостей. «Publish прошёл — значит всё хорошо» позже становится дорогим.
Одной фразы «читайте всерьёз» недостаточно, чтобы двигаться дальше, поэтому ниже — приоритет действий. Официальная документация тоже советует пробовать в этом порядке.
| Порядок | Действие | Когда применять |
|---|---|---|
| 1 | Отказаться от reflection | Activator.CreateInstance(Type) можно заменить generic-аргументом или сместить работу на source generator |
| 2 | Поставить DynamicallyAccessedMembers |
reflection нужна, но целевой тип известен на этапе компиляции |
| 3 | Поставить RequiresUnreferencedCode |
тип выбирается строкой во время выполнения и статически разобрать это нельзя |
| 4 | Подавить через UnconditionalSuppressMessage |
всё предыдущее невозможно, и вы уже убедились, что это безопасно — последнее средство |
Самый частый первый шаг — вариант 2: «тип известен, а предупреждение всё равно есть».
Например, следующий код даёт IL2070.
// IL2070: 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicMethods'
void PrintMethodNames(Type type)
{
foreach (var method in type.GetMethods())
{
Console.WriteLine(method.Name);
}
}
Если вызываете GetMethods(), объявите атрибутом, что публичные методы нужно сохранить, — и предупреждение исчезнет.
using System.Diagnostics.CodeAnalysis;
void PrintMethodNames(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
foreach (var method in type.GetMethods())
{
Console.WriteLine(method.Name);
}
}
// Если вызывающий код передаёт typeof, требование выполняется само
PrintMethodNames(typeof(DateTime));
Вызываемый API и нужная аннотация в целом соответствуют друг другу. Для GetMethod / GetMethods это PublicMethods, для GetProperty / GetProperties — PublicProperties, для Activator.CreateInstance — PublicParameterlessConstructor или PublicConstructors.
DynamicallyAccessedMemberTypes.All удобно, но оставляет все члены целевого типа, раздувает размер, и оставленные члены часто приносят следующие предупреждения, поэтому принцип такой: указывать минимум необходимого.
Если атрибут добавили, а предупреждение не исчезло, поднимитесь от места с reflection к вызывающему коду и проверьте, что атрибут стоит на всём пути. Если выпало одно звено, требование обрывается там.
flowchart TB
accTitle: Если атрибут добавили, а предупреждение осталось
accDescr: DynamicallyAccessedMembers указывают в минимуме необходимого; если предупреждение не исчезает, поднимаются от места с reflection к вызывающему коду и проверяют весь путь. Одно выпавшее звено обрывает требование.
warnleft["Атрибут есть, предупреждение осталось"] --> back["Подняться к вызывающему коду"]
back --> chain["Проверить, что атрибут стоит на всём пути"]
chain -.-> cut["Одно выпавшее звено обрывает требование"]
warnleft -.-> minrule["Указывать минимум необходимого"]
Рис. 11: Атрибут ставят не в одну точку, а на путь. Если выпало одно звено, требование обрывается там.
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 нет built-in COM. Плюс WPF плохо сочетается с trimming, а WinForms сильно зависит от built-in COM marshalling, поэтому по крайней мере сейчас оба варианта как «первый кандидат на Native AOT» стоит рассматривать очень осторожно.
Зафиксируем, что здесь значит «сейчас». Описание в этой статье опирается на официальную документацию поколений .NET 8 / 9 / 10 (по состоянию на июль 2026). По WPF и WinForms на странице Microsoft «Known trimming incompatibilities» прямо сказано: WPF сильно зависит от reflection и проверки кода во время выполнения и после trimming почти не работает, WinForms сильно зависит от built-in COM marshalling, поэтому поддержка trimming для обоих отключена на стороне .NET SDK. То есть речь уже не о «стоит быть осторожным»: сейчас это останавливает сам SDK. В будущем формулировка может измениться — на момент чтения сверьтесь с той же страницей.
flowchart TB
accTitle: Почему WPF и WinForms остановлены
accDescr: У Native AOT в Windows нет built-in COM; WPF из-за зависимости от reflection и проверки кода во время выполнения после trimming почти не работает, WinForms сильно зависит от built-in COM marshalling, поэтому поддержка trimming для обоих отключена в .NET SDK.
win["Native AOT в Windows"] --> nocom["Нет built-in COM"]
wpf["WPF"] --> refl["Сильная зависимость от reflection"]
wf["WinForms"] --> commar["Сильная зависимость от COM marshalling"]
refl --> off["SDK отключает trimming"]
commar --> off
Рис. 12: Дело не в осторожности: сейчас это останавливает SDK. Само десктопное приложение не берите первым кандидатом.
Итого,
- сразу переводить на Native AOT само приложение WPF / WinForms
- переносить COM interop как есть, с привычными для обычного .NET приёмами
в таких шагах предупреждения на publish и ограничения во время выполнения нарастают сразу, и стоимость сопровождения легко взлетает.
И наоборот,
- консоль
- worker
- небольшой Web API
- компоненты для связи с нативным кодом, которые легко свести к границе C-функций
как точка входа естественнее.
Если COM нужен, в части случаев разумнее остаться на JIT либо перепроектировать решение вокруг ComWrappers / source-generated COM.
6. Минимальные шаги
Сначала важное: для publish Native AOT нужен нативный набор инструментов.
Если просто поставить PublishAot и сразу вызвать dotnet publish, падение будет не на компиляции, а на финальной нативной компоновке. Это первый барьер — поставьте toolchain заранее.
| Среда | Что нужно |
|---|---|
| Windows | Visual Studio 2022 или новее. Рабочую нагрузку «Разработка классических приложений на C++» ставят со всеми компонентами по умолчанию |
| Ubuntu 18.04 и новее | sudo apt-get install clang zlib1g-dev |
| Alpine 3.15 и новее | sudo apk add clang build-base zlib-dev |
| Fedora 39 и новее / RHEL 8 и новее | sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel |
| macOS | Xcode Command Line Tools (поддерживается с .NET 8) |
По сути это компиляторный toolchain и пакеты разработки библиотек, от которых зависит runtime .NET. Если гоняете это в CI, в Native AOT-примерах dotnet/samples есть Dockerfile и для Linux, и для Windows — оттуда быстрее всего взять шаги установки.
Ещё: двоичный файл, собранный на Linux, работает только на том же Linux или более новом. Собранное на Ubuntu 20.04 пойдёт на 20.04 и новее, на 18.04 — нет. Выбор среды сборки сразу задаёт диапазон, куда можно распространять.
flowchart TB
accTitle: Барьер до publish и диапазон распространения
accDescr: Для publish Native AOT нужен нативный toolchain; без него падение на финальной нативной компоновке. Двоичный файл, собранный на Linux, работает только на том же Linux или более новом, поэтому выбор среды сборки задаёт диапазон распространения.
tool{"Есть ли toolchain?"}
tool -->|"Нет"| fail["Падение на нативной компоновке"]
tool -->|"Есть"| bin["Появляется двоичный файл на каждый RID"]
bin -.-> range["На Linux работает только на той же или более новой среде"]
range -.-> pick["Выбор среды сборки задаёт диапазон распространения"]
Рис. 13: Первый барьер — toolchain. На Linux «возраст» среды сборки задаёт, куда можно распространять результат.
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.
flowchart TB
accTitle: Повседневная работа после PublishAot
accDescr: Даже если PublishAot стоит в проекте, повседневный dotnet run остаётся на JIT, а компиляция Native AOT идёт на publish. Поэтому флаг держат в проекте постоянно и смотрят анализ на build и publish.
put["Поставить PublishAot в csproj"] --> daily["Повседневный dotnet run остаётся на JIT"]
put --> pub["На publish идёт AOT-компиляция"]
daily -.-> watch["Смотреть анализ и предупреждения каждый день"]
Рис. 14: Повседневный запуск — JIT, настоящая работа — publish. Поэтому настройку не вешают временно, а держат постоянно и смотрят анализ.
6.2. publish
Например, для Windows x64 это выглядит так.
dotnet publish -c Release -r win-x64
Для Linux x64 — так.
dotnet publish -c Release -r linux-x64
Результат привязан к RID. Взгляд смещается от «одной .NET DLL, которая работает везде» к «исполняемому файлу, собранному под конкретные ОС и архитектуру».
Если заходите со стороны Web API, проще начать с шаблона, изначально рассчитанного на Native AOT.
dotnet new webapiaot -o MyFirstAotWebApi
Для worker — этот.
dotnet new worker -o WorkerWithAot --aot
6.3. Как писать JSON
Незаметная, но частая точка столкновения с Native AOT — JSON.
System.Text.Json в привычном стиле легко скатывается к reflection, поэтому спокойнее опираться на 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-компоненты внутри нативного приложения
- Ситуации, где не хочется требовать заранее установленный runtime .NET
Общее у них то, что границы относительно чёткие и динамические механизмы проще сократить.
8. Где это не стоит
И наоборот, есть ясные случаи, где Native AOT с самого начала не стоит делать основной ставкой.
- Само существующее крупное приложение на WPF / WinForms
- Схемы, построенные на built-in COM interop
- Приложения, где главная роль у загрузки плагинов во время выполнения
- Конфигурации с сильной зависимостью от фреймворка, который ищет типы через рефлексию
- Библиотеки, для которых
System.Reflection.Emitили динамические прокси — обычное дело - Проектирование через слой C++/CLI
В таких случаях разумнее обычный .NET с JIT, ReadyToRun или пересмотр границ архитектуры.
9. Типичные ловушки
Напоследок — куда легко наступить при первом знакомстве с Native AOT.
- Считать предупреждения publish мелочью
- Как уже сказано, предупреждения Native AOT нужно читать всерьёз.
- Build проходит, а publish ломается
- На этапе publish анализ по-настоящему охватывает и зависимости, поэтому часть вещей видна только здесь.
- Относиться к ReadyToRun и Native AOT одинаково
- Слова похожи, сила ограничений — нет.
- Начинать сразу с самого десктопного приложения
- Спокойнее начать с console / worker / небольшого API.
- Писать JSON или привязку конфигурации в привычном стиле
- Код, рассчитанный на reflection, аукнется позже.
- Раздавать артефакт так, будто он не зависит от платформы
- Дистрибутив Native AOT привязан к RID.
- Считать, что «Native AOT = всё становится быстрее»
- Главное здесь — запуск, распространение и среда выполнения. Если это упустить, ожидания разъедутся с реальностью.
В Native AOT окончательно решает не dotnet build, а dotnet publish.
Если начать гонять publish пораньше, на поздних этапах будет меньше сюрпризов.
flowchart TB
accTitle: Решает publish
accDescr: Build может проходить, а publish — ломаться, потому что на publish анализ по-настоящему охватывает и зависимости; окончательно Native AOT решает не dotnet build, а dotnet publish.
buildok["dotnet build проходит"] --> notyet["Ещё не решено, получится ли"]
pubx["dotnet publish"] --> deep["Анализ с зависимостями идёт всерьёз"]
deep --> verdict["Вот здесь впервые видно, получится ли"]
verdict -.-> early["Поэтому publish гоняют пораньше"]
Рис. 15: Общий знаменатель ловушек. Не успокаивайтесь на «build прошёл» — гоняйте publish пораньше.
10. Итог
Одной фразой Native AOT — это механизм, который сдвигает .NET-приложение от динамической модели выполнения к модели распространения, которую проще зафиксировать статически.
Смотреть стоит на эти пять пунктов.
- Native AOT заранее компилирует код в нативный на этапе publish
- Хорошо работает для запуска, памяти и распространения
- Взамен жёсток к reflection, динамической генерации кода, built-in 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
- Fixing trim warnings - .NET
- DynamicallyAccessedMembersAttribute Class - .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# Native AOT DLL из C/C++
- Связанная статья: Вызов native DLL из C#: обёртка на C++/CLI или P/Invoke
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Практические рекомендации по многопоточности: .NET — что решить до добавления потоков
Проверенные приёмы проектирования на .NET/C#, чтобы код не «иногда падал или зависал»: не создавать потоки вручную и опираться на Task, с...
Как выбрать межпроцессное взаимодействие в 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-работы, callback на ThreadPool...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Совместимость 32 и 64 бит
Совместимость 32/64 бит, нативные границы и решения по проектированию Windows.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Сопровождение и модернизация ПО Windows
Безопасно добавляем функции, сопровождаем и поэтапно модернизируем существующее ПО Windows.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Что такое Native AOT?
- Это способ публикации .NET-приложения, при котором код заранее компилируется в нативный код на этапе publish и распространяется уже в таком виде. JIT во время выполнения не используется, поэтому время запуска и потребление памяти обычно улучшаются, а приложение проще раздавать в среды без установленного runtime .NET. Взамен хуже сочетается со свободной рефлексией, динамической генерацией кода, built-in 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 нет built-in COM, WPF плохо сочетается с trimming, а WinForms сильно зависит от built-in COM marshalling, поэтому оба варианта плохо подходят как первый кандидат на Native AOT. Если COM нужен, в части случаев разумнее остаться на JIT либо перепроектировать решение вокруг ComWrappers / source-generated COM. Как точка входа естественнее консоль, worker и небольшой Web API.
- Для каких приложений подходит Native AOT?
- Он уместен для CLI-инструментов, где главное — запуск, небольших API, которые разворачивают во множестве контейнеров, worker и фоновых служб, serverless-сценариев и короткоживущих процессов, небольших .NET-компонентов внутри нативного приложения и ситуаций, где не хочется требовать заранее установленный runtime .NET. Общее у них то, что границы относительно чёткие и динамические механизмы проще сократить. И наоборот: он не подходит, если главная роль у загрузки плагинов во время выполнения или если конфигурация сильно завязана на фреймворк, который ищет типы через рефлексию.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.