Где Runtime Async в .NET 11 экономит память, а где нет
Всем привет, меня зовут Сергей Прощаев, я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E-commerce и преподаю на курсах разработки и архитектуры в ОТУС.
В этой статье расскажу про Runtime Async в .NET 11: что меняется, когда приостановкой async-методов занимается рантайм, а не код от компилятора, и где выигрыш пропадает.
На собеседованиях по C# годами звучит один и тот же вопрос: «во что компилятор превращает async-метод?».
Правильный ответ знают все: в структуру с конечным автоматом, методом MoveNext и билдером задачи. В .NET 11 у этого ответа появляется оговорка «если вы не включили runtime-async».
Я прогнал бенчмарки команды .NET на RC2 сам: аллокации совпали с опубликованными до байта, но только при одном условии, о котором в анонсах не пишут.
Сразу оговорюсь, почему про C# пишет человек с JVM. Мой основной стек — Java и Kotlin, и именно поэтому за асинхронностью в .NET интересно следить со стороны: в 2023 году Java выпустила виртуальные потоки, а команда .NET свой эксперимент с зелёными потоками отложила и пошла улучшать async/await.
В ноябре ожидается релиз .NET 11, где Runtime Async уже есть, но как preview-функция: для своего кода её включают явно, а поведение проверяют на своей нагрузке.
С изменениями в .NET 11 разобраться проще, если понимать, как async/await работает в классической реализации. Эту тему подробнее разберём 29 октября в 20:00 на бесплатном открытом уроке «Магические слова async/await».
Три платформы, три ответа на один вопрос
Задача у всех одна: поток не должен простаивать, пока сервис ждёт ответа от базы или соседнего API. Решают её по-разному.
Компиляторы Kotlin и классического C# преобразуют асинхронные функции в конечные автоматы.
Kotlin превращает каждую suspend-функцию в автомат с полем label и скрытым параметром Continuation. Компилятор C# делает почти то же: генерирует структуру, хранит в её полях локальные переменные, которые живут через await, и раскладывает тело метода по состояниям внутри MoveNext.
JVM и CLR исполняют уже сгенерированный код и дают инфраструктуру продолжений, но сам автомат не строят.
// (Kotlin) suspend-функция: компилятор добавит скрытый параметр Continuation
// и превратит тело в конечный автомат с полем label
suspend fun price(id: Int): Int {
val base = loadBase(id) // точка приостановки, пример схематичный
return base + tax(base)
}sourcecraft.dev
Java пошла другим путём. В JDK 21, вышедшем 19 сентября 2023 года, появились виртуальные потоки (JEP 444): код остаётся синхронным и блокирующим, а приостановкой и возобновлением занимается JVM, снимая стек виртуального потока с несущего.
// (Java 21, схематично) код блокирующий, приостанавливает и возобновляет его JVM
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> callBlockingApi(id));
}sourcecraft.dev
Команда .NET попробовала тот же путь. За одиннадцать дней до выхода JDK 21, 8 сентября 2023 года, она опубликовала итоги эксперимента с зелёными потоками.
Вывод был сдержанным: зелёные потоки возможны, но это вторая модель программирования рядом с async/await, и они плохо уживаются друг с другом.
Самая наглядная цифра из отчёта: 100 миллионов минимальных вызовов P/Invoke на зелёном потоке заняли около 1800 мс вместо 300 мс. Э
ксперимент поставили на паузу и решили улучшать существующую модель. Следующим шагом стал эксперимент runtime-async в .NET 9, а итогом — Runtime Async в .NET 11.
На схеме ниже три подхода сведены рядом, чтобы было видно, где живёт конечный автомат в каждом из них.
Главная мысль схемы: .NET 11 занял середину. Программная модель остаётся прежней, с Task, await и окраской методов, но приостановку и возобновление организуют VM и JIT, у которых больше информации.
Виртуальные потоки позволяют во многих сценариях писать блокирующий код без async-сигнатур по всему стеку вызовов. .NET от окраски методов не отказывается и выигрывает за счёт того, что ничего не надо переписывать.
Что именно меняется под капотом
В классической схеме компилятор C# для каждого async-метода выпускает метод-обёртку, тип конечного автомата, поля для захваченного состояния и тело MoveNext.
Упрощённо это выглядит так:
// (C#, упрощённо) что компилятор делает из async-метода без runtime-async
public Task<int> GetPriceAsync(int id)
{
var sm = new GetPriceAsyncStateMachine { Id = id, State = -1 };
sm.Builder = AsyncTaskMethodBuilder<int>.Create();
sm.Builder.Start(ref sm);
return sm.Builder.Task;
}
struct GetPriceAsyncStateMachine : IAsyncStateMachine
{
public int State;
public int Id;
public AsyncTaskMethodBuilder<int> Builder;
private TaskAwaiter<int> _awaiter;
public void MoveNext()
{
// switch по State, получение awaiter,
// AwaitUnsafeOnCompleted при незавершённой задаче, SetResult в конце
}
public void SetStateMachine(IAsyncStateMachine stateMachine) { }
}sourcecraft.dev
С runtime-async компилятор не генерирует пару IAsyncStateMachine и MoveNext. Он оставляет компактный IL, ставит методу флаг async (MethodImplOptions.Async, значение 0x2000 по черновику изменений ECMA-335), а точки приостановки кодирует вызовами методов AsyncHelpers: для Task и ValueTask это перегрузки Await, для прочих awaiter-ов AwaitAwaiter и UnsafeAwaitAwaiter.
Для оптимизации важнее всего пара «вызов async-метода и сразу подходящий Await».
Приостановку, сохранение нужного состояния и возобновление на другом потоке организуют VM и JIT. Так что меняется не столько место, где строится прежний автомат, сколько распределение ответственности, и поэтому JIT может оптимизировать границы между async-методами, а не только код внутри каждого.
Включается фича свойством в файле проекта, выключается для проекта другим свойством. В .NET 11 это по-прежнему preview:
<!-- (XML, csproj) включение Runtime Async для своего кода -->
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
<!-- (XML, csproj) отключение для проекта -->
<PropertyGroup>
<UseRuntimeAsync>false</UseRuntimeAsync>
</PropertyGroup>sourcecraft.dev
Есть деталь, которую легко пропустить: сами библиотеки .NET 11 уже собраны с runtime-async=on и конечных автоматов от компилятора больше не содержат.
Ваш код при этом может оставаться в классическом режиме, обе модели работают вместе. Получается, что, обновившись на .NET 11, вы уже исполняете runtime-async внутри BCL, даже если флаг не трогали.
Что это даёт: пять наблюдений
Я бы предпочёл не принимать «быстрее и чище» на веру, поэтому собрал репозиторий dotnet-runtime-async-demo и прогнал его на .NET 11 RC2 (SDK 11.0.100-rc.2) в GitHub Actions на ubuntu-24.04.
Классическую схему для отдельного метода оставляет экспериментальный атрибут [RuntimeAsyncMethodGeneration(false)], как у Тауба, поэтому оба варианта работают на одном рантайме и отличаются только тем, как их собрал компилятор.
Для сверки рядом цифры из документации .NET 11 и разбора производительности Стивена Тауба.
Аллокации между прогонами стабильны, время скачет, поэтому опираюсь на байты.
Наблюдение 1. Живые стек-трейсы стали короче в разы.
Три вложенных async-метода, внутренний создаёт new StackTrace().
В классическом режиме у меня 13 кадров, из них 3 — AsyncMethodBuilderCore.Start. С runtime-async кадров 4, и это ровно цепочка вызовов.
В документации 13 и 5: там Main тоже асинхронный, а мой пробник входит в цепочку из синхронного Main через GetAwaiter().GetResult(), так что точное число кадров зависит от точки входа:
(text, вывод с runtime-async=on, results/stack_runtime.txt)
at Runtime.InnerAsync()
at Runtime.MiddleAsync()
at Runtime.OuterAsync()
at Program.<Main>$(String[] args)sourcecraft.dev
Важная оговорка из той же документации: стек-трейсы исключений из catch выглядят одинаково в обоих режимах, их давно чистит ExceptionDispatchInfo.
Выигрывают профилировщики, отладчик и всё, что смотрит на стек во время работы.
С виртуальными потоками Java сходство здесь концептуальное: при реальной приостановке await физический стек в .NET по-прежнему разматывается, Runtime Async оптимизирует существующую модель, а не заменяет её стековыми потоками.
Наблюдение 2. Когда Runtime Async устраняет промежуточные задачи.
Самый интересный механизм — слияние вызовов.
Если A ждёт B, а B ждёт C, в классической схеме у каждого метода свой сгенерированный автомат и свой результат-задача.
Автомат при этом не обязательно попадает в кучу: на синхронном пути структура живёт на стеке, а платим мы в основном за задачи-результаты.
JIT с runtime-async распознаёт пару «вызов метода, возвращающего Task, и сразу await» и передаёт результат дальше обычным значением. Задача материализуется только на внешней границе, где её действительно кто-то ждёт как объект.
На схеме ниже эта разница показана на цепочке из трёх методов.
Главная мысль схемы: контракт Task никуда не делся, он переехал туда, где задача действительно нужна.
На рисунке благоприятный случай: вся цепочка собрана в новом режиме и вызовы идут подряд. В смешанной цепочке с классическими методами или когда задачу кладут в коллекцию и вешают продолжение руками, объект по-прежнему создаётся.
Бенчмарк Тауба с двумя уровнями вложенности я повторил в BenchmarkDotNet 0.16.0-preview.2 с MemoryDiagnoser, в обычном режиме, когда каждый бенчмарк идёт в отдельном процессе:
Сценарий
Аллокации у меня
Аллокации у Тауба
Цепочка завершилась синхронно, классический async
144 B
144 B
Цепочка завершилась синхронно, runtime-async
0 B
0 B
Цепочка с Task.Yield, классический async
248 B
248 B
Цепочка с Task.Yield, runtime-async
168 B
168 B
Байты совпали до единицы. По времени у меня синхронная цепочка ускорилась с 26,95 до 10,72 нс, у Тауба с 21,221 до 6,151 нс: порядок тот же, машины разные.
Наблюдение 3. Глубокие цепочки с исключением дешевеют на порядок.
Рекурсивная цепочка бросает исключение на дне.
На глубине 30 без приостановки классический вариант аллоцирует 84,2 KB, runtime-async — 5,71 KB, у меня и у Тауба одинаково.
По времени у меня 56,6 мкс против 13,0 мкс.
Пример синтетический: в реальном сервисе картину изменят локальная обработка исключений, логирование, повторы и настоящие точки приостановки.
Наблюдение 4. Сборка становится вдвое меньше.
Приложение из десяти вложенных async-методов в классическом режиме дало SizeProbe.dll размером 10 752 байта, а с runtime-async — 5632 байта, байт в байт как в разборе.
Для одного сервиса это мелочь, в крупном приложении с множеством своих async-методов эффект может накапливаться.
Размер итогового NativeAOT-образа и готовых сторонних сборок я не мерил, так что количественного вывода для них не делаю.
Наблюдение 5. Продолжения перестали таскать пустой контекст.
Это изменение не про флаг, по документации оно работает в .NET 11 для всех.
Раньше каждое продолжение снимало и восстанавливало ExecutionContext со значениями AsyncLocal, даже когда восстанавливать было нечего. Теперь рантайм этот цикл пропускает.
Пункт из документации, я его не мерил: в сервисе с трассировкой и областями логирования контекст часто непустой, и эффект там, по моей гипотезе, скромный.
Где выигрыш пропадает
Первое, и для меня главное: результат зависит от того, как запущен замер.
Сначала я гонял те же бенчмарки с флагом --inProcess, когда BenchmarkDotNet исполняет их в своём процессе, и написал свой пробник на GC.GetTotalAllocatedBytes, который тоже работает в одном процессе.
Оба показали другую картину:
Сценарий, в одном процессе
Классический async
Runtime-async
Цепочка завершилась синхронно
144 B
72 B
Цепочка с Task.Yield
192 B
240 B
Причину расхождения я не установил, поэтому не считаю эти цифры свойством самой реализации.
Методики тоже не одинаковые: мой пробник считает аллокации всего процесса, включая пул потоков, а не одного вызова. Оба режима шли на одной модели процессора, AMD EPYC 9V74, так что разница в железе маловероятна.
Для выводов об аллокациях беру стандартный запуск BenchmarkDotNet в отдельном процессе: эти значения совпали с опубликованными. Абсолютное время отличается между окружениями, и я считаю его результатом конкретного прогона, а не свойством .NET 11.
Внутрипроцессные цифры оставляю отдельным наблюдением, которое ещё надо проверить.
Практический вывод: сравнивайте замеры, запущенные одинаково, на одной версии SDK и рантайма, иначе вы меряете методику, а не механизм.
Время на коротких цепочках вообще ненадёжно. У Тауба исключение на глубине 1 с приостановкой в runtime-async медленнее в 1,3 раза, а у меня быстрее: 7,709 мкс против 6,438 мкс. Байты в обоих прогонах те же: 1,68 KB против 1,4 KB.
Второе: это микробенчмарки.
Типовой запрос ждёт базу миллисекунды, а экономия измеряется наносекундами.
Могу себе представить нагрузочный тест I/O-bound API, где с флагом латентность не изменится, и это будет честный результат.
Заметнее эффект там, где много синхронно завершающихся await (кэши, парсеры, глубокие конвейеры), и в метриках GC, а не во времени ответа.
Третье: фича в preview.
Документация прямо просит присылать отчёты о пропускной способности и размере библиотек в обе стороны, положительные и отрицательные. До стабилизации производительность и детали поведения могут меняться от сборки к сборке.
Переменные окружения DOTNET_RuntimeAsync и UNSUPPORTED_RuntimeAsync больше не поддерживаются, включение теперь через Features, отключение через UseRuntimeAsync=false.
Четвёртое: всё, что опирается на внутренности старой схемы, стоит проверить.
Кадров билдера в живом стеке у меня стало 0 вместо 3, и если в коде или в инструментах кто-то разбирает стек по именам вида <Method>d__0 или по кадрам MoveNext, их больше не найдут.
Я бы проверил:
самописные логгеры, которые склеивают стек в строку;
правила группировки ошибок в системе мониторинга;
диагностику, которая ищет конечный автомат через reflection.
Пятое: поддержка не сплошная.
Runtime-async работает с четырьмя типами возврата: Task, ValueTask, Task<T> и ValueTask<T>.
По разбору Тауба, async void, async-итераторы и собственные task-like типы со своими билдерами пока остаются на классическом преобразовании.
Для пограничных случаев, вроде by-ref локальных переменных и await в обработчиках исключений, сверяться стоит с поведением текущего компилятора, а не только с черновиком ECMA-335.
Что проверить у себя
Мой вариант порядка, если сервис уже на .NET 10 и переезд на .NET 11 запланирован:
Обновить проект на .NET 11 без флага и снять базовые метрики: аллокации в секунду, паузы GC, p99 латентности. Часть выигрыша вы получите уже здесь, потому что BCL собрана в новом режиме.
В отдельной ветке включить <Features>$(Features);runtime-async=on</Features> и прогнать те же замеры на той же версии SDK и рантайма, тем же способом. Так эффект обновления BCL отделяется от эффекта флага. Сравнивать аллокации, а не только время.
Найти горячий путь с глубокой цепочкой await и написать на него бенчмарк BenchmarkDotNet с MemoryDiagnoser. Там будет видно, сливаются ли вызовы.
Проверить всё, что читает стек: логирование, APM-агент, группировку ошибок. Если что-то ломается, лучше узнать до GA, а не после.
Если встали ограничения, отключить фичу для проекта через <UseRuntimeAsync>false</UseRuntimeAsync> и оставить отчёт команде .NET.
Сроки поджимают: по официальной политике поддержки .NET 8 и .NET 9 перестают поддерживаться 10 ноября 2026 года, а .NET 11 RC2 вышел 13 октября с go-live лицензией.
Окно для экспериментов как раз сейчас.
Вывод
Самое интересное здесь, на мой взгляд, не наносекунды, а выбор архитектуры.
Java с виртуальными потоками позволила во многих сценариях писать блокирующий код без async-сигнатур, переложив приостановку на JVM, хотя асинхронные API в экосистеме никуда не делись.
Kotlin до сих пор строит конечный автомат в компиляторе, ровно как C# последние двенадцать лет. .NET в 2023 году отказался от зелёных потоков и пошёл третьим путём: модель программирования оставили прежней, а реализацию перенесли туда, где у неё больше информации.
Для разработчика на C# это означает простую вещь: ответ «async — это синтаксический сахар над конечным автоматом» теперь неполный.
Компилятор больше не выпускает привычный MoveNext, приостановку и возобновление организуют VM и JIT, и JIT может оптимизировать границы между вложенными async-методами, потому что видит цепочку вызовов.
Для разработчика на JVM это хороший повод посмотреть на корутины Kotlin иначе: компиляторная схема не единственная, и платформа с похожей моделью только что показала, сколько она стоит.
Ссылка на мой репозиторий с экспериментом
Начать стоит с README.md: там все таблицы этой статьи, включая разницу между запуском в одном процессе и в отдельных.
shared/Chains.cs — измеряемые цепочки.
src/AsyncProbe/Program.cs — стек-трейсы и аллокации.
bench/AsyncBench/Program.cs — бенчмарки.
./run.sh — собирает всё с нуля и сверяет результат с инвариантами из expected.txt.
ИСТОЧНИКИРепозиторий с экспериментом
What's new in .NET 11 runtime, Microsoft Learn, обновлено для RC2
Toub S., Performance Improvements in .NET 11, .NET Blog, 15.09.2026
Runtime-async, черновик изменений ECMA-335, dotnet/runtime
Green Thread Experiment Results, dotnet/runtimelab #2398, 08.09.2023
.NET 9 Runtime Async Experiment, dotnet/runtime #94620
.NET and .NET Core Support Policy
Announcing .NET 11 Release Candidate 2, .NET Blog, 13.10.2026
JDK 21, OpenJDK
Kotlin Coroutines, KEEP
Чтобы расти до уровня Senior C#-разработчика и браться за более сложные задачи, важно не только уверенно писать код, но и понимать, как работает .NET под капотом. На курсе «C#-разработчик. Продвинутый уровень» можно углубить эти знания, научиться находить узкие места в приложениях и принимать более обоснованные архитектурные решения.
Пройдите вступительный тест, чтобы оценить свой текущий уровень, выявить пробелы и понять, какие навыки стоит подтянуть для следующего карьерного шага.
Mentions
Где Runtime Async в .NET 11 экономит память, а где нет Всем привет, меня зовут Сергей Прощаев, я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E-commerce и преподаю на курсах разработки и архитектуры в ОТУС. В этой статье расскажу про Runtime Async в .NET 11: что меняется, когда приостановкой async-методов занимается рантайм, а не код от компилятора, и где выигрыш пропадает. На собеседованиях по C# годами звучит один и тот же вопрос: «во что компилятор превращает async-метод?». Правильный ответ знают все: в структуру с конечным автоматом, методом MoveNext и билдером задачи. В .NET 11 у этого ответа появляется оговорка «если вы не включили runtime-async». Я прогнал бенчмарки команды .NET на RC2 сам: аллокации совпали с опубликованными до байта, но только при одном условии, о котором в анонсах не пишут. Сразу оговорюсь, почему про C# пишет человек с JVM. Мой основной стек — Java и Kotlin, и именно поэтому за асинхронностью в .NET интересно следить со стороны: в 2023 году Java выпустила виртуальные потоки, а команда .NET свой эксперимент с зелёными потоками отложила и пошла улучшать async/await. В ноябре ожидается релиз .NET 11, где Runtime Async уже есть, но как preview-функция: для своего кода её включают явно, а поведение проверяют на своей нагрузке. С изменениями в .NET 11 разобраться проще, если понимать, как async/await работает в классической реализации. Эту тему подробнее разберём 29 октября в 20:00 на бесплатном открытом уроке «Магические слова async/await». Три платформы, три ответа на один вопрос Задача у всех одна: поток не должен простаивать, пока сервис ждёт ответа от базы или соседнего API. Решают её по-разному. Компиляторы Kotlin и классического C# преобразуют асинхронные функции в конечные автоматы. Kotlin превращает каждую suspend-функцию в автомат с полем label и скрытым параметром Continuation. Компилятор C# делает почти то же: генерирует структуру, хранит в её полях локальные переменные, которые живут через await, и раскладывает тело метода по состояниям внутри MoveNext. JVM и CLR исполняют уже сгенерированный код и дают инфраструктуру продолжений, но сам автомат не строят. // (Kotlin) suspend-функция: компилятор добавит скрытый параметр Continuation // и превратит тело в конечный автомат с полем label suspend fun price(id: Int): Int { val base = loadBase(id) // точка приостановки, пример схематичный return base + tax(base) }sourcecraft.dev Java пошла другим путём. В JDK 21, вышедшем 19 сентября 2023 года, появились виртуальные потоки (JEP 444): код остаётся синхронным и блокирующим, а приостановкой и возобновлением занимается JVM, снимая стек виртуального потока с несущего. // (Java 21, схематично) код блокирующий, приостанавливает и возобновляет его JVM try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> callBlockingApi(id)); }sourcecraft.dev Команда .NET попробовала тот же путь. За одиннадцать дней до выхода JDK 21, 8 сентября 2023 года, она опубликовала итоги эксперимента с зелёными потоками. Вывод был сдержанным: зелёные потоки возможны, но это вторая модель программирования рядом с async/await, и они плохо уживаются друг с другом. Самая наглядная цифра из отчёта: 100 миллионов минимальных вызовов P/Invoke на зелёном потоке заняли около 1800 мс вместо 300 мс. Э ксперимент поставили на паузу и решили улучшать существующую модель. Следующим шагом стал эксперимент runtime-async в .NET 9, а итогом — Runtime Async в .NET 11. На схеме ниже три подхода сведены рядом, чтобы было видно, где живёт конечный автомат в каждом из них. Главная мысль схемы: .NET 11 занял середину. Программная модель остаётся прежней, с Task, await и окраской методов, но приостановку и возобновление организуют VM и JIT, у которых больше информации. Виртуальные потоки позволяют во многих сценариях писать блокирующий код без async-сигнатур по всему стеку вызовов. .NET от окраски методов не отказывается и выигрывает за счёт того, что ничего не надо переписывать. Что именно меняется под капотом В классической схеме компилятор C# для каждого async-метода выпускает метод-обёртку, тип конечного автомата, поля для захваченного состояния и тело MoveNext. Упрощённо это выглядит так: // (C#, упрощённо) что компилятор делает из async-метода без runtime-async public Task<int> GetPriceAsync(int id) { var sm = new GetPriceAsyncStateMachine { Id = id, State = -1 }; sm.Builder = AsyncTaskMethodBuilder<int>.Create(); sm.Builder.Start(ref sm); return sm.Builder.Task; } struct GetPriceAsyncStateMachine : IAsyncStateMachine { public int State; public int Id; public AsyncTaskMethodBuilder<int> Builder; private TaskAwaiter<int> _awaiter; public void MoveNext() { // switch по State, получение awaiter, // AwaitUnsafeOnCompleted при незавершённой задаче, SetResult в конце } public void SetStateMachine(IAsyncStateMachine stateMachine) { } }sourcecraft.dev С runtime-async компилятор не генерирует пару IAsyncStateMachine и MoveNext. Он оставляет компактный IL, ставит методу флаг async (MethodImplOptions.Async, значение 0x2000 по черновику изменений ECMA-335), а точки приостановки кодирует вызовами методов AsyncHelpers: для Task и ValueTask это перегрузки Await, для прочих awaiter-ов AwaitAwaiter и UnsafeAwaitAwaiter. Для оптимизации важнее всего пара «вызов async-метода и сразу подходящий Await». Приостановку, сохранение нужного состояния и возобновление на другом потоке организуют VM и JIT. Так что меняется не столько место, где строится прежний автомат, сколько распределение ответственности, и поэтому JIT может оптимизировать границы между async-методами, а не только код внутри каждого. Включается фича свойством в файле проекта, выключается для проекта другим свойством. В .NET 11 это по-прежнему preview: <!-- (XML, csproj) включение Runtime Async для своего кода --> <PropertyGroup> <TargetFramework>net11.0</TargetFramework> <Features>$(Features);runtime-async=on</Features> </PropertyGroup> <!-- (XML, csproj) отключение для проекта --> <PropertyGroup> <UseRuntimeAsync>false</UseRuntimeAsync> </PropertyGroup>sourcecraft.dev Есть деталь, которую легко пропустить: сами библиотеки .NET 11 уже собраны с runtime-async=on и конечных автоматов от компилятора больше не содержат. Ваш код при этом может оставаться в классическом режиме, обе модели работают вместе. Получается, что, обновившись на .NET 11, вы уже исполняете runtime-async внутри BCL, даже если флаг не трогали. Что это даёт: пять наблюдений Я бы предпочёл не принимать «быстрее и чище» на веру, поэтому собрал репозиторий dotnet-runtime-async-demo и прогнал его на .NET 11 RC2 (SDK 11.0.100-rc.2) в GitHub Actions на ubuntu-24.04. Классическую схему для отдельного метода оставляет экспериментальный атрибут [RuntimeAsyncMethodGeneration(false)], как у Тауба, поэтому оба варианта работают на одном рантайме и отличаются только тем, как их собрал компилятор. Для сверки рядом цифры из документации .NET 11 и разбора производительности Стивена Тауба. Аллокации между прогонами стабильны, время скачет, поэтому опираюсь на байты. Наблюдение 1. Живые стек-трейсы стали короче в разы. Три вложенных async-метода, внутренний создаёт new StackTrace(). В классическом режиме у меня 13 кадров, из них 3 — AsyncMethodBuilderCore.Start. С runtime-async кадров 4, и это ровно цепочка вызовов. В документации 13 и 5: там Main тоже асинхронный, а мой пробник входит в цепочку из синхронного Main через GetAwaiter().GetResult(), так что точное число кадров зависит от точки входа: (text, вывод с runtime-async=on, results/stack_runtime.txt) at Runtime.InnerAsync() at Runtime.MiddleAsync() at Runtime.OuterAsync() at Program.<Main>$(String[] args)sourcecraft.dev Важная оговорка из той же документации: стек-трейсы исключений из catch выглядят одинаково в обоих режимах, их давно чистит ExceptionDispatchInfo. Выигрывают профилировщики, отладчик и всё, что смотрит на стек во время работы. С виртуальными потоками Java сходство здесь концептуальное: при реальной приостановке await физический стек в .NET по-прежнему разматывается, Runtime Async оптимизирует существующую модель, а не заменяет её стековыми потоками. Наблюдение 2. Когда Runtime Async устраняет промежуточные задачи. Самый интересный механизм — слияние вызовов. Если A ждёт B, а B ждёт C, в классической схеме у каждого метода свой сгенерированный автомат и свой результат-задача. Автомат при этом не обязательно попадает в кучу: на синхронном пути структура живёт на стеке, а платим мы в основном за задачи-результаты. JIT с runtime-async распознаёт пару «вызов метода, возвращающего Task, и сразу await» и передаёт результат дальше обычным значением. Задача материализуется только на внешней границе, где её действительно кто-то ждёт как объект. На схеме ниже эта разница показана на цепочке из трёх методов. Главная мысль схемы: контракт Task никуда не делся, он переехал туда, где задача действительно нужна. На рисунке благоприятный случай: вся цепочка собрана в новом режиме и вызовы идут подряд. В смешанной цепочке с классическими методами или когда задачу кладут в коллекцию и вешают продолжение руками, объект по-прежнему создаётся. Бенчмарк Тауба с двумя уровнями вложенности я повторил в BenchmarkDotNet 0.16.0-preview.2 с MemoryDiagnoser, в обычном режиме, когда каждый бенчмарк идёт в отдельном процессе: Сценарий Аллокации у меня Аллокации у Тауба Цепочка завершилась синхронно, классический async 144 B 144 B Цепочка завершилась синхронно, runtime-async 0 B 0 B Цепочка с Task.Yield, классический async 248 B 248 B Цепочка с Task.Yield, runtime-async 168 B 168 B Байты совпали до единицы. По времени у меня синхронная цепочка ускорилась с 26,95 до 10,72 нс, у Тауба с 21,221 до 6,151 нс: порядок тот же, машины разные. Наблюдение 3. Глубокие цепочки с исключением дешевеют на порядок. Рекурсивная цепочка бросает исключение на дне. На глубине 30 без приостановки классический вариант аллоцирует 84,2 KB, runtime-async — 5,71 KB, у меня и у Тауба одинаково. По времени у меня 56,6 мкс против 13,0 мкс. Пример синтетический: в реальном сервисе картину изменят локальная обработка исключений, логирование, повторы и настоящие точки приостановки. Наблюдение 4. Сборка становится вдвое меньше. Приложение из десяти вложенных async-методов в классическом режиме дало SizeProbe.dll размером 10 752 байта, а с runtime-async — 5632 байта, байт в байт как в разборе. Для одного сервиса это мелочь, в крупном приложении с множеством своих async-методов эффект может накапливаться. Размер итогового NativeAOT-образа и готовых сторонних сборок я не мерил, так что количественного вывода для них не делаю. Наблюдение 5. Продолжения перестали таскать пустой контекст. Это изменение не про флаг, по документации оно работает в .NET 11 для всех. Раньше каждое продолжение снимало и восстанавливало ExecutionContext со значениями AsyncLocal, даже когда восстанавливать было нечего. Теперь рантайм этот цикл пропускает. Пункт из документации, я его не мерил: в сервисе с трассировкой и областями логирования контекст часто непустой, и эффект там, по моей гипотезе, скромный. Где выигрыш пропадает Первое, и для меня главное: результат зависит от того, как запущен замер. Сначала я гонял те же бенчмарки с флагом --inProcess, когда BenchmarkDotNet исполняет их в своём процессе, и написал свой пробник на GC.GetTotalAllocatedBytes, который тоже работает в одном процессе. Оба показали другую картину: Сценарий, в одном процессе Классический async Runtime-async Цепочка завершилась синхронно 144 B 72 B Цепочка с Task.Yield 192 B 240 B Причину расхождения я не установил, поэтому не считаю эти цифры свойством самой реализации. Методики тоже не одинаковые: мой пробник считает аллокации всего процесса, включая пул потоков, а не одного вызова. Оба режима шли на одной модели процессора, AMD EPYC 9V74, так что разница в железе маловероятна. Для выводов об аллокациях беру стандартный запуск BenchmarkDotNet в отдельном процессе: эти значения совпали с опубликованными. Абсолютное время отличается между окружениями, и я считаю его результатом конкретного прогона, а не свойством .NET 11. Внутрипроцессные цифры оставляю отдельным наблюдением, которое ещё надо проверить. Практический вывод: сравнивайте замеры, запущенные одинаково, на одной версии SDK и рантайма, иначе вы меряете методику, а не механизм. Время на коротких цепочках вообще ненадёжно. У Тауба исключение на глубине 1 с приостановкой в runtime-async медленнее в 1,3 раза, а у меня быстрее: 7,709 мкс против 6,438 мкс. Байты в обоих прогонах те же: 1,68 KB против 1,4 KB. Второе: это микробенчмарки. Типовой запрос ждёт базу миллисекунды, а экономия измеряется наносекундами. Могу себе представить нагрузочный тест I/O-bound API, где с флагом латентность не изменится, и это будет честный результат. Заметнее эффект там, где много синхронно завершающихся await (кэши, парсеры, глубокие конвейеры), и в метриках GC, а не во времени ответа. Третье: фича в preview. Документация прямо просит присылать отчёты о пропускной способности и размере библиотек в обе стороны, положительные и отрицательные. До стабилизации производительность и детали поведения могут меняться от сборки к сборке. Переменные окружения DOTNET_RuntimeAsync и UNSUPPORTED_RuntimeAsync больше не поддерживаются, включение теперь через Features, отключение через UseRuntimeAsync=false. Четвёртое: всё, что опирается на внутренности старой схемы, стоит проверить. Кадров билдера в живом стеке у меня стало 0 вместо 3, и если в коде или в инструментах кто-то разбирает стек по именам вида <Method>d__0 или по кадрам MoveNext, их больше не найдут. Я бы проверил: самописные логгеры, которые склеивают стек в строку; правила группировки ошибок в системе мониторинга; диагностику, которая ищет конечный автомат через reflection. Пятое: поддержка не сплошная. Runtime-async работает с четырьмя типами возврата: Task, ValueTask, Task<T> и ValueTask<T>. По разбору Тауба, async void, async-итераторы и собственные task-like типы со своими билдерами пока остаются на классическом преобразовании. Для пограничных случаев, вроде by-ref локальных переменных и await в обработчиках исключений, сверяться стоит с поведением текущего компилятора, а не только с черновиком ECMA-335. Что проверить у себя Мой вариант порядка, если сервис уже на .NET 10 и переезд на .NET 11 запланирован: Обновить проект на .NET 11 без флага и снять базовые метрики: аллокации в секунду, паузы GC, p99 латентности. Часть выигрыша вы получите уже здесь, потому что BCL собрана в новом режиме. В отдельной ветке включить <Features>$(Features);runtime-async=on</Features> и прогнать те же замеры на той же версии SDK и рантайма, тем же способом. Так эффект обновления BCL отделяется от эффекта флага. Сравнивать аллокации, а не только время. Найти горячий путь с глубокой цепочкой await и написать на него бенчмарк BenchmarkDotNet с MemoryDiagnoser. Там будет видно, сливаются ли вызовы. Проверить всё, что читает стек: логирование, APM-агент, группировку ошибок. Если что-то ломается, лучше узнать до GA, а не после. Если встали ограничения, отключить фичу для проекта через <UseRuntimeAsync>false</UseRuntimeAsync> и оставить отчёт команде .NET. Сроки поджимают: по официальной политике поддержки .NET 8 и .NET 9 перестают поддерживаться 10 ноября 2026 года, а .NET 11 RC2 вышел 13 октября с go-live лицензией. Окно для экспериментов как раз сейчас. Вывод Самое интересное здесь, на мой взгляд, не наносекунды, а выбор архитектуры. Java с виртуальными потоками позволила во многих сценариях писать блокирующий код без async-сигнатур, переложив приостановку на JVM, хотя асинхронные API в экосистеме никуда не делись. Kotlin до сих пор строит конечный автомат в компиляторе, ровно как C# последние двенадцать лет. .NET в 2023 году отказался от зелёных потоков и пошёл третьим путём: модель программирования оставили прежней, а реализацию перенесли туда, где у неё больше информации. Для разработчика на C# это означает простую вещь: ответ «async — это синтаксический сахар над конечным автоматом» теперь неполный. Компилятор больше не выпускает привычный MoveNext, приостановку и возобновление организуют VM и JIT, и JIT может оптимизировать границы между вложенными async-методами, потому что видит цепочку вызовов. Для разработчика на JVM это хороший повод посмотреть на корутины Kotlin иначе: компиляторная схема не единственная, и платформа с похожей моделью только что показала, сколько она стоит. Ссылка на мой репозиторий с экспериментом Начать стоит с README.md: там все таблицы этой статьи, включая разницу между запуском в одном процессе и в отдельных. shared/Chains.cs — измеряемые цепочки. src/AsyncProbe/Program.cs — стек-трейсы и аллокации. bench/AsyncBench/Program.cs — бенчмарки. ./run.sh — собирает всё с нуля и сверяет результат с инвариантами из expected.txt. ИСТОЧНИКИРепозиторий с экспериментом What's new in .NET 11 runtime, Microsoft Learn, обновлено для RC2 Toub S., Performance Improvements in .NET 11, .NET Blog, 15.09.2026 Runtime-async, черновик изменений ECMA-335, dotnet/runtime Green Thread Experiment Results, dotnet/runtimelab #2398, 08.09.2023 .NET 9 Runtime Async Experiment, dotnet/runtime #94620 .NET and .NET Core Support Policy Announcing .NET 11 Release Candidate 2, .NET Blog, 13.10.2026 JDK 21, OpenJDK Kotlin Coroutines, KEEP Чтобы расти до уровня Senior C#-разработчика и браться за более сложные задачи, важно не только уверенно писать код, но и понимать, как работает .NET под капотом. На курсе «C#-разработчик. Продвинутый уровень» можно углубить эти знания, научиться находить узкие места в приложениях и принимать более обоснованные архитектурные решения. Пройдите вступительный тест, чтобы оценить свой текущий уровень, выявить пробелы и понять, какие навыки стоит подтянуть для следующего карьерного шага.
Android Compose Compiler Metrics in 2026 --- title: "Compose Compiler Metrics: Fix Recomposition Bugs Fast" published: true description: "Learn to parse Compose compiler reports, identify stability inference failures, fix unstable classes, and reclaim your 16ms frame budget." tags: android, kotlin, mobile, architecture canonical_url: https://mvpfactory.co/blog/compose-compiler-metrics-recomposition --- ## What You Will Build By the end of this tutorial you will have compiler-generated `.txt` metric files wired into your build, the vocabulary to read them, and a repeatable process for eliminating unnecessary recompositions before you ever open a profiler. In a mid-size e-commerce app with 87 composables and a heavy list-based UI, fixing three unstable data classes cut recompositions per scroll gesture from 340 down to 91 — a 73% reduction with zero business logic changes. ## Prerequisites - Kotlin Gradle Plugin 1.8+ - Compose Compiler 1.5+ - AGP 8+ --- ## Step 1: Enable the Compiler Report Add this to your `build.gradle.kts`. This is the minimal setup to get this working: tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { compilerOptions { freeCompilerArgs.addAll( "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + project.layout.buildDirectory.get().asFile.absolutePath + "/compose_reports", "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + project.layout.buildDirectory.get().asFile.absolutePath + "/compose_metrics" ) } } After a build, check `build/compose_reports/` for four files: `*-classes.txt`, `*-composables.txt`, `*-composables.csv`, and `*-module.json`. --- ## Step 2: Read the Composables Report Open `*-composables.txt`. The compiler annotates every composable with its stability verdict: restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ProductCard( stable product: Product stable onClick: Function0<Unit> ) restartable scheme("[androidx.compose.ui.UiComposable]") fun CartRow( unstable cartItem: CartItem stable onRemove: Function0<Unit> ) `CartRow` is restartable but **not skippable**. One unstable parameter blocks the entire composable. Every time the parent recomposes, `CartRow` follows — regardless of whether `cartItem` actually changed. Here is the pattern I use in every project to reason about this: | Status | Can Skip Unchanged Inputs | Recomposes on Parent Recompose | |---|---|---| | Restartable + Skippable | Yes | No | | Restartable only | No | Yes | | Not restartable | No | Inline in parent | Non-restartable is a **compiler decision**, not a developer target. Focus your work on moving composables from restartable-only into restartable-and-skippable. --- ## Step 3: Hunt Stability Inference Failures in `*-classes.txt` unstable class CartItem { stable val id: String stable val quantity: Int unstable val price: BigDecimal <runtime stability> = Unstable } `BigDecimal` is not on the Compose stability allowlist, so the entire class fails. The most common offenders in production are `BigDecimal`, `Date`, `Calendar`, and `java.util` collections (`List`, `Map`, `Set` — replace with `kotlinx.collections.immutable`). **If you own the class**, annotate it directly: @Immutable data class CartItem( val id: String, val quantity: Int, val price: BigDecimal ) **If you don't own it**, add it to a stability config file: // stability_config.conf com.squareup.moshi.JsonClass java.math.BigDecimal Reference it in Gradle: `stabilityConfigurationFile = rootProject.file("stability_config.conf")`. --- ## Step 4: Trace the Cascade The docs do not mention this explicitly, but one unstable class at the top of your tree can invalidate an entire subtree on every state write. Take a `CheckoutScreen` receiving an unstable `OrderSummary`. It hosts `PricingBreakdown`, `DeliveryOptions`, and `ConfirmButton` — none of which changed. Because `CheckoutScreen` is non-skippable, all three children recompose on every parent invalidation. If `OrderSummary` is rebuilt from a `StateFlow` on every emission, you are triggering that cascade on every keystroke in a nearby search field sharing the same composition scope. In a production app with 120 composables, I measured a single non-skippable screen-level composable generating over 300 unnecessary recompositions per second during a list scroll — 11ms of a 16ms frame budget, gone. The compiler report tells you *which parameter* is blocking skippability. The runtime profiler only shows you the symptom. Fix it at the source. --- ## Gotchas - **AGP 8+ deprecates `project.buildDir`** — use `layout.buildDirectory` as shown above, or the path will resolve incorrectly. - **`@Stable` is a promise, not a guarantee.** If you annotate a class `@Stable` but mutate it outside of Compose state, you will silently skip recompositions that should have happened. - **`kotlinx.collections.immutable` is not a drop-in `List` replacement.** You need to update call sites to `toPersistentList()` — easy to miss in ViewModels that return plain `listOf()`. - **Non-restartable composables are not your target.** Do not try to "achieve" non-restartable status. The compiler decides that for cheap, side-effect-free composables. Annotating toward it manually is a waste of effort. --- ## Three Things to Do Now 1. **Wire compiler reports into CI and diff `*-composables.txt` on PRs.** A new non-skippable composable in a feature branch should be a blocking comment, not a post-ship discovery. 2. **Audit every class that crosses a composable boundary.** Any `unstable` class in a screen-level parameter list is a guaranteed regression at scale. 3. **Set a recomposition budget per screen.** Target fewer than 5 recompositions per second on idle screens in Perfetto. If more than 20% of your composables are non-skippable, you have structural stability problems — no amount of `remember {}` will save you. I keep a recomposition budget checklist in my notes — right next to the HealthyDesk reminder that fires every 45 minutes telling me to stand up. Both exist because the cost of ignoring a small chronic drain is surprisingly high. **Further reading:** [Compose compiler metrics — official docs](https://developer.android.com/develop/ui/compose/performance/stability/diagnose) | [kotlinx.collections.immutable](https://github.com/Kotlin/kotlinx.collections.immutable)
🚀 kotlinx.serialization 1.12.0 is here! ⚡ Up to 30% faster decoding ✅ Stabilized Json & Core APIs 🛡️ Better JSON exceptions 📦 Protobuf unknown-field support 🔧 Uuid & Instant improvements 🔗 github.com/Kotlin/kotlinx.serialization/releases/… #Kotlin #KMP #AndroidDev #Kotlin
🚀 kotlinx.serialization 1.12.0 is here! ⚡ Up to 30% faster decoding ✅ Stabilized Json & Core APIs 🛡️ Better JSON exceptions 📦 Protobuf unknown-field support 🔧 Uuid & Instant improvements 🔗 github.com/Kotlin/kotlinx.serialization/releases/… #Kotlin #KMP #AndroidDev #Kotlin
github.com/Kotlin/kotlin-lsp/blob/main/RELEASES.md kotlin lspのリリース情報を眺めているんだけど、lspにデバッグ機能も統合してるの?
I kinda like kotlinx.html in Kotlin; it's an eDSL for writing HTML in actual Kotlin. So I can put break points in my template code. It's a tiny bit slower than string-with-some-interpolation type of template libraries; but it's so little that it's a price I'm willing to pay. Also there's more impact on compile times than with string-with-some-interpolation type of template libraries. The typesafety you get is what you already have in Kotlin, and there a little extra in that you cannot nest certain HTML-elements in certain other elements (adhering to the HTML spec). You can come up with your own elements and attributes, but the ones that are in the HMTL-spec are usually available in kotlinx.html as well (and thus can be auto-completed). A large part of this library is generated from the actual HTML spec. Claude Code seems to have not problem writing it (I barely write templates myself these days). kotlin/kotlinx.html dzone.com
ktfmt moved to the kotlin org on GitHub Kotlin/ktfmt
Kotlin は JetBrains 製なのもあって最近まで公式の LSP はなかったですね Kotlin/kotlin-lsp