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

2. Машина состояний: что генерирует компилятор

О главе

Цель: уметь прочитать машину состояний, которую строит компилятор из любого async-метода, понимать, какие переменные становятся её полями и как устроены try/catch/finally, using и лямбды, и написать машину руками.

Лабораторная: start/ — настоящие async-методы для декомпиляции (Samples.cs, Constructs.cs) и заготовка ручной машины с TODO в MoveNext. final/ — рабочая ручная машина и режимы delay / completed / throw. Код — в конце главы.

Визуализация: интерактивная карта строк (§2.2): наводишь на строку — видишь её след в C#, коде без сахара, IL и asm.

Статус: ✅ проверено на Windows 11, SDK 10.0.401, рантайм 10.0.12. Все листинги сняты с этой лабораторной: .\tools\disasm.ps1 chapters\02-state-machine\start -Type Samples (и -Type Constructs), Release; Debug — отдельно в §2.5.

2.1. Анатомия: что искать в любом скомпилированном async-методе

Любой async-метод компилятор разбирает на две части.

  1. Заглушка (stub) — метод с тем же именем и сигнатурой, но уже без async, помеченный атрибутом [AsyncStateMachine(typeof(...))]. Он создаёт машину, запускает её и возвращает Task.
  2. Машина состояний — вложенный тип с непроизносимым именем <ИмяМетода>d__N ([CompilerGenerated], реализует IAsyncStateMachine). В Release это struct, в Debug — class (§2.5). Всё тело метода переезжает в её метод MoveNext().

Поля машины подчиняются системе имён компилятора:

Поле Что хранит Пример
<>1__state номер состояния (таблица ниже) int
<>t__builder builder: связь машины с возвращаемым Task AsyncTaskMethodBuilder<int>
имя параметра параметры метода копируются в поля машины как есть x, ids
<>u__N awaiter, сохранённый между вызовами MoveNext; по одному полю на тип awaiter'а TaskAwaiter <>u__1
<имя>5__N поднятая локальная переменная: её значение нужно после await (§2.4) <local>5__2
<>7__wrapN служебное временное значение, которое должно пережить await, в Release: массив и индекс foreach, «разлитый» операнд выражения, отложенное исключение finally <>7__wrap1
<>s__N то же в Debug (§2.5) <>s__2
<>4__this ссылка на this или на объект замыкания (у лямбд, §2.6) <>4__this

Значения <>1__state:

Значение Смысл
-1 метод выполняется (или ещё не начат)
0, 1, 2, … метод приостановлен на await с этим номером; при следующем MoveNext выполнение продолжится с места после него
-2 метод завершён: успешно или с исключением

2.2. Простейший пример: один await

Метод из start/Samples.cs:

start/Samples.cs
    public static async Task<int> GetValueAsync()
    {
        await Task.Delay(100);
        return 42;
    }

Вот во что он превратился на всех уровнях. Нумерация строк — внутри каждой вкладки.

[AsyncStateMachine(typeof(<GetValueAsync>d__0))]
public static Task<int> GetValueAsync()
{
    <GetValueAsync>d__0 stateMachine = default;
    stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

[StructLayout(LayoutKind.Auto)]
[CompilerGenerated]
private struct <GetValueAsync>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    private TaskAwaiter <>u__1;

    private void MoveNext()
    {
        int num = <>1__state;
        int result;
        try
        {
            TaskAwaiter awaiter;
            if (num != 0)
            {
                awaiter = Task.Delay(100).GetAwaiter();
                if (!awaiter.IsCompleted)
                {
                    num = (<>1__state = 0);
                    <>u__1 = awaiter;
                    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                    return;
                }
            }
            else
            {
                awaiter = <>u__1;
                <>u__1 = default;
                num = (<>1__state = -1);
            }
            awaiter.GetResult();
            result = 42;
        }
        catch (Exception exception)
        {
            <>1__state = -2;
            <>t__builder.SetException(exception);
            return;
        }
        <>1__state = -2;
        <>t__builder.SetResult(result);
    }

    void IAsyncStateMachine.MoveNext()
    {
        this.MoveNext();
    }

    [DebuggerHidden]
    private void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        <>t__builder.SetStateMachine(stateMachine);
    }

    void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
    {
        this.SetStateMachine(stateMachine);
    }
}

Обе части в исходной сборке лежат внутри класса Samples; обёртка класса и служебные комментарии ILSpy опущены.

.class public auto ansi abstract sealed beforefieldinit Samples extends System.Object
{
    // заглушка
    .method public hidebysig static class Task`1<int32> GetValueAsync () cil managed
    {
        .custom instance void AsyncStateMachineAttribute::.ctor(class System.Type) = ( ... )
        .maxstack 2
        .locals init ([0] valuetype Samples/'<GetValueAsync>d__0')

        IL_0000: ldloca.s 0
        IL_0002: call valuetype AsyncTaskMethodBuilder`1<!0> AsyncTaskMethodBuilder`1<int32>::Create()
        IL_0007: stfld valuetype AsyncTaskMethodBuilder`1<int32> Samples/'<GetValueAsync>d__0'::'<>t__builder'
        IL_000c: ldloca.s 0
        IL_000e: ldc.i4.m1                                                      // state = -1
        IL_000f: stfld int32 Samples/'<GetValueAsync>d__0'::'<>1__state'
        IL_0014: ldloca.s 0
        IL_0016: ldflda valuetype AsyncTaskMethodBuilder`1<int32> Samples/'<GetValueAsync>d__0'::'<>t__builder'
        IL_001b: ldloca.s 0
        IL_001d: call instance void AsyncTaskMethodBuilder`1<int32>::Start<valuetype Samples/'<GetValueAsync>d__0'>(!!0&)
        IL_0022: ldloca.s 0
        IL_0024: ldflda valuetype AsyncTaskMethodBuilder`1<int32> Samples/'<GetValueAsync>d__0'::'<>t__builder'
        IL_0029: call instance class Task`1<!0> AsyncTaskMethodBuilder`1<int32>::get_Task()
        IL_002e: ret
    }

    // машина состояний
    .class nested private auto ansi sealed beforefieldinit '<GetValueAsync>d__0'
        extends System.ValueType
        implements IAsyncStateMachine
    {
        .custom instance void CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )

        .field public int32 '<>1__state'
        .field public valuetype AsyncTaskMethodBuilder`1<int32> '<>t__builder'
        .field private valuetype TaskAwaiter '<>u__1'

        .method private final hidebysig newslot virtual instance void MoveNext () cil managed
        {
            .override method instance void IAsyncStateMachine::MoveNext()
            .maxstack 3
            .locals init ([0] int32, [1] int32, [2] valuetype TaskAwaiter, [3] class System.Exception)

            IL_0000: ldarg.0
            IL_0001: ldfld int32 Samples/'<GetValueAsync>d__0'::'<>1__state'
            IL_0006: stloc.0
            .try
            {
                IL_0007: ldloc.0
                IL_0008: brfalse.s IL_0040                                      // state == 0 → на возобновление

                IL_000a: ldc.i4.s 100                                           // Task.Delay(100)
                IL_000c: call class Task Task::Delay(int32)
                IL_0011: callvirt instance valuetype TaskAwaiter Task::GetAwaiter()
                IL_0016: stloc.2
                IL_0017: ldloca.s 2
                IL_0019: call instance bool TaskAwaiter::get_IsCompleted()      // уже готово?
                IL_001e: brtrue.s IL_005c

                IL_0020: ldarg.0                                                // state = 0
                IL_0021: ldc.i4.0
                IL_0022: dup
                IL_0023: stloc.0
                IL_0024: stfld int32 Samples/'<GetValueAsync>d__0'::'<>1__state'
                IL_0029: ldarg.0                                                // сохранить awaiter
                IL_002a: ldloc.2
                IL_002b: stfld valuetype TaskAwaiter Samples/'<GetValueAsync>d__0'::'<>u__1'
                IL_0030: ldarg.0                                                // подписаться
                IL_0031: ldflda valuetype AsyncTaskMethodBuilder`1<int32> Samples/'<GetValueAsync>d__0'::'<>t__builder'
                IL_0036: ldloca.s 2
                IL_0038: ldarg.0
                IL_0039: call instance void AsyncTaskMethodBuilder`1<int32>::AwaitUnsafeOnCompleted<valuetype TaskAwaiter, valuetype Samples/'<GetValueAsync>d__0'>(!!0&, !!1&)
                IL_003e: leave.s IL_0093                                        // уйти

                IL_0040: ldarg.0                                                // возобновление: восстановить awaiter
                IL_0041: ldfld valuetype TaskAwaiter Samples/'<GetValueAsync>d__0'::'<>u__1'
                IL_0046: stloc.2
                IL_0047: ldarg.0
                IL_0048: ldflda valuetype TaskAwaiter Samples/'<GetValueAsync>d__0'::'<>u__1'
                IL_004d: initobj TaskAwaiter
                IL_0053: ldarg.0
                IL_0054: ldc.i4.m1
                IL_0055: dup
                IL_0056: stloc.0
                IL_0057: stfld int32 Samples/'<GetValueAsync>d__0'::'<>1__state'

                IL_005c: ldloca.s 2
                IL_005e: call instance void TaskAwaiter::GetResult()           // конец await
                IL_0063: ldc.i4.s 42                                            // return 42
                IL_0065: stloc.1
                IL_0066: leave.s IL_007f
            }
            catch System.Exception
            {
                IL_0068: stloc.3
                IL_0069: ldarg.0
                IL_006a: ldc.i4.s -2
                IL_006c: stfld int32 Samples/'<GetValueAsync>d__0'::'<>1__state'
                IL_0071: ldarg.0
                IL_0072: ldflda valuetype AsyncTaskMethodBuilder`1<int32> Samples/'<GetValueAsync>d__0'::'<>t__builder'
                IL_0077: ldloc.3
                IL_0078: call instance void AsyncTaskMethodBuilder`1<int32>::SetException(class System.Exception)
                IL_007d: leave.s IL_0093
            }

            IL_007f: ldarg.0
            IL_0080: ldc.i4.s -2
            IL_0082: stfld int32 Samples/'<GetValueAsync>d__0'::'<>1__state'
            IL_0087: ldarg.0
            IL_0088: ldflda valuetype AsyncTaskMethodBuilder`1<int32> Samples/'<GetValueAsync>d__0'::'<>t__builder'
            IL_008d: ldloc.1
            IL_008e: call instance void AsyncTaskMethodBuilder`1<int32>::SetResult(!0)   // 42 → Task

            IL_0093: ret
        }

        .method private final hidebysig newslot virtual instance void SetStateMachine (class IAsyncStateMachine stateMachine) cil managed
        {
            .override method instance void IAsyncStateMachine::SetStateMachine(class IAsyncStateMachine)
            IL_0000: ldarg.0
            IL_0001: ldflda valuetype AsyncTaskMethodBuilder`1<int32> Samples/'<GetValueAsync>d__0'::'<>t__builder'
            IL_0006: ldarg.1
            IL_0007: call instance void AsyncTaskMethodBuilder`1<int32>::SetStateMachine(class IAsyncStateMachine)
            IL_000c: ret
        }
    }
}

Для читаемости убраны пространства имён ([System.Runtime]System.Runtime.CompilerServices. и т. п.), служебные комментарии ILSpy и атрибуты Nullable. Инструкции не тронуты. MoveNext объявлен private, а с интерфейсом его связывает директива .override.

; ===== заглушка: Samples:GetValueAsync() (FullOpts), x64, Windows =====
G_M000_IG01:
       sub      rsp, 56
       vxorps   xmm4, xmm4, xmm4
       vmovdqa  xmmword ptr [rsp+0x20], xmm4    ; машина (24 байта) на стеке = default + Create()
       xor      eax, eax
       mov      qword ptr [rsp+0x30], rax
G_M000_IG02:
       mov      dword ptr [rsp+0x20], -1        ; state = -1
       lea      rcx, [rsp+0x20]                 ; ref stateMachine — адрес на стеке
       call     [AsyncMethodBuilderCore:Start[Samples+<GetValueAsync>d__0](byref)]
       mov      rax, gword ptr [rsp+0x28]       ; builder.Task: m_task уже есть?
       test     rax, rax
       je       SHORT G_M000_IG04
G_M000_IG03:
       add      rsp, 56
       ret
G_M000_IG04:
       lea      rcx, [rsp+0x28]
       call     [AsyncTaskMethodBuilder`1[int]:InitializeTaskAsPromise()]   ; нет — создать лениво
       jmp      SHORT G_M000_IG03

; ===== машина: Samples+<GetValueAsync>d__0:MoveNext() (FullOpts) =====
; поля машины: [rcx] = <>1__state, [rcx+0x08] = <>t__builder.m_task, [rcx+0x10] = <>u__1
G_M000_IG01:
       push     rbp
       push     rsi
       push     rbx
       sub      rsp, 48
       lea      rbp, [rsp+0x40]
       xor      eax, eax
       mov      qword ptr [rbp-0x18], rax
       mov      bword ptr [rbp+0x10], rcx
G_M000_IG02:
       mov      eax, dword ptr [rcx]            ; num = <>1__state
G_M000_IG03:
       test     eax, eax
       jne      SHORT G_M000_IG05               ; state != 0 → первый вызов
       mov      rax, gword ptr [rcx+0x10]       ; возобновление: awaiter = <>u__1
       mov      gword ptr [rbp-0x18], rax
       xor      eax, eax
       mov      qword ptr [rcx+0x10], rax       ; <>u__1 = default
       mov      dword ptr [rcx], -1             ; state = -1
G_M000_IG04:
       mov      rdx, gword ptr [rbp-0x18]       ; GetResult() встроен: смотрим флаги Task
       mov      eax, dword ptr [rdx+0x34]       ;   Task.m_stateFlags
       and      eax, 0x11000000
       cmp      eax, 0x1000000                  ;   RanToCompletion?
       je       G_M000_IG07
       jmp      G_M000_IG06
G_M000_IG05:
       mov      rcx, 0x7FF8955453A8             ; Task.Delay(100) встроен:
       call     CORINFO_HELP_GET_NONGCSTATIC_BASE
       mov      ecx, 0x80000D10
       mov      rbx, gword ptr [rcx]            ;   TimeProvider.System
       mov      rcx, 0x7FF895545570
       call     CORINFO_HELP_NEWSFAST           ;   new DelayPromise
       mov      rsi, rax
       mov      rcx, rsi
       mov      r8, rbx
       mov      edx, 100
       call     [System.Threading.Tasks.Task+DelayPromise:.ctor(uint,System.TimeProvider):this]
       mov      gword ptr [rbp-0x18], rsi       ; GetAwaiter() встроен: awaiter = задача
       mov      rcx, gword ptr [rbp-0x18]
       test     dword ptr [rcx+0x34], 0x1600000 ; IsCompleted встроен: флаги «завершена»
       jne      SHORT G_M000_IG04               ;   уже готово → сразу к GetResult
       xor      edx, edx
       mov      rcx, bword ptr [rbp+0x10]
       mov      dword ptr [rcx], edx            ; state = 0
       lea      rcx, bword ptr [rcx+0x10]
       mov      rdx, gword ptr [rbp-0x18]
       call     CORINFO_HELP_CHECKED_ASSIGN_REF ; <>u__1 = awaiter
       mov      rcx, bword ptr [rbp+0x10]
       lea      rdx, bword ptr [rcx+0x08]
       call     [System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1[int]:GetStateMachineBox[Samples+<GetValueAsync>d__0](byref,byref):System.Runtime.CompilerServices.IAsyncStateMachineBox]
       mov      rdx, rax                        ; упаковать машину в кучу (бокс = Task)
       lea      rcx, [rbp-0x18]
       call     [System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1[int]:AwaitUnsafeOnCompleted[System.Runtime.CompilerServices.TaskAwaiter](byref,System.Runtime.CompilerServices.IAsyncStateMachineBox)]
       jmp      SHORT G_M000_IG09               ; подписались — уйти
G_M000_IG06:
       mov      rcx, rdx                        ; задача не RanToCompletion: бросить исключение/отмену
       xor      edx, edx
       call     [System.Runtime.CompilerServices.TaskAwaiter:HandleNonSuccessAndDebuggerNotification(System.Threading.Tasks.Task,int)]
       nop
G_M000_IG07:
       mov      rcx, bword ptr [rbp+0x10]
       mov      dword ptr [rcx], -2             ; state = -2
       lea      rbx, bword ptr [rcx+0x08]
       cmp      gword ptr [rbx], 0              ; SetResult встроен: есть ли уже Task (бокс)?
       jne      SHORT G_M000_IG11
G_M000_IG08:
       mov      rcx, 0x7FF8954F4160             ; нет (метод не приостанавливался):
       call     CORINFO_HELP_NEWSFAST           ;   new Task<int>
       mov      dword ptr [rax+0x34], 0x1000000 ;   m_stateFlags = RanToCompletion
       mov      dword ptr [rax+0x38], 42        ; m_result = 42
       mov      rcx, rbx
       mov      rdx, rax
       call     CORINFO_HELP_CHECKED_ASSIGN_REF
G_M000_IG09:
       nop
G_M000_IG10:
       add      rsp, 48
       pop      rbx
       pop      rsi
       pop      rbp
       ret
G_M000_IG11:
       mov      rbx, gword ptr [rbx]            ; да: завершить существующий Task (TrySetResult)
       test     byte  ptr [(reloc 0x7ff895548618)], 1
       jne      SHORT G_M000_IG13
G_M000_IG12:
       mov      rcx, 0x7FF895548558
       call     CORINFO_HELP_GET_GCSTATIC_BASE
G_M000_IG13:
       mov      ecx, 0x80000D58
       mov      rcx, gword ptr [rcx]
       cmp      byte  ptr [rcx+0x9D], 0         ;   включена ли трассировка TPL?
       je       SHORT G_M000_IG15
G_M000_IG14:
       mov      rcx, 0x7FF895548558
       call     CORINFO_HELP_GET_GCSTATIC_BASE
       mov      ecx, 0x80000D58
       mov      rsi, gword ptr [rcx]
       mov      rcx, rbx
       cmp      dword ptr [rcx], ecx
       call     [System.Threading.Tasks.Task:get_Id():int:this]
       mov      edx, eax
       mov      rcx, rsi
       mov      r8d, 1
       cmp      dword ptr [rcx], ecx
       call     [System.Threading.Tasks.TplEventSource:TraceOperationEnd(int,int):this]
G_M000_IG15:
       mov      eax, dword ptr [rbx+0x34]       ;   m_stateFlags
       mov      dword ptr [rbp-0x1C], eax
       test     eax, 0x5600000                  ;   уже завершена? тогда ошибка
       jne      SHORT G_M000_IG19
G_M000_IG16:
       lea      rsi, bword ptr [rbx+0x34]
       mov      ecx, eax
       or       ecx, 0x4000000                  ;   флаг «завершается»
       lock
       cmpxchg  dword ptr [rsi], ecx            ;   атомарно: только один может завершить Task
       cmp      eax, dword ptr [rbp-0x1C]
       je       SHORT G_M000_IG18
G_M000_IG17:
       mov      rcx, rbx
       mov      edx, 0x4000000
       mov      r8d, 0x5600000
       call     [System.Threading.Tasks.Task:AtomicStateUpdateSlow(int,int):bool:this]
       test     eax, eax
       je       SHORT G_M000_IG19
G_M000_IG18:
       mov      dword ptr [rbx+0x38], 42        ; m_result = 42
       mov      ecx, dword ptr [rbx+0x34]
       or       ecx, 0x1000000                  ; флаг RanToCompletion
       xchg     dword ptr [rsi], ecx
       mov      rsi, gword ptr [rbx+0x28]
       test     rsi, rsi
       je       SHORT G_M000_IG21
       jmp      SHORT G_M000_IG20
G_M000_IG19:
       mov      ecx, 24
       call     [System.ThrowHelper:ThrowInvalidOperationException(int)]
       int3
G_M000_IG20:
       mov      rcx, rbx
       call     [System.Threading.Tasks.Task:NotifyParentIfPotentiallyAttachedTask():this]
       mov      rcx, rsi
       call     [System.Threading.Tasks.Task+ContingentProperties:SetCompleted():this]
G_M000_IG21:
       mov      rcx, rbx
       call     [System.Threading.Tasks.Task:FinishContinuations():this]   ; разбудить тех, кто ждёт этот Task
       jmp      G_M000_IG09
; --- обработчик catch: SetException ---
G_M000_IG22:
       sub      rsp, 40
G_M000_IG23:
       mov      rdx, rcx
       mov      rcx, bword ptr [rbp+0x10]
       mov      dword ptr [rcx], -2
       add      rcx, 8
       call     [System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1[int]:SetException(System.Exception):this]
       lea      rax, G_M000_IG09
G_M000_IG24:
       add      rsp, 40
       ret

Листинги взяты как есть, добавлены только комментарии ;. Что видно:

  • Почти всё встроено (inline): Task.Delay превратился в new DelayPromise(100, TimeProvider.System), а GetAwaiter, IsCompleted, GetResult и SetResult — в чтение и запись m_stateFlags по смещению 0x34 внутри Task.
  • 42 встречается дважды (IG08 и IG18), потому что SetResult встроен с обеими ветками. Если метод не приостанавливался, Task ещё нет: создаётся готовый Task<int> сразу с результатом. Если приостанавливался, Task — это бокс машины: в него атомарно записывается результат, и будятся продолжения.
  • Упаковка машины в кучу — вызов GetStateMachineBox прямо перед подпиской (IG05). Это и есть та единственная аллокация «реальной приостановки», о которой пойдёт речь в главе 3.

Соответствие строк исходника (Samples.cs) и кода без сахара:

Исходник Без сахара Что там
строка 8, сигнатура строки 1–9 заглушка: машина на стеке, состояние -1, Start, вернуть Task
строки 13–17 структура машины и её поля: состояние, builder, awaiter
строки 21, 26 вход в MoveNext: прочитать состояние и выбрать ветку
строка 10, await Task.Delay(100) строка 28 получить awaiter
строка 29 уже завершено? Тогда без остановки
строки 31–34 приостановка: запомнить состояние и awaiter, подписаться, выйти
строки 37–42 возобновление: забрать awaiter, вернуть состояние -1
строка 43 GetResult(): конец await; здесь бросится исключение задачи
строка 11, return 42 строки 44, 52–53 результат в локальную, состояние -2, SetResult
(неявно) строки 46–51 любое исключение тела уходит в Task через SetException
строки 56–70 реализация интерфейса IAsyncStateMachine

Интерактивная карта строк

То же соответствие сразу на всех четырёх уровнях. Наведите на строку в любой панели, щелчок закрепляет выбор. Открыть на весь экран.

2.3. Как это выполняется: два вызова MoveNext

Пройдём по коду без сахара (вкладка выше) в том порядке, в каком он выполняется на самом деле.

Вызов GetValueAsync() — обычный синхронный вызов заглушки.

  1. Строка 4: машина — локальная переменная-структура, она на стеке.
  2. Строки 5–6: builder создан, состояние -1 («ещё не начинали»).
  3. Строка 7: Start синхронно, на этом же потоке, вызывает MoveNext.

Первый вызов MoveNext (состояние -1):

  1. Строка 21: num = -1. Строка 26: num != 0, значит, это первый вход.
  2. Строка 28: Task.Delay(100) создаёт задачу, берём её awaiter.
  3. Строка 29: задача ещё не завершена, идём в приостановку.
  4. Строки 31–32: записать в поля номер состояния 0 и awaiter. Это делается до следующей строки, потому что там машина будет скопирована в кучу, и копия должна уже всё содержать.
  5. Строка 33: AwaitUnsafeOnCompleted упаковывает машину в объект в куче (бокс машины, см. ниже) и подписывает его на завершение задачи Delay.
  6. Строка 34: return. MoveNext выходит, Start возвращается в заглушку.

Снова в заглушке:

  1. Строка 8: вернуть builder.Task. Машина приостанавливалась, поэтому это бокс: незавершённый Task<int>. Вызывающий получил задачу, а стек метода исчез: всё его состояние лежит в боксе.

Через 100 мс — второй вызов MoveNext (состояние 0). Его вызывает бокс на потоке, который завершил задачу Delay:

  1. Строка 21: num = 0. Строка 26: ложь, идём в else.
  2. Строки 39–41: достать awaiter из поля, очистить поле, вернуть состояние -1.
  3. Строка 43: GetResult(). Если бы Delay завершился с ошибкой или отменой, исключение было бы брошено здесь и поймано в строке 46.
  4. Строка 44: result = 42.
  5. Строки 52–53: состояние -2, SetResult(42). Задача, которую держит вызывающий, переходит в RanToCompletion, и запускаются её продолжения.

Главный вывод: await — это не «ожидание на месте», а разрез метода. Каждый кусок между await — ветка MoveNext, номер состояния говорит, с какого куска продолжать. Между кусками метод не занимает ни потока, ни стека.

Что такое «бокс машины»

Пока метод не приостанавливался, машина-структура лежит на стеке заглушки (строка 4). При первой реальной приостановке (строка 33) builder создаёт в куче объект класса AsyncTaskMethodBuilder<TResult>.AsyncStateMachineBox<TStateMachine> и копирует в его поле StateMachine вашу структуру. Этот объект — наследник Task<TResult>, и именно он становится задачей, которую получает вызывающий (строка 8). Его называют боксом по аналогии с упаковкой значимого типа в объект. Бокс создаётся не больше одного раза за вызов: второй и следующие await используют тот же объект. Если метод ни разу не приостановился, бокса нет вовсе. Исходники класса бокса и опыт, в котором бокс видно вживую, — в главе 1, §1.4, а устройство builder'а подробно — в главе 3.

2.4. Какие переменные становятся полями машины

Локальная переменная живёт на стеке MoveNext. Но после приостановки MoveNext выходит, и стек пропадает. Поэтому каждое значение, которое нужно после await, компилятор переносит в поле машины (говорят «поднимает», hoisting). Всё остальное остаётся обычными локальными переменными MoveNext.

Почему в примерах await Task.Yield()

Task.Yield() — самый короткий await, который всегда приостанавливает метод: его awaiter возвращает IsCompleted == false, а продолжение ставится в очередь (пула потоков или контекста). Для опыта с полями это удобно: не нужны ни таймер, ни другая задача. Разбор устройства Yield и отличие от yield return — в главе 1, §1.3. Здесь важно другое: какие переменные станут полями, решает компилятор по тексту программы, а не по тому, приостановится ли метод на самом деле. С await Task.CompletedTask (никогда не приостанавливает) поля были бы теми же.

Простой случай

start/Constructs.cs
    public static async Task<int> HoistAsync(int x)
    {
        int before = x + 1;          // живёт через await → поле машины
        int temp = x * 100;          // используется только до await → обычная локальная MoveNext
        Console.WriteLine(temp);
        await Task.Yield();
        return before;
    }
HoistAsync: поля и начало MoveNext (Release)
private struct <HoistAsync>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    public int x;
    private int <before>5__2;
    private YieldAwaitable.YieldAwaiter <>u__1;

    private void MoveNext()
    {
        int num = <>1__state;
        int result;
        try
        {
            YieldAwaitable.YieldAwaiter awaiter;
            if (num != 0)
            {
                <before>5__2 = x + 1;
                Console.WriteLine(x * 100);
                awaiter = Task.Yield().GetAwaiter();
                ...
            }
            ...
            awaiter.GetResult();
            result = <before>5__2;
        }
        ...
  • Строка 5: параметр x (строка 6 исходника) стал публичным полем с тем же именем: его записывает заглушка.
  • Строка 6: before (строка 8 исходника) нужна после await (строка 12 исходника), поэтому стала полем <before>5__2. Записывается в строке 18, читается после возобновления в строке 25.
  • Строка 19: temp (строка 9 исходника) нужна только до await. Поля для неё нет, и компилятор вообще сократил переменную: Console.WriteLine(x * 100).
  • Строка 7: awaiter Task.Yield() — другого типа, чем у Task: YieldAwaitable.YieldAwaiter.

Точно так же CalcAsync из Samples.cs (строка 28: int local = x * 2;, используется после await) получает поля public int x; и private int <local>5__2;.

Неочевидный случай: цикл и составное присваивание

start/Samples.cs
    public static async Task<int> SumAsync(int[] ids)
    {
        int sum = 0;
        foreach (var id in ids)
            sum += await LoadAsync(id);
        return sum;
    }

Кажется, что sum (строка 17) обязана стать полем: она нужна и до, и после каждого await в строке 19. Посмотрим, что получилось в Release и в Debug:

private struct <SumAsync>d__1 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    public int[] ids;
    private int[] <>7__wrap1;
    private int <>7__wrap2;
    private int <>7__wrap3;
    private TaskAwaiter<int> <>u__1;

    private void MoveNext()
    {
        int num = <>1__state;
        int result2;
        try
        {
            int num2;
            if (num != 0)
            {
                num2 = 0;
                <>7__wrap1 = ids;
                <>7__wrap2 = 0;
                goto IL_00ad;
            }
            TaskAwaiter<int> awaiter = <>u__1;
            <>u__1 = default;
            num = (<>1__state = -1);
            goto IL_008e;
            IL_008e:
            int result = awaiter.GetResult();
            num2 = <>7__wrap3 + result;
            <>7__wrap2++;
            goto IL_00ad;
            IL_00ad:
            if (<>7__wrap2 < <>7__wrap1.Length)
            {
                int id = <>7__wrap1[<>7__wrap2];
                <>7__wrap3 = num2;
                awaiter = LoadAsync(id).GetAwaiter();
                if (!awaiter.IsCompleted)
                {
                    num = (<>1__state = 0);
                    <>u__1 = awaiter;
                    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                    return;
                }
                goto IL_008e;
            }
            <>7__wrap1 = null;
            result2 = num2;
        }
        catch (Exception exception)
        {
            <>1__state = -2;
            <>t__builder.SetException(exception);
            return;
        }
        <>1__state = -2;
        <>t__builder.SetResult(result2);
    }
}
private sealed class <SumAsync>d__1 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    public int[] ids;
    private int <sum>5__1;
    private int[] <>s__2;
    private int <>s__3;
    private int <id>5__4;
    private int <>s__5;
    private int <>s__6;
    private TaskAwaiter<int> <>u__1;

    private void MoveNext()
    {
        int num = <>1__state;
        int result;
        try
        {
            if (num != 0)
            {
                <sum>5__1 = 0;
                <>s__2 = ids;
                <>s__3 = 0;
                goto IL_00dc;
            }
            TaskAwaiter<int> awaiter = <>u__1;
            <>u__1 = default;
            num = (<>1__state = -1);
            goto IL_00ae;
            IL_00ae:
            <>s__6 = awaiter.GetResult();
            <sum>5__1 = <>s__5 + <>s__6;
            <>s__3++;
            goto IL_00dc;
            IL_00dc:
            if (<>s__3 < <>s__2.Length)
            {
                <id>5__4 = <>s__2[<>s__3];
                <>s__5 = <sum>5__1;
                awaiter = LoadAsync(<id>5__4).GetAwaiter();
                if (!awaiter.IsCompleted)
                {
                    num = (<>1__state = 0);
                    <>u__1 = awaiter;
                    <SumAsync>d__1 stateMachine = this;
                    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
                    return;
                }
                goto IL_00ae;
            }
            <>s__2 = null;
            result = <sum>5__1;
        }
        catch (Exception exception)
        {
            <>1__state = -2;
            <>t__builder.SetException(exception);
            return;
        }
        <>1__state = -2;
        <>t__builder.SetResult(result);
    }
}

Факт: в Release sum вообще не стала полем

На вкладке «Release» sum — это обычная локальная num2 (строка 17). Через await переживает не сама sum, а «разлитое» (spilled) значение левой части sum += await …. C# вычисляет левый операнд до await, сохраняет его в <>7__wrap3 (строка 38), а после возобновления заново считает sum как <>7__wrap3 + result (строка 31). Компилятор поднимает значения, которые должны пережить await, а не переменные «как в исходнике».

Остальные поля Release: <>7__wrap1 и <>7__wrap2 — массив и индекс foreach (строки 21–22, 32, 35, 37): цикл продолжается после каждого await. id полем не стала (строка 37): после await она не нужна.

В первой версии курса я ожидал поля <sum>5__2 и <id>5__5. Так и выглядит Debug (вкладка «Debug», строки 6 и 9), а не Release.

Как читать цикл в Release:

  • Строки 18–24: первый вход. sum = 0, запомнить массив и индекс, перейти к проверке условия цикла.
  • Строки 34–48: проверка условия и тело цикла. id берётся из массива, sum «разливается» в поле, вызывается LoadAsync. Если задача не готова — приостановка (строки 40–45).
  • Строки 29–33: продолжение после await. Сюда приходят и после возобновления (строки 25–28), и синхронно, если LoadAsync вернул уже завершённую задачу (строка 47).

В нашей лабораторной LoadAsync возвращает Task.FromResult, то есть уже завершённую задачу. Строка 40 всегда ложна, и весь цикл проходит за один вызов MoveNext, без приостановок и без бокса.

Практический вывод: чем больше значений живёт через await, тем больше машина, а значит, и бокс в куче. Объявляйте переменные ближе к месту использования и не тяните ненужное через await.

2.5. Debug против Release

Debug-сборка той же лабораторной (dotnet build -c Debug) даёт другую машину. Сравните вкладки в §2.4 и заглушку метода GetValueAsync. Вкладки «Release» и «Debug» здесь те же, что в §2.4: переключая одну, вы переключаете все:

GetValueAsync, Release: заглушка
1
2
3
4
5
6
7
8
9
[AsyncStateMachine(typeof(<GetValueAsync>d__0))]
public static Task<int> GetValueAsync()
{
    <GetValueAsync>d__0 stateMachine = default;
    stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}
GetValueAsync, Debug: заглушка
[AsyncStateMachine(typeof(<GetValueAsync>d__0))]
[DebuggerStepThrough]
public static Task<int> GetValueAsync()
{
    <GetValueAsync>d__0 stateMachine = new <GetValueAsync>d__0();
    stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

Отличаются две вещи: атрибут [DebuggerStepThrough] (строка 2 во вкладке Debug) и способ создания машины (new в строке 5 Debug против default в строке 4 Release). Остальные строки заглушки совпадают.

Release Debug
Тип машины struct sealed class
Создание в заглушке default — на стеке (строка 4) new — объект в куче сразу (строка 5), даже если метод не приостановится
Поднятые переменные только значения, нужные после await; служебные — <>7__wrapN все локальные (<sum>5__1, <id>5__4), служебные — <>s__N
SetStateMachine передаёт вызов builder'у пустой: классу упаковка не нужна
Атрибут заглушки — [DebuggerStepThrough] (строка 2 во вкладке Debug): отладчик не заходит в заглушку

Зачем так в Debug: класс не нужно копировать при приостановке, а все локальные в полях видны в окне Locals отладчика на любом шаге. Выводы о памяти и скорости делайте только по Release.

2.6. Сложные конструкции

await в try, catch и finally

await внутри catch и finally разрешён с C# 6. Чтобы понять, почему его не было раньше, посмотрите, во что это превращается:

start/Constructs.cs
    public static async Task<string> TryCatchFinallyAsync()
    {
        try
        {
            await Task.Yield();
            throw new InvalidOperationException("boom");
        }
        catch (InvalidOperationException e)
        {
            await Task.Yield();          // await в catch — с C# 6
            return e.Message;
        }
        finally
        {
            await Task.Yield();          // await в finally — с C# 6
        }
    }
TryCatchFinallyAsync: MoveNext (Release)
private struct <TryCatchFinallyAsync>d__1 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<string> <>t__builder;
    private object <>7__wrap1;
    private int <>7__wrap2;
    private string <>7__wrap3;
    private YieldAwaitable.YieldAwaiter <>u__1;
    private InvalidOperationException <e>5__5;

    private void MoveNext()
    {
        int num = <>1__state;
        string result;
        try
        {
            YieldAwaitable.YieldAwaiter awaiter;
            if ((uint)num > 1u)
            {
                if (num == 2)
                {
                    awaiter = <>u__1;
                    <>u__1 = default;
                    num = (<>1__state = -1);
                    goto IL_0198;
                }
                <>7__wrap1 = null;
                <>7__wrap2 = 0;
            }
            object obj;
            int num2;
            try
            {
                if (num != 0)
                {
                    if (num == 1)
                    {
                        awaiter = <>u__1;
                        <>u__1 = default;
                        num = (<>1__state = -1);
                        goto IL_010f;
                    }
                    num2 = 0;
                }
                try
                {
                    if (num != 0)
                    {
                        awaiter = Task.Yield().GetAwaiter();
                        if (!awaiter.IsCompleted)
                        {
                            num = (<>1__state = 0);
                            <>u__1 = awaiter;
                            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                            return;
                        }
                    }
                    else
                    {
                        awaiter = <>u__1;
                        <>u__1 = default;
                        num = (<>1__state = -1);
                    }
                    awaiter.GetResult();
                    throw new InvalidOperationException("boom");
                }
                catch (InvalidOperationException ex)
                {
                    obj = ex;
                    num2 = 1;
                }
                if (num2 == 1)
                {
                    <e>5__5 = (InvalidOperationException)obj;
                    awaiter = Task.Yield().GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        num = (<>1__state = 1);
                        <>u__1 = awaiter;
                        <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                        return;
                    }
                    goto IL_010f;
                }
                goto end_IL_0021;
                IL_010f:
                awaiter.GetResult();
                <>7__wrap3 = <e>5__5.Message;
                <>7__wrap2 = 1;
                end_IL_0021:;
            }
            catch (object obj2)
            {
                <>7__wrap1 = obj2;
            }
            awaiter = Task.Yield().GetAwaiter();
            if (!awaiter.IsCompleted)
            {
                num = (<>1__state = 2);
                <>u__1 = awaiter;
                <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                return;
            }
            goto IL_0198;
            IL_0198:
            awaiter.GetResult();
            obj = <>7__wrap1;
            if (obj != null)
            {
                ExceptionDispatchInfo.Capture((obj as Exception) ?? throw obj).Throw();
            }
            num2 = <>7__wrap2;
            if (num2 != 1)
            {
                <>7__wrap1 = null;
                <>7__wrap3 = null;
                throw null;
            }
            result = <>7__wrap3;
        }
        catch (Exception exception)
        {
            <>1__state = -2;
            <>t__builder.SetException(exception);
            return;
        }
        <>1__state = -2;
        <>t__builder.SetResult(result);
    }
}

Три await дали три состояния: 0 — в try (строка 20 исходника), 1 — в catch (строка 25), 2 — в finally (строка 30). Устройство:

  • Строки 67–71: настоящий catch сделан пустым. Он только запоминает исключение в локальную и ставит флаг num2 = 1 (строки 69–70). Пока выполнение внутри настоящего catch, из MoveNext уйти нельзя: IL запрещает покидать обработчик исключения через return с последующим возвратом внутрь. Поэтому тело вашего catch (await и return e.Message) вынесено за его пределы, в строки 72–90. Само исключение, нужное после await, поднято в поле <e>5__5 (строки 9, 74), а e.Message из строки 26 исходника — в <>7__wrap3 (строка 88).
  • Строки 32–95: finally тоже переписан. Исключение, вылетевшее из try/catch, ловится универсальным catch (object) (строки 92–95) и откладывается в поле <>7__wrap1. Затем выполняется тело finally со своим await (строки 96–104), а после него отложенное исключение перебрасывается (строки 107–111).
  • Строка 110: ExceptionDispatchInfo.Capture(…).Throw() перебрасывает исключение с исходным стеком вызовов. Простой throw obj затёр бы стек. Это тот же механизм, которым await пробрасывает исключение задачи (глава 8).
  • Строка 6: <>7__wrap2 — признак «return уже был» (1 ставится в строке 89). Флаг нужен, потому что return e.Message выполняется до finally, а значение возвращается после него.
  • Строки 113–118: путь, где метод дошёл до конца без return. Здесь он недостижим (try всегда бросает), но компилятор обязан его сгенерировать.

await using

start/Constructs.cs
    public static async Task UsingAsync()
    {
        await using var resource = new Resource();
        await Task.Yield();
    }

await using var resource = … — это try { … } finally { await resource.DisposeAsync(); }, а значит, разворачивается тем же способом, что и finally выше. Фрагмент MoveNext:

UsingAsync: MoveNext, фрагмент (Release)
            try
            {
                ...                                         // await Task.Yield() — состояние 0
                awaiter2.GetResult();
                <>7__wrap3 = 1;
            }
            catch (object obj)
            {
                <>7__wrap2 = obj;                           // отложить исключение
            }
            if (<resource>5__2 != null)
            {
                awaiter = <resource>5__2.DisposeAsync().GetAwaiter();
                if (!awaiter.IsCompleted)
                {
                    num = (<>1__state = 1);
                    <>u__2 = awaiter;
                    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                    return;
                }
                goto IL_0106;
            }
            ...
            object obj2 = <>7__wrap2;
            if (obj2 != null)
            {
                ExceptionDispatchInfo.Capture((obj2 as Exception) ?? throw obj2).Throw();
            }
  • Строки 7–10: исключение тела откладывается, как в finally выше.
  • Строки 11–22: DisposeAsync() вызывается и ожидается (состояние 1). Его awaiter — ValueTaskAwaiter, поэтому у машины второе поле awaiter'а <>u__2: по одному полю на каждый тип awaiter'а.
  • Строки 24–28: отложенное исключение перебрасывается с исходным стеком.
  • Сам ресурс поднят в поле <resource>5__2: он нужен после await.

lock и Span<T>: что запрещено и почему

Код Результат Почему
lock (o) { await …; } ошибка CS1996 Monitor принадлежит потоку, а после await продолжение может выполняться на другом потоке и не сможет освободить блокировку. Тип System.Threading.Lock (.NET 9) — тоже CS1996. Замена — SemaphoreSlim (глава 10).
Span<int> до await компилируется (с C# 13) Span — ref struct, ему нельзя в кучу. Пока он не живёт через await, ему не нужно поле машины.
Span<int>, используемый после await ошибка CS4007 Значение пришлось бы поднять в поле машины, то есть в бокс в куче. Для ref struct это невозможно.

Разрешённый вариант из лабораторной:

start/Constructs.cs
    public static async Task<int> SpanAsync(int[] data)
    {
        int sum = 0;
        {
            Span<int> span = data;       // ref struct: только до await
            foreach (int v in span)
                sum += v;
        }
        await Task.Yield();
        // Span<int> tooLate = data; await Task.Yield(); tooLate[0]++;   // раскомментируйте: ошибка компиляции
        return sum;
    }

span (строка 46) живёт в блоке, который заканчивается до await (строка 50). Раскомментируйте строку 51 — получите CS4007. В декомпиляции полем стала только sum (<sum>5__2): она нужна после await в строке 52.

Async-лямбда

start/Constructs.cs
    public static Func<Task<int>> MakeLambda(int seed) => async () =>
    {
        await Task.Yield();
        return seed * 2;
    };

У лямбды своя машина состояний. Лямбда захватывает параметр seed, поэтому компилятор сначала строит класс замыкания, а машину кладёт внутрь него:

MakeLambda: замыкание и машина лямбды (Release, сокращено)
private sealed class <>c__DisplayClass4_0
{
    private struct <<MakeLambda>b__0>d : IAsyncStateMachine
    {
        public int <>1__state;
        public AsyncTaskMethodBuilder<int> <>t__builder;
        public <>c__DisplayClass4_0 <>4__this;
        private YieldAwaitable.YieldAwaiter <>u__1;
        ...                                          // MoveNext: result = <>4__this.seed * 2
    }

    public int seed;

    [AsyncStateMachine(typeof(<<MakeLambda>b__0>d))]
    internal Task<int> <MakeLambda>b__0()
    {
        <<MakeLambda>b__0>d stateMachine = default;
        stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
        stateMachine.<>4__this = this;
        stateMachine.<>1__state = -1;
        stateMachine.<>t__builder.Start(ref stateMachine);
        return stateMachine.<>t__builder.Task;
    }
}
  • Строки 1 и 12: класс замыкания <>c__DisplayClass4_0 с захваченной переменной seed.
  • Строки 14–23: тело лямбды — метод <MakeLambda>b__0 этого класса, и это такая же заглушка, как у обычного async-метода.
  • Строка 7: машина хранит ссылку на замыкание в поле <>4__this и через неё читает seed.

2.7. Пишем машину состояний руками

Лучший способ понять конечный автомат — написать его. Публичные AsyncTaskMethodBuilder<T>, IAsyncStateMachine и TaskAwaiter доступны и вам: компилятор использует именно их.

«Компилируем» руками CalcAsync:

start/Samples.cs
    public static async Task<int> CalcAsync(int x)
    {
        int local = x * 2;
        await Task.Delay(100);
        return local + 1;
    }

Компилятор сделал из него машину с полями x, <local>5__2, <>u__1 и MoveNext того же устройства, что у GetValueAsync в §2.2: строка 28 исходника стала <local>5__2 = x * 2; в первой ветке, строка 30 — result = <local>5__2 + 1; после GetResult().

Задание

В start/ManualMachine.cs заготовлены заглушка CalcManualAsync и структура CalcMachine с полями. Метод MoveNext пока сразу завершает задачу исключением. Реализуйте его по образцу кода без сахара из §2.2 (подсказка — в комментарии TODO в самом файле). Запуск из папки chapters: dotnet run -c Release --project 02-state-machine\start. Пока MoveNext не написан, последняя строка вывода — CalcManual(20) : Реализуйте CalcMachine.MoveNext.

Готовое решение — final/ManualMachine.cs:

final/ManualMachine.cs
    public static Task<int> CalcManualAsync(int x)
    {
        var machine = new CalcMachine
        {
            X = x,
            State = -1,
            Builder = AsyncTaskMethodBuilder<int>.Create(),
        };
        machine.Builder.Start(ref machine);       // первый MoveNext() идёт синхронно, здесь же
        return machine.Builder.Task;
    }
}

struct CalcMachine : IAsyncStateMachine
{
    public int State;
    public AsyncTaskMethodBuilder<int> Builder;
    public int X;                       // параметр
    private int _local;                 // поднятая локальная
    private TaskAwaiter _awaiter;       // awaiter между приостановками

    public void MoveNext()
    {
        int result;
        try
        {
            TaskAwaiter awaiter;
            if (State == 0)
            {
                // возобновление после await
                awaiter = _awaiter;
                _awaiter = default;
                State = -1;
            }
            else
            {
                _local = X * 2;                                           // код до await
                Task awaited = Manual.Mode == "completed" ? Task.CompletedTask : Task.Delay(100);
                awaiter = awaited.GetAwaiter();
                if (!awaiter.IsCompleted)
                {
                    State = 0;
                    _awaiter = awaiter;
                    Builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);  // здесь структура упаковывается в кучу
                    return;
                }
            }
            if (Manual.Mode == "throw")
                throw new InvalidOperationException("ошибка внутри MoveNext");
            awaiter.GetResult();
            result = _local + 1;                                          // return local + 1
        }
        catch (Exception e)
        {
            State = -2;
            Builder.SetException(e);
            return;
        }
        State = -2;
        Builder.SetResult(result);
    }

    public void SetStateMachine(IAsyncStateMachine machine) => Builder.SetStateMachine(machine);
}

Как оно соответствует коду без сахара из §2.2:

ManualMachine.cs Без сахара (§2.2) Что
строки 9–19 строки 1–9 заглушка: машина, builder, -1, Start, Task
строки 24–28 строки 15–17 поля: состояние, builder, параметр, поднятая локальная, awaiter
строки 36–42 строки 37–42 возобновление
строки 45–54 строки 28–35 код до await и приостановка
строки 58–59 строки 43–44 GetResult и результат
строки 61–68 строки 46–53 исключение и SetResult
строка 71 строки 61–65 SetStateMachine передаёт вызов builder'у

Порядок веток другой (у компилятора «первый вход» идёт первым, у нас — «возобновление»), но логика та же. Строки 46 и 56–57 — режимы эксперимента: аргумент командной строки выбирает, что будет ожидаться и бросится ли исключение.

Запуск в трёх режимах (из папки chapters: dotnet run -c Release --project 02-state-machine\final -- <режим>):

1
2
3
4
5
6
mode=delay, main thread 2
GetValueAsync  = 42
SumAsync       = 60
CalcAsync(20)  = 41, thread 5
сразу после вызова: IsCompleted=False, Status=WaitingForActivation
CalcManual(20) = 41, thread 5

Ручная машина ведёт себя как настоящая. Сразу после вызова задача не завершена (строка 5): машина приостановилась в строке 53 ManualMachine.cs. Результат пришёл на потоке пула (строка 6).

1
2
3
4
5
6
mode=completed, main thread 2
GetValueAsync  = 42
SumAsync       = 60
CalcAsync(20)  = 41, thread 5
сразу после вызова: IsCompleted=True, Status=RanToCompletion
CalcManual(20) = 41, thread 5

Ожидается Task.CompletedTask (строка 46): строка 48 видит IsCompleted == true, приостановки нет. Весь метод прошёл внутри Start (строка 17), и CalcManualAsync вернула уже завершённую задачу (строка 5 вывода). Поток в строке 6 — 5, а не 2, потому что предыдущий await (строка 4) уже увёл Main на поток пула.

1
2
3
4
5
6
mode=throw, main thread 2
GetValueAsync  = 42
SumAsync       = 60
CalcAsync(20)  = 41, thread 5
сразу после вызова: IsCompleted=False, Status=WaitingForActivation
исключение увидели только при await: ошибка внутри MoveNext

Исключение (строка 57) брошено во втором вызове MoveNext, после возобновления. Его поймал catch (строка 61) и положил в задачу через SetException (строка 64). Из вызова CalcManualAsync ничего не вылетело (строка 5 вывода), а исключение увидел только await (строка 6).

2.8. Итоги

  • Async-метод = заглушка (создаёт машину, Start, возвращает builder.Task) + структура-машина с MoveNext, в которую переехало тело метода.
  • <>1__state: -1 — выполняется, 0…N — стоит на await №N, -2 — завершён.
  • Каждый await — одна и та же конструкция: получить awaiter → IsCompleted? → иначе запомнить состояние и awaiter, AwaitUnsafeOnCompleted, return → при возобновлении достать awaiter → GetResult().
  • Полями становятся значения, нужные после await: параметры, поднятые локальные (<x>5__N), служебные «разлитые» значения (<>7__wrapN). В Debug полями становятся все локальные.
  • В Release машина — структура: пока метод не приостановился, она на стеке, и кучи нет. В Debug это класс, и new есть всегда.
  • catch и finally с await переписываются в «запомнить исключение → выполнить тело вне обработчика → перебросить через ExceptionDispatchInfo».
  • await внутри lock — CS1996; Span через await — CS4007; до await можно (C# 13).

Код лабораторной

Запуск из папки главы: dotnet run -c Release --project start или --project final -- delay|completed|throw.

start/Program.cs
// Глава 02. Сравниваем настоящий async-метод и машину состояний, написанную руками.
// Пока MoveNext не реализован, ручной вариант завершится NotImplementedException.

Console.WriteLine($"main thread {Environment.CurrentManagedThreadId}");

Console.WriteLine($"GetValueAsync  = {await Samples.GetValueAsync()}");
Console.WriteLine($"SumAsync       = {await Samples.SumAsync([1, 2, 3])}");
Console.WriteLine($"CalcAsync(20)  = {await Samples.CalcAsync(20)}, thread {Environment.CurrentManagedThreadId}");

try
{
    int r = await Manual.CalcManualAsync(20);
    Console.WriteLine($"CalcManual(20) = {r}, thread {Environment.CurrentManagedThreadId}");
}
catch (NotImplementedException e)
{
    Console.WriteLine($"CalcManual(20) : {e.Message}");
}
start/Constructs.cs
// §2.4–2.6: что компилятор делает со сложными конструкциями. Смотрим декомпиляцией:
//   .\tools\disasm.ps1 chapters\02-state-machine\start -Type Constructs -Mode cs
public static class Constructs
{
    // §2.4: какие локальные переменные становятся полями машины.
    public static async Task<int> HoistAsync(int x)
    {
        int before = x + 1;          // живёт через await → поле машины
        int temp = x * 100;          // используется только до await → обычная локальная MoveNext
        Console.WriteLine(temp);
        await Task.Yield();
        return before;
    }

    // §2.6: await в try, catch и finally.
    public static async Task<string> TryCatchFinallyAsync()
    {
        try
        {
            await Task.Yield();
            throw new InvalidOperationException("boom");
        }
        catch (InvalidOperationException e)
        {
            await Task.Yield();          // await в catch — с C# 6
            return e.Message;
        }
        finally
        {
            await Task.Yield();          // await в finally — с C# 6
        }
    }

    // §2.6: await using — это try/finally с await DisposeAsync().
    public static async Task UsingAsync()
    {
        await using var resource = new Resource();
        await Task.Yield();
    }

    // §2.6: Span<T> в async-методе разрешён с C# 13, если не живёт через await.
    public static async Task<int> SpanAsync(int[] data)
    {
        int sum = 0;
        {
            Span<int> span = data;       // ref struct: только до await
            foreach (int v in span)
                sum += v;
        }
        await Task.Yield();
        // Span<int> tooLate = data; await Task.Yield(); tooLate[0]++;   // раскомментируйте: ошибка компиляции
        return sum;
    }

    // §2.6: у async-лямбды своя машина состояний.
    public static Func<Task<int>> MakeLambda(int seed) => async () =>
    {
        await Task.Yield();
        return seed * 2;
    };

    sealed class Resource : IAsyncDisposable
    {
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
    }
}
start/ManualMachine.cs
using System.Runtime.CompilerServices;

// §2.7. Ручная «компиляция» Samples.CalcAsync.
public static class Manual
{
    // Заглушка (stub): то, что компилятор оставляет на месте async-метода. Готова.
    public static Task<int> CalcManualAsync(int x)
    {
        var machine = new CalcMachine
        {
            X = x,
            State = -1,
            Builder = AsyncTaskMethodBuilder<int>.Create(),
        };
        machine.Builder.Start(ref machine);
        return machine.Builder.Task;
    }
}

struct CalcMachine : IAsyncStateMachine
{
    public int State;
    public AsyncTaskMethodBuilder<int> Builder;
    public int X;                       // параметр
    private int _local;                 // поднятая локальная
    private TaskAwaiter _awaiter;       // awaiter между приостановками

    public void MoveNext()
    {
        // TODO: повторите структуру MoveNext из декомпиляции CalcAsync (README, §2.2 и §2.7):
        //  1. State != 0: код до await (_local = X * 2), awaiter = Task.Delay(100).GetAwaiter();
        //     если !awaiter.IsCompleted: State = 0, сохранить awaiter в поле,
        //     Builder.AwaitUnsafeOnCompleted(ref awaiter, ref this), return.
        //  2. State == 0: восстановить awaiter из поля, очистить поле, State = -1.
        //  3. awaiter.GetResult(); результат = _local + 1.
        //  4. Всё это в try; в catch: State = -2, Builder.SetException(e), return.
        //  5. В конце: State = -2, Builder.SetResult(результат).
        Builder.SetException(new NotImplementedException("Реализуйте CalcMachine.MoveNext"));
    }

    public void SetStateMachine(IAsyncStateMachine machine) => Builder.SetStateMachine(machine);
}
start/Samples.cs
// Настоящие async-методы. Их машины состояний смотрим декомпилятором (README, §2.2–2.4):
//   dotnet build -c Release
//   ilspycmd -ds AsyncAwait=false bin/Release/net10.0/Ch02.Start.dll -t Samples
// Методы лежат в обычном классе, а не в top-level statements, чтобы имена в декомпиляции были читаемыми.
public static class Samples
{
    // §2.2: простейший случай, один await.
    public static async Task<int> GetValueAsync()
    {
        await Task.Delay(100);
        return 42;
    }

    // §2.4: цикл с await, «поднятые» переменные.
    public static async Task<int> SumAsync(int[] ids)
    {
        int sum = 0;
        foreach (var id in ids)
            sum += await LoadAsync(id);
        return sum;
    }

    static Task<int> LoadAsync(int id) => Task.FromResult(id * 10);

    // §2.7: этот метод мы «компилируем» руками в ManualMachine.cs.
    public static async Task<int> CalcAsync(int x)
    {
        int local = x * 2;
        await Task.Delay(100);
        return local + 1;
    }
}
final/Program.cs
// Глава 02, итог. Ручная машина состояний ведёт себя как настоящий async-метод.
// Режимы: dotnet run -c Release -- delay | completed | throw

Manual.Mode = args.FirstOrDefault() ?? "delay";
Console.WriteLine($"mode={Manual.Mode}, main thread {Environment.CurrentManagedThreadId}");

Console.WriteLine($"GetValueAsync  = {await Samples.GetValueAsync()}");
Console.WriteLine($"SumAsync       = {await Samples.SumAsync([1, 2, 3])}");
Console.WriteLine($"CalcAsync(20)  = {await Samples.CalcAsync(20)}, thread {Environment.CurrentManagedThreadId}");

Task<int> manual = Manual.CalcManualAsync(20);
// completed: задача уже завершена при возврате из заглушки, весь метод прошёл внутри Start.
// throw: вызов не бросил, исключение лежит в задаче.
Console.WriteLine($"сразу после вызова: IsCompleted={manual.IsCompleted}, Status={manual.Status}");
try
{
    int r = await manual;
    Console.WriteLine($"CalcManual(20) = {r}, thread {Environment.CurrentManagedThreadId}");
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"исключение увидели только при await: {e.Message}");
}
final/Constructs.cs
// §2.4–2.6: что компилятор делает со сложными конструкциями. Смотрим декомпиляцией:
//   .\tools\disasm.ps1 chapters\02-state-machine\start -Type Constructs -Mode cs
public static class Constructs
{
    // §2.4: какие локальные переменные становятся полями машины.
    public static async Task<int> HoistAsync(int x)
    {
        int before = x + 1;          // живёт через await → поле машины
        int temp = x * 100;          // используется только до await → обычная локальная MoveNext
        Console.WriteLine(temp);
        await Task.Yield();
        return before;
    }

    // §2.6: await в try, catch и finally.
    public static async Task<string> TryCatchFinallyAsync()
    {
        try
        {
            await Task.Yield();
            throw new InvalidOperationException("boom");
        }
        catch (InvalidOperationException e)
        {
            await Task.Yield();          // await в catch — с C# 6
            return e.Message;
        }
        finally
        {
            await Task.Yield();          // await в finally — с C# 6
        }
    }

    // §2.6: await using — это try/finally с await DisposeAsync().
    public static async Task UsingAsync()
    {
        await using var resource = new Resource();
        await Task.Yield();
    }

    // §2.6: Span<T> в async-методе разрешён с C# 13, если не живёт через await.
    public static async Task<int> SpanAsync(int[] data)
    {
        int sum = 0;
        {
            Span<int> span = data;       // ref struct: только до await
            foreach (int v in span)
                sum += v;
        }
        await Task.Yield();
        // Span<int> tooLate = data; await Task.Yield(); tooLate[0]++;   // раскомментируйте: ошибка компиляции
        return sum;
    }

    // §2.6: у async-лямбды своя машина состояний.
    public static Func<Task<int>> MakeLambda(int seed) => async () =>
    {
        await Task.Yield();
        return seed * 2;
    };

    sealed class Resource : IAsyncDisposable
    {
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
    }
}
final/ManualMachine.cs
using System.Runtime.CompilerServices;

// §2.7. Ручная «компиляция» Samples.CalcAsync.
public static class Manual
{
    // Режим эксперимента: "delay" (по умолчанию), "completed" (await на завершённой задаче), "throw" (исключение).
    public static string Mode = "delay";

    public static Task<int> CalcManualAsync(int x)
    {
        var machine = new CalcMachine
        {
            X = x,
            State = -1,
            Builder = AsyncTaskMethodBuilder<int>.Create(),
        };
        machine.Builder.Start(ref machine);       // первый MoveNext() идёт синхронно, здесь же
        return machine.Builder.Task;
    }
}

struct CalcMachine : IAsyncStateMachine
{
    public int State;
    public AsyncTaskMethodBuilder<int> Builder;
    public int X;                       // параметр
    private int _local;                 // поднятая локальная
    private TaskAwaiter _awaiter;       // awaiter между приостановками

    public void MoveNext()
    {
        int result;
        try
        {
            TaskAwaiter awaiter;
            if (State == 0)
            {
                // возобновление после await
                awaiter = _awaiter;
                _awaiter = default;
                State = -1;
            }
            else
            {
                _local = X * 2;                                           // код до await
                Task awaited = Manual.Mode == "completed" ? Task.CompletedTask : Task.Delay(100);
                awaiter = awaited.GetAwaiter();
                if (!awaiter.IsCompleted)
                {
                    State = 0;
                    _awaiter = awaiter;
                    Builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);  // здесь структура упаковывается в кучу
                    return;
                }
            }
            if (Manual.Mode == "throw")
                throw new InvalidOperationException("ошибка внутри MoveNext");
            awaiter.GetResult();
            result = _local + 1;                                          // return local + 1
        }
        catch (Exception e)
        {
            State = -2;
            Builder.SetException(e);
            return;
        }
        State = -2;
        Builder.SetResult(result);
    }

    public void SetStateMachine(IAsyncStateMachine machine) => Builder.SetStateMachine(machine);
}
final/Samples.cs
// Настоящие async-методы. Их машины состояний смотрим декомпилятором (README, §2.2–2.4):
//   dotnet build -c Release
//   ilspycmd -ds AsyncAwait=false bin/Release/net10.0/Ch02.Start.dll -t Samples
// Методы лежат в обычном классе, а не в top-level statements, чтобы имена в декомпиляции были читаемыми.
public static class Samples
{
    // §2.2: простейший случай, один await.
    public static async Task<int> GetValueAsync()
    {
        await Task.Delay(100);
        return 42;
    }

    // §2.4: цикл с await, «поднятые» переменные.
    public static async Task<int> SumAsync(int[] ids)
    {
        int sum = 0;
        foreach (var id in ids)
            sum += await LoadAsync(id);
        return sum;
    }

    static Task<int> LoadAsync(int id) => Task.FromResult(id * 10);

    // §2.7: этот метод мы «компилируем» руками в ManualMachine.cs.
    public static async Task<int> CalcAsync(int x)
    {
        int local = x * 2;
        await Task.Delay(100);
        return local + 1;
    }
}