Перейти к содержанию

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. Как включить

Ch14.Final.csproj
<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();. Что получается в разных моделях.

C# без сахара: машина состояний (ilspycmd -ds AsyncAwait=false), сокращено
private struct <C>d__7 : IAsyncStateMachine
{
    public int <>1__state;                                  // (1)!
    public AsyncTaskMethodBuilder <>t__builder;             // (2)!
    public bool suspend;                                    // (3)!
    private YieldAwaitable.YieldAwaiter <>u__1;             // (4)!

    private void MoveNext()
    {
        int num = <>1__state;
        try
        {
            YieldAwaitable.YieldAwaiter awaiter;
            if (num == 0) { awaiter = <>u__1; <>u__1 = default; num = (<>1__state = -1); goto IL_0065; }
            if (suspend)
            {
                awaiter = Task.Yield().GetAwaiter();
                if (!awaiter.IsCompleted)
                {
                    num = (<>1__state = 0);
                    <>u__1 = awaiter;
                    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                    return;
                }
                goto IL_0065;
            }
            goto end_IL_0007;
            IL_0065: awaiter.GetResult();
            end_IL_0007:;
        }
        catch (Exception exception) { <>1__state = -2; <>t__builder.SetException(exception); return; }
        <>1__state = -2;
        <>t__builder.SetResult();
    }
}
  1. Номер состояния.
  2. Builder.
  3. Аргумент метода становится полем машины.
  4. Поле под awaiter, живущее через await.
IL метода C при runtime-async=on (ilspycmd -il)
.method private hidebysig static
    class [System.Runtime]System.Threading.Tasks.Task C (bool suspend) cil managed flags(2000)
{
    .locals init (
        [0] valuetype YieldAwaitable/YieldAwaiter,
        [1] valuetype YieldAwaitable
    )
    IL_0000: ldarg.0
    IL_0001: brfalse.s IL_0027
    IL_0003: call valuetype YieldAwaitable Task::Yield()
    IL_0008: stloc.1
    IL_0009: ldloca.s 1
    IL_000b: call instance valuetype YieldAwaitable/YieldAwaiter YieldAwaitable::GetAwaiter()
    IL_0010: stloc.0
    IL_0011: ldloca.s 0
    IL_0013: call instance bool YieldAwaiter::get_IsCompleted()
    IL_0018: brtrue.s IL_0020
    IL_001a: ldloc.0
    IL_001b: call void AsyncHelpers::UnsafeAwaitAwaiter<YieldAwaiter>(!!0)
    IL_0020: ldloca.s 0
    IL_0022: call instance void YieldAwaiter::GetResult()
    IL_0027: ret
}

Что видно в 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
internal sealed class Continuation
{
    public Continuation Next;                                       // (1)!
    public unsafe delegate*<Continuation, Continuation> Resume;     // (2)!
    public uint State;                                              // (3)!
    public CorInfoContinuationFlags Flags;                          // (4)!
    public byte[] Data;                                             // (5)!
    public object[] GCData;                                         // (6)!
    // ...
}
  1. Цепочка: continuation нижнего кадра (вызываемого) ссылается на continuation вызывающего.
  2. Указатель на сгенерированный JIT код возобновления этого метода.
  3. Номер точки возобновления (как <>1__state).
  4. Флаги: куда возвращаться после возобновления (пул, захваченный SynchronizationContext, TaskScheduler), нужен ли слот под исключение, где результат.
  5. Живые локальные не-ссылочных типов, запакованные JIT'ом.
  6. Живые ссылки (в том числе сохранённый ExecutionContext).

Объект такой собирается только при реальной приостановке. Пока IsCompleted == true, ни Continuation, ни Task не создаются: синхронный путь в runtime async — просто вызовы методов.

AsyncHelpers.Await

CoreLib 10.0.12: AsyncHelpers.Await<T> и UnsafeAwaitAwaiter (сокращено)
[MethodImpl(MethodImplOptions.Async)]
public static T Await<T>(Task<T> task)
{
    TaskAwaiter<T> awaiter = task.GetAwaiter();
    if (!awaiter.IsCompleted)
        UnsafeAwaitAwaiter(awaiter);                                // (1)!
    return awaiter.GetResult();
}

[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.Async)]
public static void UnsafeAwaitAwaiter<TAwaiter>(TAwaiter awaiter) where TAwaiter : ICriticalNotifyCompletion
{
    Continuation continuation = t_runtimeAsyncAwaitState.SentinelContinuation
        ?? (t_runtimeAsyncAwaitState.SentinelContinuation = new Continuation());
    t_runtimeAsyncAwaitState.Notifier = awaiter;                    // (2)!
    AsyncSuspend(continuation);                                     // (3)!
}
  1. await на Task — тот же паттерн awaiter, что и в машине состояний, только теперь он записан в CoreLib на C#.
  2. Awaiter кладётся в поток-локальное поле: ждать будет не builder, а код «над цепочкой» (см. ниже).
  3. AsyncSuspend — [Intrinsic]: JIT превращает вызов в «вернуть вверх по стеку объект Continuation». Из обычного C# вызвать его нельзя (там throw new UnreachableException()).

JIT-код метода C

Тот же метод C в JIT-ассемблере (x64, DOTNET_TieredCompilation=0, DOTNET_JitDisasm=Probe:C). Метод помечен ; async:

JIT x64 (System V), runtime async, .NET 10.0.12
G_M000_IG02:
       test     rdi, rdi                       ; (1)!
       jne      SHORT G_M000_IG08
       test     sil, sil                       ; suspend?
       je       SHORT G_M000_IG04
G_M000_IG03:
       xor      esi, esi
       xor      rdi, rdi
       call     [AsyncHelpers:UnsafeAwaitAwaiter[YieldAwaiter](YieldAwaiter)]
       test     rcx, rcx                       ; (2)!
       jne      SHORT G_M000_IG06
G_M000_IG04:
       xor      ecx, ecx                       ; (3)!
       ...
       ret
G_M000_IG06:
       mov      rdi, rcx
       mov      esi, 1
       xor      edx, edx
       call     [CORINFO_HELP_ALLOC_CONTINUATION]   ; (4)!
       mov      rbx, rax
       mov      rax, 0x7CEF0FC580D8
       mov      qword ptr [rbx+0x20], rax           ; (5)!
       ...
       call     [AsyncHelpers:CaptureExecutionContext()]   ; (6)!
       ...
       mov      rcx, rbx                            ; (7)!
       ...
       ret
G_M000_IG08:
       mov      rdi, gword ptr [rdi+0x18]
       mov      rdi, gword ptr [rdi+0x10]
       call     [AsyncHelpers:RestoreExecutionContext(ExecutionContext)]   ; (8)!
       jmp      SHORT G_M000_IG04
  1. Скрытый первый аргумент — continuation. Если он не null, метод вызван не «с нуля», а для возобновления (ветка IG08).
  2. UnsafeAwaitAwaiter вернул в rcx ненулевой Continuation — значит, await приостановился.
  3. Обычный выход: возвращаем null в rcx («завершился, продолжения нет»).
  4. Приостановка: выделить Continuation (хелпер рантайма).
  5. Сохранить указатель на код возобновления.
  6. Захватить ExecutionContext (и положить его в GCData).
  7. Вернуть Continuation вызывающему — вызывающий поймёт по ненулевому rcx, что надо приостановиться тоже.
  8. Возобновление: достать сохранённый ExecutionContext и восстановить его на потоке.
JIT x64 (Windows), runtime async, .NET 10.0.12
G_M000_IG01:
       push     rsi
       push     rbx
       sub      rsp, 40
G_M000_IG02:
       test     rcx, rcx                       ; continuation != null → возобновление
       jne      SHORT G_M000_IG08
       test     dl, dl                         ; suspend?
       je       SHORT G_M000_IG04
G_M000_IG03:
       xor      edx, edx
       xor      rcx, rcx
       call     [AsyncHelpers:UnsafeAwaitAwaiter[YieldAwaiter](YieldAwaiter)]
       test     rcx, rcx                       ; вернул Continuation → приостановка
       jne      SHORT G_M000_IG06
G_M000_IG04:
       xor      ecx, ecx                       ; нормальный выход: null
G_M000_IG05:
       add      rsp, 40
       pop      rbx
       pop      rsi
       ret
G_M000_IG06:
       mov      edx, 1
       xor      r8d, r8d
       call     [CORINFO_HELP_ALLOC_CONTINUATION]
       mov      rbx, rax
       mov      rax, 0x7FF8559F1488
       mov      qword ptr [rbx+0x20], rax      ; код возобновления
       xor      eax, eax
       mov      qword ptr [rbx+0x28], rax
       mov      rsi, gword ptr [rbx+0x18]
       call     [AsyncHelpers:CaptureExecutionContext()]
       lea      rcx, bword ptr [rsi+0x10]
       mov      rdx, rax
       call     CORINFO_HELP_ASSIGN_REF
       mov      rcx, rbx                       ; вернуть Continuation вызывающему
G_M000_IG07:
       add      rsp, 40
       pop      rbx
       pop      rsi
       ret
G_M000_IG08:
       mov      rcx, gword ptr [rcx+0x18]
       mov      rcx, gword ptr [rcx+0x10]
       call     [AsyncHelpers:RestoreExecutionContext(ExecutionContext)]
       jmp      SHORT G_M000_IG04

Те же восемь шагов, что в аннотациях на вкладке 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:

CoreLib 10.0.12: AsyncHelpers.ThunkTaskCore (сокращено)
public unsafe static void MoveNext<T, TOps>(T task) where T : Task where TOps : IThunkTaskOps<T>
{
    executionAndSyncBlockStore.Push();
    Continuation continuation = TOps.GetContinuationState(task);
    do
    {
        try
        {
            Continuation continuation2 = continuation.Resume(continuation);          // (1)!
            if (continuation2 != null)                                                // (2)!
            {
                continuation2.Next = continuation.Next;
                HandleSuspended<T, TOps>(task);
                executionAndSyncBlockStore.Pop();
                return;
            }
            continuation = continuation.Next;                                        // (3)!
        }
        catch (Exception ex)
        {
            Continuation continuation3 = UnwindToPossibleHandler(continuation);      // (4)!
            if (continuation3.Resume == null)
            {
                bool flag = ((ex is OperationCanceledException ex2) ? task.TrySetCanceled(ex2.CancellationToken, ex2) : task.TrySetException(ex));
                return;
            }
            continuation3.SetException(ex);
            continuation = continuation3;
        }
        if (continuation.Resume == null)
        {
            TOps.SetCompleted(task, continuation);                                   // (5)!
            return;
        }
    }
    while (!QueueContinuationFollowUpActionIfNecessary<T, TOps>(task, continuation));
}
  1. Вызвать функцию возобновления самого нижнего кадра.
  2. Он снова приостановился (вернул новый Continuation) — подписываемся на новый awaiter и выходим.
  3. Он завершился — переходим к вызывающему.
  4. Исключение: идём по цепочке вверх в поисках кадра, у которого есть catch.
  5. Дошли до конца цепочки — завершаем Task.

QueueContinuationFollowUpActionIfNecessary (там же) смотрит на Flags и решает, где продолжать: на пуле потоков, Post в захваченный SynchronizationContext или в захваченный TaskScheduler — это то же самое решение, которое в главе 5 принимали awaiter'ы.

Интерактивная схема

Цепочка из трёх методов с приостановкой: что лежит на стеке и в куче в обеих моделях. Открыть на весь экран.

14.5. Поведение

Что осталось, как было

Режим context (dotnet run -c Release --project final -- context) одинаково работает в обеих моделях на обоих рантаймах:

Ubuntu 26.04, .NET 10.0.12 (обычная сборка и runtime async), .NET 11 RC1 (обе) — вывод одинаковый
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() бросается исключение.

обычная сборка, .NET 10.0.12
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
runtime async, .NET 10.0.12
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).

start/Program.cs
// Глава 14. Что компилятор делает с async-методом при RuntimeAsync=on.
// PREDICT: сколько вложенных типов вида <...>d__N появится у Probe после сборки с -p:RuntimeAsync=on?
// TODO 1: соберите и запустите обычным способом, потом с -p:RuntimeAsync=on БЕЗ переменной DOTNET_RuntimeAsync (на .NET 10) — что произойдёт?
// TODO 2: запустите с DOTNET_RuntimeAsync=1 режим stack — какие кадры исчезли из трассы?
// Запуск: dotnet run -c Release --project start -- info
//         $env:DOTNET_RuntimeAsync = 1; dotnet run -c Release --project start -p:RuntimeAsync=on -- stack
using System.Reflection;

switch (args.FirstOrDefault() ?? "info")
{
    case "info": Probe.Info(); break;
    case "stack": await Probe.Stack(); break;
}

static class Probe
{
    const MethodImplAttributes AsyncFlag = (MethodImplAttributes)0x2000;   // MethodImplAttributes.Async

    public static void Info()
    {
        MethodInfo m = typeof(Probe).GetMethod(nameof(C), BindingFlags.Static | BindingFlags.NonPublic)!;
        Console.WriteLine($"рантайм: {Environment.Version}, DOTNET_RuntimeAsync={Environment.GetEnvironmentVariable("DOTNET_RuntimeAsync") ?? "<не задана>"}");
        Console.WriteLine($"C: Async = {(m.GetMethodImplementationFlags() & AsyncFlag) != 0}");
        var nested = typeof(Probe).GetNestedTypes(BindingFlags.NonPublic).Select(t => t.Name).ToArray();
        Console.WriteLine($"вложенные типы Probe: {(nested.Length == 0 ? "нет" : string.Join(", ", nested))}");
    }

    public static async Task Stack()
    {
        try { await A(); }
        catch (Exception e) { Console.WriteLine(e.StackTrace); }
    }

    static async Task A() { await B(); }
    static async Task B() { await C(); }
    static async Task C() { await Task.Yield(); throw new InvalidOperationException("из C"); }
}
final/Program.cs
// Глава 14. Один и тот же код, две модели async. Режимы (после -- ):
//   info     — что компилятор записал в метаданные: флаг Async или вложенная машина состояний;
//   stack    — трасса исключения после приостановки в цепочке A -> B -> C;
//   cost     — аллокации и время: цепочка из трёх async-методов, синхронный путь и приостановка;
//   context  — AsyncLocal и SynchronizationContext в этих методах.
// Сборка и запуск:
//   dotnet run -c Release --project final -- info                                       (машины состояний компилятора)
//   $env:DOTNET_RuntimeAsync = 1; dotnet run -c Release --project final -p:RuntimeAsync=on -- info    (.NET 10, runtime async)
using System.Diagnostics;
using System.Reflection;

switch (args.FirstOrDefault() ?? "info")
{
    case "info": Probe.Info(); break;
    case "stack": await Probe.Stack(); break;
    case "cost": await Probe.Cost(); break;
    case "context": await Probe.Context(); break;
}

static class Probe
{
    const MethodImplAttributes AsyncFlag = (MethodImplAttributes)0x2000;   // MethodImplAttributes.Async

    public static void Info()
    {
        Console.WriteLine($"рантайм: {Environment.Version}, DOTNET_RuntimeAsync={Environment.GetEnvironmentVariable("DOTNET_RuntimeAsync") ?? "<не задана>"}");
        MethodInfo m = typeof(Probe).GetMethod(nameof(C), BindingFlags.Static | BindingFlags.NonPublic)!;
        MethodImplAttributes flags = m.GetMethodImplementationFlags();
        Console.WriteLine($"C: MethodImplAttributes = {flags} (0x{(int)flags:X}), Async = {(flags & AsyncFlag) != 0}");
        Console.WriteLine($"C: AsyncStateMachineAttribute = {(m.GetCustomAttribute<System.Runtime.CompilerServices.AsyncStateMachineAttribute>() is not null)}");
        var nested = typeof(Probe).GetNestedTypes(BindingFlags.NonPublic).Select(t => t.Name).ToArray();
        Console.WriteLine($"вложенные типы Probe: {(nested.Length == 0 ? "нет" : string.Join(", ", nested))}");
    }

    public static async Task Stack()
    {
        try { await StackA(); }
        catch (Exception e)
        {
            Console.WriteLine(e.GetType().Name + ": " + e.Message);
            foreach (string line in e.StackTrace!.Split('\n'))
                Console.WriteLine("  " + System.Text.RegularExpressions.Regex.Replace(line.TrimEnd(), @" in .*[/\\]", " in "));
        }
    }

    public static async Task Cost()
    {
        const int sync = 2_000_000, susp = 200_000;
        // Прогрев по времени: пока JIT не переведёт методы с Tier-0 на оптимизированный код
        // (отсчёт многоуровневой компиляции начинается через ~100 мс бездействия), цифры бессмысленны.
        for (int round = 0; round < 3; round++)
        {
            long until = Stopwatch.GetTimestamp() + Stopwatch.Frequency / 3;
            while (Stopwatch.GetTimestamp() < until) { await A(false); await A(true); }
            await Task.Delay(300);
        }

        GC.Collect();
        long b0 = GC.GetAllocatedBytesForCurrentThread();
        long t0 = Stopwatch.GetTimestamp();
        for (int i = 0; i < sync; i++) await A(false);
        double ns = Stopwatch.GetElapsedTime(t0).TotalMilliseconds * 1e6 / sync;
        long bytes = GC.GetAllocatedBytesForCurrentThread() - b0;
        Console.WriteLine($"цепочка A→B→C без приостановки: {ns,7:F1} нс/оп, {bytes / (double)sync,6:F1} Б/оп");

        await Task.Run(async () =>
        {
            GC.Collect();
            long b1 = GC.GetTotalAllocatedBytes(precise: true);
            long t1 = Stopwatch.GetTimestamp();
            for (int i = 0; i < susp; i++) await A(true);
            double ns2 = Stopwatch.GetElapsedTime(t1).TotalMilliseconds * 1e6 / susp;
            long bytes2 = GC.GetTotalAllocatedBytes(precise: true) - b1;
            Console.WriteLine($"цепочка A→B→C, C приостанавливается: {ns2,7:F0} нс/оп, {bytes2 / (double)susp,6:F1} Б/оп");
        });
    }

    public static async Task Context()
    {
        // AsyncLocal, записанный в вызываемом async-методе, не виден вызывающему (как и всегда).
        var local = new AsyncLocal<string>();
        async Task Set() { local.Value = "из Set"; await Task.Yield(); }
        local.Value = "до";
        await Set();
        Console.WriteLine($"AsyncLocal после await Set(): {local.Value}");

        // Значение, записанное до await, видно после него.
        local.Value = "перед Yield";
        await Task.Yield();
        Console.WriteLine($"AsyncLocal после Yield: {local.Value}");

        // SynchronizationContext: продолжение должно вернуться в него.
        var ctx = new CountingContext();
        SynchronizationContext.SetSynchronizationContext(ctx);
        await Task.Yield();
        Console.WriteLine($"после Yield под SynchronizationContext: Post вызван {ctx.Posts} раз, контекст тот же = {ReferenceEquals(SynchronizationContext.Current, ctx)}");
        await Task.Delay(10).ConfigureAwait(false);
        Console.WriteLine($"после ConfigureAwait(false): контекст = {SynchronizationContext.Current?.GetType().Name ?? "null"}");
        SynchronizationContext.SetSynchronizationContext(null);
    }

    static async Task A(bool suspend = false) { await B(suspend); }
    static async Task B(bool suspend) { await C(suspend); }
    static async Task C(bool suspend)
    {
        if (suspend) await Task.Yield();
    }

    // Для режима stack: исключение бросается после приостановки.
    static async Task StackA() { await StackB(); }
    static async Task StackB() { await StackC(); }
    static async Task StackC() { await Task.Yield(); throw new InvalidOperationException("из StackC"); }
}

sealed class CountingContext : SynchronizationContext
{
    public int Posts;
    public override void Post(SendOrPostCallback d, object? state)
    {
        Interlocked.Increment(ref Posts);
        ThreadPool.QueueUserWorkItem(_ => { SetSynchronizationContext(this); d(state); });
    }
}