14. Runtime Async: async-методы в рантайме¶
О главе
Цель: понять, как async переезжает из компилятора в рантайм: что меняется в IL, как JIT сохраняет кадр при приостановке, что при этом происходит со стеком, контекстами, аллокациями и совместимостью.
Лабораторная: start/ — флаг в метаданных и трасса исключения (info, stack). final/ — плюс замер цены (cost) и поведение контекстов (context). Одна и та же программа собирается в две модели: -p:RuntimeAsync=on включает runtime async в компиляторе.
Визуализация: Две модели рядом — Цепочка из трёх методов с приостановкой: что лежит на стеке и в куче в обеих моделях.
Статус: ✅ проверено на стенде Ubuntu 26.04, 2 ядра (а .NET 10.0.12 ещё и на Windows 11, 8 ядер: режимы info, stack, context, cost, JIT-листинг; результаты info, stack, context совпали, различия — во вкладках; .NET 11 на Windows не проверялся): .NET 10.0.12 (SDK 10.0.401) и .NET 11.0 RC1 (SDK 11.0.100-rc.1.26425.128). .NET 10 — превью, .NET 11. Фича экспериментальная: детали будут меняться, перепроверьте на своей версии. Листинг JIT снят на Linux x64 (System V), на Windows регистры другие.
14.1. Зачем это нужно¶
В главах 1–3 мы разобрали, как компилятор превращает async-метод в структуру-машину состояний, builder и awaiter, а при первой реальной приостановке копирует машину в бокс на куче. У этой схемы есть цена:
- на каждый
async-метод — отдельный тип машины состояний,MoveNextс большимswitch, поле<>u__Nпод каждый awaiter; - на каждую приостановку в цепочке — свой бокс, а на каждый возвращаемый
Task— объектTask(если неValueTask); - при возобновлении цепочки
A → B → Cрантайм вызываетMoveNextу каждого уровня по очереди через продолженияTask; - трасса стека на «возобновлённом» пути состоит из
MoveNextи служебных кадров, которые приходится потом чистить.
Runtime async — другое разделение работы между компилятором и рантаймом: компилятор не генерирует машину состояний, а помечает метод флагом Async в метаданных и превращает каждый await в вызов AsyncHelpers.Await(...). Всё остальное делает JIT: когда await нужно приостановить, он сам сохраняет живые локальные переменные в объект Continuation и возвращается к вызывающему.
14.2. Как включить¶
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Condition="'$(RuntimeAsync)' == 'on'">
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<Features>$(Features);runtime-async=on</Features>
<NoWarn>$(NoWarn);SYSLIB5007</NoWarn>
</PropertyGroup>
</Project>
| Что | .NET 10.0.12 | .NET 11.0 RC1 |
|---|---|---|
Флаг компилятора Features=runtime-async=on |
нужен | нужен (без него — машины состояний, проверено) |
EnablePreviewFeatures |
нужен (иначе CA2252: Using 'Await' requires opting into preview features) | не нужен |
NoWarn=SYSLIB5007 |
нужен (иначе ошибка: AsyncHelpers помечен [Experimental]) |
не нужен |
Переменная окружения DOTNET_RuntimeAsync=1 при запуске |
нужна | не нужна |
Факт: на .NET 10 без переменной окружения программа не запускается
Сборка с runtime-async=on на .NET 10.0.12 запущена без DOTNET_RuntimeAsync=1:
Unhandled exception. System.TypeLoadException: Could not load type 'Program' from assembly 'Ch14.Final, ...' because the format is invalid.
Рантайм не понимает метод с флагом Async и отказывается загружать весь тип. С DOTNET_RuntimeAsync=1 всё работает. На .NET 11 RC1 переменная не нужна: рантайм включает поддержку сам.
Факт: библиотека, собранная с флагом, «заражает» приложение
Проверка (.NET 10.0.12): библиотека Lib собрана с runtime-async=on, приложение собрано без. Приложение без переменной падает с TypeLoadException: Could not load type 'Lib'; с DOTNET_RuntimeAsync=1 работает в обе стороны: приложение await'ит метод библиотеки, а библиотека await'ит метод приложения на машине состояний (результаты 3 и 42). Приложению, которое использует такую библиотеку, нужна и переменная, и NoWarn=CA2252 (метки превью передаются дальше по зависимостям). Поэтому на .NET 10 выпускать библиотеки в runtime async нельзя.
14.3. Что компилятор делает с методом¶
Лабораторный метод C (строки 104–107 final/Program.cs): if (suspend) await Task.Yield();. Что получается в разных моделях.
- Номер состояния.
- Builder.
- Аргумент метода становится полем машины.
- Поле под awaiter, живущее через
await.
Что видно в IL:
- строка 2:
flags(2000)— этоMethodImplAttributes.Async(0x2000). Метод по-прежнему «возвращаетTask» в метаданных, но тело — линейный код без машины состояний, безswitch, безtry/catch; - строка 9: обычный
if (suspend); - строки 16–17: паттерн awaiter остался,
IsCompletedпроверяется в IL; - строка 19: приостановка — вызов
AsyncHelpers.UnsafeAwaitAwaiter. Это не обычный вызов, JIT его распознаёт. Дляawait taskкомпилятор вызываетAsyncHelpers.Await(task)(так вB: сначала вызовC(bool), затемAwait(Task)).
Факт: вложенных типов не остаётся
dotnet run -c Release --project start -- info (.NET 10.0.12):
# обычная сборка
C: Async = False
вложенные типы Probe: <>c, <>c__DisplayClass4_0, <A>d__5, <B>d__6, <C>d__7, <Context>d__4, <Cost>d__3, <Stack>d__2, <StackA>d__8, <StackB>d__9, <StackC>d__10
# -p:RuntimeAsync=on, DOTNET_RuntimeAsync=1
C: Async = True
вложенные типы Probe: <>c, <>c__DisplayClass4_0
Оставшиеся типы — это кеш лямбд и замыкание, к async отношения не имеют. Машин состояний нет ни у одного метода: метаданные стали меньше, а AsyncStateMachineAttribute исчез.
14.4. Что делает рантайм¶
Continuation: кадр, снятый со стека¶
| CoreLib 10.0.12: System.Runtime.CompilerServices.Continuation | |
|---|---|
- Цепочка: continuation нижнего кадра (вызываемого) ссылается на continuation вызывающего.
- Указатель на сгенерированный JIT код возобновления этого метода.
- Номер точки возобновления (как
<>1__state). - Флаги: куда возвращаться после возобновления (пул, захваченный
SynchronizationContext,TaskScheduler), нужен ли слот под исключение, где результат. - Живые локальные не-ссылочных типов, запакованные JIT'ом.
- Живые ссылки (в том числе сохранённый
ExecutionContext).
Объект такой собирается только при реальной приостановке. Пока IsCompleted == true, ни Continuation, ни Task не создаются: синхронный путь в runtime async — просто вызовы методов.
AsyncHelpers.Await¶
awaitнаTask— тот же паттерн awaiter, что и в машине состояний, только теперь он записан в CoreLib на C#.- Awaiter кладётся в поток-локальное поле: ждать будет не builder, а код «над цепочкой» (см. ниже).
AsyncSuspend—[Intrinsic]: JIT превращает вызов в «вернуть вверх по стеку объектContinuation». Из обычного C# вызвать его нельзя (тамthrow new UnreachableException()).
JIT-код метода C¶
Тот же метод C в JIT-ассемблере (x64, DOTNET_TieredCompilation=0, DOTNET_JitDisasm=Probe:C). Метод помечен ; async:
- Скрытый первый аргумент — continuation. Если он не
null, метод вызван не «с нуля», а для возобновления (веткаIG08). UnsafeAwaitAwaiterвернул вrcxненулевойContinuation— значит,awaitприостановился.- Обычный выход: возвращаем
nullвrcx(«завершился, продолжения нет»). - Приостановка: выделить
Continuation(хелпер рантайма). - Сохранить указатель на код возобновления.
- Захватить
ExecutionContext(и положить его вGCData). - Вернуть
Continuationвызывающему — вызывающий поймёт по ненулевомуrcx, что надо приостановиться тоже. - Возобновление: достать сохранённый
ExecutionContextи восстановить его на потоке.
Те же восемь шагов, что в аннотациях на вкладке Linux, но в соглашении Windows x64: continuation приходит в rcx (а не в rdi), флаг suspend — в dl (а не в sil), возвращаемое «Continuation или null» — в rcx и там и там. Отличия: в начале кадр с сохранением rsi/rbx и 40 байтами резерва под «shadow space» вызываемых методов (на Linux кадр строится иначе), а адрес кода возобновления — 0x7FF8… вместо 0x7CEF…. Размер метода 124 байта.
Возвращаемое значение «null или Continuation» заменяет весь механизм builder'а: каждый вызывающий проверяет его после вызова и, если оно не null, сам добавляет свой кадр в цепочку и возвращается дальше.
Кто в итоге ждёт?¶
Цепочка Continuation рано или поздно доходит до границы между runtime-async кодом и миром Task: до метода, который вызвали как обычный Task-возвращающий (не из runtime-async кода). Там создаётся ThunkTask (наследник Task), и с него начинается обычное продолжение через INotifyCompletion:
- Вызвать функцию возобновления самого нижнего кадра.
- Он снова приостановился (вернул новый
Continuation) — подписываемся на новый awaiter и выходим. - Он завершился — переходим к вызывающему.
- Исключение: идём по цепочке вверх в поисках кадра, у которого есть
catch. - Дошли до конца цепочки — завершаем
Task.
QueueContinuationFollowUpActionIfNecessary (там же) смотрит на Flags и решает, где продолжать: на пуле потоков, Post в захваченный SynchronizationContext или в захваченный TaskScheduler — это то же самое решение, которое в главе 5 принимали awaiter'ы.
Интерактивная схема¶
Цепочка из трёх методов с приостановкой: что лежит на стеке и в куче в обеих моделях. Открыть на весь экран.
14.5. Поведение¶
Что осталось, как было¶
Режим context (dotnet run -c Release --project final -- context) одинаково работает в обеих моделях на обоих рантаймах:
AsyncLocal после await Set(): до
AsyncLocal после Yield: перед Yield
после Yield под SynchronizationContext: Post вызван 1 раз, контекст тот же = True
после ConfigureAwait(false): контекст = null
AsyncLocal, записанный в вызываемомasync-методе, не виден вызывающему (границаExecutionContext, глава 7, сохраняется).- Продолжение возвращается в захваченный
SynchronizationContext. ConfigureAwait(false)отпускает контекст.
Что изменилось: трасса стека¶
Факт: на .NET 10 после приостановки пропадают кадры промежуточных async-методов
dotnet run -c Release --project final -- stack: цепочка Stack → StackA → StackB → StackC, в StackC после await Task.Yield() бросается исключение.
InvalidOperationException: из StackC
at Probe.StackC() in Program.cs:line 112
at Probe.StackB() in Program.cs:line 111
at Probe.StackA() in Program.cs:line 110
at Probe.Stack() in Program.cs:line 37
InvalidOperationException: из StackC
at Probe.StackC() in Program.cs:line 112
at System.Runtime.CompilerServices.AsyncHelpers.ThunkTaskCore.MoveNext[T,TOps](T task)
--- End of stack trace from previous location ---
at Probe.Stack() in Program.cs:line 37
StackB и StackA на реальном стеке в момент броска не было (они «лежали» в цепочке Continuation), и на .NET 10 они в трассу не попадают, зато появляется служебный кадр ThunkTaskCore.MoveNext. На .NET 11 RC1 трасса со всеми четырьмя кадрами, как у обычной сборки (см. §14.7). Если вы разбираете трассы автоматически (логи, телеметрия), проверьте, что они не рассчитывают на присутствие кадров промежуточных методов.
Совместимость¶
- Сигнатура метода в метаданных не меняется (
Task,Task<T>,ValueTask…), поэтому вызывающий не знает, как реализован метод: вызов из обычного async-кода и обратно работает (проверено в обе стороны на .NET 10, см. §14.2). - Флаг выставляется на всю сборку, отдельные методы выбирать нельзя.
- Отладка и профилировщики: поддержка в превью неполная (в Visual Studio и Rider не проверялось).
14.6. Цена: что показали замеры¶
Режим cost (dotnet run -c Release --project final -- cost): цепочка A → B → C из трёх async-методов; await A(false) не приостанавливается ни на одном уровне (2 млн вызовов), await A(true) приостанавливается в C через Task.Yield() (200 тыс. вызовов). Прогрев по времени (с паузами), чтобы цифры не были цифрами неоптимизированного кода Tier-0. Три запуска на каждую конфигурацию.
| Конфигурация | Без приостановки, нс/вызов цепочки | С приостановкой, нс/вызов | С приостановкой, Б/вызов |
|---|---|---|---|
| .NET 10.0.12, обычный async | 34–39 | 580–670 | 288 |
| .NET 10.0.12, runtime async | 6,5–11 | 1000–1430 | 322 |
| .NET 11 RC1, обычный async | 54–84 | 1150–1860 | 285–287 |
| .NET 11 RC1, runtime async | 29–50 | 430–770 | 160 |
| Конфигурация | Без приостановки, нс/вызов цепочки | С приостановкой, нс/вызов | С приостановкой, Б/вызов |
|---|---|---|---|
| .NET 10.0.12, обычный async | 23–29 | 890–1000 | 287 |
| .NET 10.0.12, runtime async | 2,5–3,7 | 760–770 | 330 |
| .NET 11 RC1 | не проверялось: на Windows-стенде нет .NET 11 |
Факт: синхронный путь в runtime async заметно дешевле, а цена приостановки зависит от версии
Без приостановки runtime async быстрее в 3–6 раз на .NET 10 и в 1,5–3 раза на .NET 11 RC1 (три await, ни одного Task, ни одной машины). Это самый частый случай в реальном коде (кеши, уже завершённые операции).
Приостановка на .NET 10.0.12 в runtime async дороже обычной модели: 1000–1430 нс против 580–670 и на 34 байта больше. Каждый кадр цепочки — это три объекта (Continuation, byte[] для данных, object[] для ссылок), и оптимизаций для этого путь в превью ещё нет. На .NET 11 RC1 картина обратная: 160 байт против ~286, и время в 2–3 раза меньше. Абсолютные цифры .NET 11 в нашем стенде шумят сильнее (на двух ядрах с активным фоновым JIT), поэтому сравнивайте только внутри одной версии.
На Windows (.NET 10.0.12, 8 ядер, три запуска) картина другая. Синхронный путь выигрывает ещё сильнее (в 8–10 раз: 2,5–3,7 против 23–29 нс), а приостановка в runtime async не дороже, а чуть дешевле обычной (≈ 765 против 890–1000 нс). Байт больше, как и на Linux (330 против 287, +43; на Linux +34). Выводы по аллокациям и по синхронному пути одинаковы на обеих ОС, вывод «приостановка дороже» — только для нашего Linux-стенда причина не выяснена: подозрение на цену пробуждения потока в Task.Yield (см. главу 12), которая на Windows и Linux различается, а не на сам runtime async. Поэтому сравнивайте конфигурации только внутри одной ОС.
Вывод: на .NET 10 runtime async — повод изучать модель, а не включать её в продакшене; оценка по цифрам .NET 11 RC1 осторожная, до релиза перемерить.
Под капотом: почему без прогрева цифры врут
Первый вариант cost мерил сразу после короткого прогрева и дал для runtime async на .NET 10 «58–76 нс» и «320–560 Б» против «6–10 нс» и «312 Б» (при DOTNET_TieredCompilation=0). Разница — в неоптимизированном коде Tier-0: многоуровневая компиляция переводит горячие методы на оптимизированный код только спустя ~100 мс бездействия компилятора. Поэтому прогрев в лабораторной идёт по времени, с паузами. Тот же эффект есть у любого бенчмарка, написанного вручную; BenchmarkDotNet делает прогрев сам.
14.7. .NET 11 RC1: что изменилось¶
.NET 11 (RC1, 11.0.100-rc.1.26425.128): runtime async почти готов
- Включение. Переменная
DOTNET_RuntimeAsync=1иEnablePreviewFeatures/NoWarn=SYSLIB5007не нужны; достаточно<Features>runtime-async=on</Features>в проекте. Без флага компилятор по-прежнему генерирует машины состояний (проверено). - Трасса стека полная:
StackC,StackB,StackA,Stack, как у обычной сборки. - Аллокации при приостановке цепочки из трёх методов: 160 Б вместо ~286 Б у машин состояний (на .NET 10: 322 Б против 288).
- Библиотека рантайма: в общем фреймворке (все сборки
Microsoft.NETCore.AppкромеSystem.Private.CoreLib, который в подсчёт не вошёл) методов с флагомAsync— 800 (System.Net.Http— 119,System.Private.Xml— 310,System.Net.Mail— 36 и т. д.), на машинах состояний осталось 175. На .NET 10.0.12 — 0 против 853: библиотеки рантайма ещё не собирались с runtime async. Это значит, что на .NET 11 ваш код ужеawait'ит методы в runtime async, не зная об этом. - Пока включается в проекте вручную. Станет ли это умолчанием в релизе .NET 11 или .NET 12 — не проверено, смотрите заметки к релизу.
Итоги¶
- Runtime async переносит работу машины состояний из компилятора в JIT: метод получает флаг
Async,awaitстановится вызовомAsyncHelpers.Await, а при приостановке JIT сохраняет живые переменные в цепочку объектовContinuation. - Синхронный путь стал заметно дешевле (нет
Task, бокса, builder'а). - Цепочка продолжается через
ThunkTaskи привычныеINotifyCompletion,ExecutionContextиSynchronizationContextсохраняют свою семантику. - На .NET 10 это превью: нужна переменная окружения, библиотеки с флагом «заражают» приложение, пропадают кадры в трассе, приостановка дороже. На .NET 11 RC1 переменная не нужна, трасса полная, приостановка дешевле, а большая часть async-методов библиотек фреймворка уже собрана этим способом.
Код лабораторной¶
Запуск из папки главы: dotnet run -c Release --project start -- info (или final; с runtime async — -p:RuntimeAsync=on, на .NET 10 ещё $env:DOTNET_RuntimeAsync = 1).
| final/Program.cs | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | |