Всем привет, меня зовут Сергей Прощаев, я 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)
}

Java пошла другим путём. В JDK 21, вышедшем 19 сентября 2023 года, появились виртуальные потоки (JEP 444): код остаётся синхронным и блокирующим, а приостановкой и возобновлением занимается JVM, снимая стек виртуального потока с несущего.

// (Java 21, схематично) код блокирующий, приостанавливает и возобновляет его JVM
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> callBlockingApi(id));
}

Команда.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) { }
}

С runtime‑async компилятор не генерирует пару IAsyncStateMachine и MoveNext. Он оставляет компактный IL, ставит методу флаг async (MethodImplOptions.Async, значение 0×2000 по черновику изменений 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>

Есть деталь, которую легко пропустить: сами библиотеки.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)

Важная оговорка из той же документации: стек‑трейсы исключений из 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 запланирован:

  1. Обновить проект на.NET 11 без флага и снять базовые метрики: аллокации в секунду, паузы GC, p99 латентности. Часть выигрыша вы получите уже здесь, потому что BCL собрана в новом режиме.

  2. В отдельной ветке включить <Features>$(Features);runtime-async=on</Features> и прогнать те же замеры на той же версии SDK и рантайма, тем же способом. Так эффект обновления BCL отделяется от эффекта флага. Сравнивать аллокации, а не только время.

  3. Найти горячий путь с глубокой цепочкой await и написать на него бенчмарк BenchmarkDotNet с MemoryDiagnoser. Там будет видно, сливаются ли вызовы.

  4. Проверить всё, что читает стек: логирование, APM‑агент, группировку ошибок. Если что‑то ломается, лучше узнать до GA, а не после.

  5. Если встали ограничения, отключить фичу для проекта через <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.

ИСТОЧНИКИ

Чтобы расти до уровня Senior C#‑разработчика и браться за более сложные задачи, важно не только уверенно писать код, но и понимать, как работает.NET под капотом. На курсе «C#‑разработчик. Продвинутый уровень» можно углубить эти знания, научиться находить узкие места в приложениях и принимать более обоснованные архитектурные решения.

Пройдите вступительный тест, чтобы оценить свой текущий уровень, выявить пробелы и понять, какие навыки стоит подтянуть для следующего карьерного шага.

Комментарии (1)


  1. Tikani
    28.10.2026 20:52

    Почему техлид Java|Kotlin направления пишет что-то про кишки .NET, с которым навряд ли взаимодействует на работе на ежедневной основе? Или тут как у Стивена Хокинга, который в молодости преподавал в вузе и сам читал учебник, по которому преподавал студентам, с опережением программы на 2 недели?