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

0. Стенд и инструменты

О главе

Цель: собрать стенд и научиться смотреть на async-код на всех уровнях: C# без «сахара», IL, машинный код JIT, исходники рантайма.

Лабораторная: отдельного start/final нет. Инструменты пробуем на проекте главы 2, практикум в §0.8.

Статус: ✅ проверено на Windows 11, SDK 10.0.401, рантайм 10.0.12, ilspycmd 11.1.

0.1. Стенд

Нужно:

  • .NET 10 SDK (любой feature band 10.0.1xx и новее). Проверка: dotnet --list-sdks и dotnet --list-runtimes.
  • Git — чтобы получить репозиторий.
  • PowerShell. Скрипты курса работают и в Windows PowerShell 5.1, и в PowerShell 7.
  • Python 3.11+ — только если хотите собирать учебник локально (§0.9).
  • Любая IDE: Visual Studio 2026, Rider или VS Code с C# Dev Kit.

С нуля на новой машине:

1
2
3
4
5
git clone <адрес репозитория> async-await
cd async-await\chapters
dotnet tool restore                                  # ilspycmd нужной версии (§0.3)
dotnet build -c Release                              # все лабораторные, 32 проекта
dotnet run -c Release --project 01-mental-model\start
  • Строка 2: все команды dotnet в курсе выполняются из папки chapters (или из папки главы внутри неё). Там лежат решение, global.json и манифест инструментов, а dotnet ищет их от текущей папки вверх.
  • Строка 3: инструменты курса описаны в манифесте chapters/.config/dotnet-tools.json с точными версиями. dotnet tool restore ставит их локально для курса, а не глобально в систему.
  • Строка 4: в chapters одно решение, AsyncCourse.slnx, поэтому dotnet build находит его сам. Оно содержит start и final всех глав. Общие настройки проектов (net10.0, nullable, последний C#) — в chapters/Directory.Build.props.
  • Строка 5: из папки главы то же короче: dotnet run -c Release --project start.

Что в chapters отвечает за воспроизводимость:

Файл Зачем
global.json закрепляет .NET 10 SDK (rollForward: latestFeature). Даже если на машине стоит превью .NET 11, курс собирается десятым. Действует, только если команда запущена из chapters или глубже
.config/dotnet-tools.json версия ilspycmd (11.1) — листинги декомпиляции совпадут с книгой
nuget.config источник пакетов nuget.org (BenchmarkDotNet для главы 12)

Факт: NuGet без nuget.org

На стенде автора в глобальной конфигурации NuGet был подключён только офлайн-каталог Visual Studio, и dotnet add package отвечал There are no versions available. Локальный nuget.config решает это, не трогая глобальные настройки машины.

Только Release

Всё, что касается машин состояний и аллокаций, смотрим только в Release. В Debug компилятор делает машину состояний классом (а не структурой) и сохраняет все локальные переменные полями, чтобы их видел отладчик. Выводы о производительности по Debug-сборке неверны.

0.2. Пять уровней взгляда на один await

Уровень Что видно Инструмент
1. C# как написан async/await редактор
2. C# без «сахара» (lowered) заглушка + машина состояний <M>d__N с MoveNext ilspycmd -ds AsyncAwait=false, ILSpy, SharpLab
3. IL то же в инструкциях стековой машины; видно, что реально вызывается ilspycmd -il, ILSpy, Rider IL Viewer
4. Машинный код что JIT сделал из IL: встраивания, раскладка полей, проверки флагов Task DOTNET_JitDisasm, окно Disassembly в VS
5. Рантайм что делают AsyncTaskMethodBuilder, Task, TaskAwaiter source.dot.net, GitHub dotnet/runtime, декомпиляция CoreLib

На уровне 2 удобно разбираться в логике, на уровне 3 — убеждаться, что именно вызывается. Уровень 4 нужен, когда речь о стоимости, уровень 5 — когда нужно понять поведение (контексты, продолжения, аллокации).

Вот как один и тот же метод выглядит на уровнях 1–4. Это «было → стало»: на каждой вкладке весь GetValueAsync целиком (вывод tools/disasm.ps1 на сборке главы 2, Release, рантайм 10.0.12).

Главное, что видно при переключении: от метода осталась заглушка ①, а его тело — и await ②, и return 42 ③ — переехало в метод MoveNext новой структуры <GetValueAsync>d__0, которую компилятор вложил в тот же класс. Метки ①②③ одинаковы на всех вкладках. Во всех главах курса вкладки с одинаковыми названиями переключаются вместе.

public static class Samples
{
    public static async Task<int> GetValueAsync()   // ①
    {
        await Task.Delay(100);                      // ②
        return 42;                                  // ③
    }
}
public static class Samples
{
    // ① заглушка: то, что осталось на месте метода. Уже без async.
    [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);              // ① синхронно вызывает MoveNext() ниже
        return stateMachine.<>t__builder.Task;                           // ① Task, который получит вызывающий
    }

    // Машина состояний: сюда переехало тело метода.
    [StructLayout(LayoutKind.Auto)]
    [CompilerGenerated]
    private struct <GetValueAsync>d__0 : IAsyncStateMachine
    {
        public int <>1__state;                        // -1: идёт, 0: стоит на await ②, -2: завершён
        public AsyncTaskMethodBuilder<int> <>t__builder;
        private TaskAwaiter <>u__1;                   // ② awaiter хранится между вызовами MoveNext

        private void MoveNext()
        {
            int num = <>1__state;
            int result;
            try
            {
                TaskAwaiter awaiter;
                if (num != 0)                                                      // первый вызов (из Start)
                {
                    awaiter = Task.Delay(100).GetAwaiter();                        // ② await Task.Delay(100)
                    if (!awaiter.IsCompleted)                                      // ② уже готово? тогда без остановки
                    {
                        num = (<>1__state = 0);                                    // ② запомнить, где остановились
                        <>u__1 = awaiter;
                        <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); // ② «вызови MoveNext, когда Delay завершится»
                        return;                                                    // ② уйти: поток свободен
                    }
                }
                else                                                               // второй вызов (Delay завершился)
                {
                    awaiter = <>u__1;                                              // ② восстановить awaiter
                    <>u__1 = default;
                    num = (<>1__state = -1);
                }
                awaiter.GetResult();                                               // ② конец await; исключение бросится здесь
                result = 42;                                                       // ③ return 42
            }
            catch (Exception exception)
            {
                <>1__state = -2;
                <>t__builder.SetException(exception);                              // исключение уходит в Task
                return;
            }
            <>1__state = -2;
            <>t__builder.SetResult(result);                                        // ③ 42 попадает в Task
        }

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

MoveNext вызывается дважды: первый раз из Start в заглушке (доходит до Delay, подписывается и уходит), второй — когда Delay завершится (идёт по ветке else до result = 42 и завершает Task). Явные реализации интерфейса в конце 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.

Подробно, шаг за шагом, всё это разберём в главе 2. Там же будет интерактивная схема: наводишь на строку C# и видишь её след во всех представлениях.

0.3. ilspycmd: декомпиляция в C# и IL

ilspycmd — локальный инструмент курса. Его ставит dotnet tool restore из §0.1. Запуск — из папки chapters или глубже (dotnet ищет манифест от текущей папки вверх):

dotnet tool restore
dotnet tool run ilspycmd --version        # или короче: dotnet ilspycmd --version

Если глобальная конфигурация NuGet не видит nuget.org, dotnet tool restore подхватит chapters/nuget.config. Для работы вне репозитория можно поставить и глобальную копию: dotnet tool install --global ilspycmd --version 11.1.0.9782 --add-source https://api.nuget.org/v3/index.json. Скрипт tools/disasm.ps1 (§0.5) сам переходит в chapters и вызывает локальную версию, поэтому его можно запускать из любой папки.

На Windows дополнительных переменных не нужно. На Linux/macOS при SDK в ~/.dotnet понадобится DOTNET_ROOT=$HOME/.dotnet.

Основные ключи:

Ключ Что делает
-t Samples декомпилировать один тип (вложенные типы, то есть машины состояний, попадают вместе с ним)
-m "M:Samples.CalcAsync(System.Int32)" один член по XML-doc id или токену метаданных
-ds AsyncAwait=false не восстанавливать async: показать заглушку и машину состояний
-il IL вместо C#
--disable-updatecheck не ходить в сеть за обновлениями (для скриптов)
-o папка писать в файлы, а не в консоль

Декомпилятор «слишком умный»

Без -ds AsyncAwait=false ILSpy узнаёт шаблон машины состояний и показывает исходный async-метод, будто ничего не произошло. В GUI ILSpy это галочка Decompile async methods в настройках декомпилятора.

Ловушка: декомпилированный код попадает в сборку

SDK-проект включает в компиляцию все *.cs в папке проекта рекурсивно. Если сохранить декомпилированный Samples.cs рядом с проектом, следующая сборка упадёт с сотней ошибок на именах вида <>1__state. В курсе папка disasm/ исключена через DefaultItemExcludes в chapters/Directory.Build.props.

0.4. Машинный код: DOTNET_JitDisasm

С .NET 7 JIT умеет печатать ассемблер в обычном (Release) рантайме, без отладочных сборок. Управление идёт через переменные окружения:

Переменная Значение
DOTNET_JitDisasm фильтр методов: Класс:Метод, * — любой фрагмент, несколько фильтров через пробел
DOTNET_JitStdOutFile писать листинг в файл, а не в stdout программы
DOTNET_JitDisasmSummary=1 вместо кода — список всех JIT-скомпилированных методов с уровнем оптимизации
DOTNET_TieredCompilation=0 сразу оптимизированный код (FullOpts) вместо Tier0
DOTNET_ReadyToRun=0 не использовать предкомпилированный код библиотек: JIT компилирует и методы CoreLib
DOTNET_JitDisasmDiffable=1 без конкретных адресов: удобно сравнивать листинги

Уровни компиляции. Метод сначала компилируется быстро и без оптимизаций (Tier0). Если его вызывают часто, он перекомпилируется с оптимизациями и данными профиля (Tier1 + Dynamic PGO, включён по умолчанию с .NET 8). Короткая демо-программа обычно не доживает до Tier1. Поэтому по умолчанию отключаем многоуровневую компиляцию и смотрим FullOpts: это оптимизированный код, но без PGO. Чтобы увидеть настоящий Tier1, вызовите метод много раз без DOTNET_TieredCompilation=0: в листинге будут обе версии.

Факт: имя класса в фильтре — полное

Класс сравнивается с полным именем, с пространством имён:

Фильтр Результат
*CalcAsync*:MoveNext ✅ Samples+<CalcAsync>d__3:MoveNext (у Samples нет пространства имён)
AsyncMethodBuilderCore:Start* ❌ ничего
*AsyncMethodBuilderCore:Start ✅ все инстанциации
System.Runtime.CompilerServices.AsyncMethodBuilderCore:Start ✅
Start ✅ (только имя метода — любой класс)

Факт: генерики рантайма над вашими структурами всегда компилирует JIT

AsyncMethodBuilderCore.Start<TStateMachine> и AsyncStateMachineBox<TResult, TStateMachine> инстанцируются вашим типом машины. Предкомпилированной версии в CoreLib для них быть не может, поэтому они видны в листинге без DOTNET_ReadyToRun=0. Отдельного AsyncTaskMethodBuilder<T>.Start в листинге нет: он встраивается в заглушку.

0.5. Скрипт tools/disasm.ps1

Скрипт собирает проект в Release и раскладывает все уровни по файлам в disasm/ рядом с проектом (папка в .gitignore):

# C# без сахара, C# с async, IL и ассемблер MoveNext
.\tools\disasm.ps1 chapters\02-state-machine\final -Type Samples -Method '*CalcAsync*:MoveNext'

# только ассемблер рантаймового Start для всех машин программы
.\tools\disasm.ps1 chapters\02-state-machine\final -Mode asm -Method '*AsyncMethodBuilderCore:Start'

# что вообще скомпилировал JIT: ищем имена генерированных типов и инстанциаций
.\tools\disasm.ps1 chapters\02-state-machine\final -Mode asm -Method '*' -Summary

# декомпилировать тип рантайма из System.Private.CoreLib текущего .NET
.\tools\disasm.ps1 -Assembly corelib -Type 'System.Runtime.CompilerServices.AsyncMethodBuilderCore' -Mode cs
Параметр Смысл
-Type тип для C#/IL; без него — вся сборка
-Method фильтр DOTNET_JitDisasm (см. §0.4)
-Mode all (по умолчанию), cs, cs-async, il, asm; можно через запятую
-Tier0 / -NoR2R / -Diffable / -Summary соответствующие переменные из §0.4
-Assembly готовая сборка или corelib вместо проекта
-AppArgs аргументы программе при снятии ассемблера

Для режима asm программа запускается: JIT печатает только то, что реально выполнялось. Метод, который не вызывался, в листинг не попадёт. Веб-приложение (глава 17) так не снять: оно не завершается само.

Факт: ловушка Windows PowerShell 5.1

Первая версия скрипта искала CoreLib через [object].Assembly.Location и декомпилировала… mscorlib из .NET Framework 4.x: Windows PowerShell сам работает на .NET Framework. Выдал это код с MoveNextRunner и AsyncCausalityTracer, которых в современном рантайме нет. Теперь путь берётся из dotnet --list-runtimes.

0.6. Исходники рантайма

Декомпиляция показывает, что скомпилировано, а исходники — зачем: в них есть комментарии разработчиков рантайма.

  • source.dot.net — поиск по имени типа, навигация по ссылкам, версия последнего релиза.
  • GitHub dotnet/runtime, ветка release/10.0. Для .NET 11 — ветка main .NET 11.
  • Visual Studio / Rider: F12 на Task или AsyncTaskMethodBuilder открывает исходник через SourceLink (или декомпилирует, если исходник недоступен).
  • Декомпиляция CoreLib вашей версии: .\tools\disasm.ps1 -Assembly corelib -Type .... Это гарантия, что вы читаете ровно тот код, который выполняется.

Карта файлов, к которым курс будет возвращаться (все пути от src/libraries/System.Private.CoreLib/src/System/):

Файл Что там Главы
Runtime/CompilerServices/AsyncMethodBuilderCore.cs Start: запуск первого MoveNext и восстановление контекстов 2, 7
Runtime/CompilerServices/AsyncTaskMethodBuilderT.cs AsyncStateMachineBox, AwaitUnsafeOnCompleted, SetResult 3, 4
Runtime/CompilerServices/TaskAwaiter.cs IsCompleted, GetResult, подписка продолжения 3, 5, 8
Threading/Tasks/Task.cs m_stateFlags, m_continuationObject, RunContinuations 4, 5
Threading/ExecutionContext.cs захват и восстановление контекста, AsyncLocal 7
Threading/Tasks/Sources/ManualResetValueTaskSourceCore.cs основа своих IValueTaskSource 12
Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs пулинг боксов машин 12
Threading/PortableThreadPool*.cs пул потоков, hill climbing, защита от голодания 13

Под капотом: AsyncMethodBuilderCore.Start

Так выглядит метод, который запускает любую машину состояний (рантайм 10.0.12, декомпиляция CoreLib, сокращено). Здесь видно, почему изменения AsyncLocal внутри async-метода не видны вызывающему (глава 7):

public static void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    Thread currentThread = Thread.CurrentThread;
    ExecutionContext executionContext = currentThread._executionContext;    // (1)!
    SynchronizationContext synchronizationContext = currentThread._synchronizationContext;
    try
    {
        stateMachine.MoveNext();                                            // (2)!
    }
    finally
    {
        if (synchronizationContext != currentThread._synchronizationContext)
            currentThread._synchronizationContext = synchronizationContext; // (3)!
        ExecutionContext executionContext2 = currentThread._executionContext;
        if (executionContext != executionContext2)
            ExecutionContext.RestoreChangedContextToThread(currentThread, executionContext, executionContext2);
    }
}
  1. Запомнить контексты потока до запуска метода.
  2. Первый шаг метода — синхронно, на текущем потоке, до первого незавершённого await.
  3. Что бы метод ни сделал с контекстами, вызывающий получает поток в прежнем состоянии.

0.7. Другие инструменты

  • SharpLab (sharplab.io): быстро вставить код и увидеть C# без сахара, IL или JIT asm в браузере. Версия компилятора и рантайма там своя и может отставать от .NET 10 . Для «фактов» курса используйте локальный стенд.
  • Visual Studio: окно Disassembly (Ctrl+Alt+D при остановке) и Parallel Stacks в режиме Tasks (глава 15).
  • Rider: окно IL Viewer с режимами «low-level C#» и IL.
  • BenchmarkDotNet [DisassemblyDiagnoser]: ассемблер прямо в отчёте бенчмарка (глава 12).
  • dotnet-counters, dotnet-dump, dotnet-trace: живой процесс (глава 15).

0.8. Практикум

Лабораторная: инструменты на проекте главы 2

Все задания — на проекте chapters/02-state-machine/final. Ответы ниже, под спойлером.

Задание 1. Снимите C#, IL и ассемблер:

.\tools\disasm.ps1 chapters\02-state-machine\final -Type Samples -Method 'Samples:GetValueAsync *CalcAsync*:MoveNext'

Найдите в Samples.cs заглушку GetValueAsync и машину <GetValueAsync>d__0. Это класс или структура? Какие у неё поля?

Задание 2. В Samples.il найдите, как машина «подключается» к интерфейсу IAsyncStateMachine. Почему в C#-декомпиляции появляется комментарий ILSpy generated this explicit interface implementation from .override directive?

Задание 3. Прочитайте Samples_GetValueAsync__CalcAsync__MoveNext.asm, листинг Samples:GetValueAsync. Где лежит машина состояний? Что стало с вызовами AsyncTaskMethodBuilder<int>.Create(), .Start() и .Task?

Задание 4. Снимите -Summary и найдите все типы AsyncStateMachineBox. Сколько их и почему они разные?

Задание 5. Поле локальной переменной local в CalcAsync называется иначе, чем «разлитая» sum в SumAsync (глава 2, §2.4). Почему?

Ответы

1. В Release — struct с атрибутом [StructLayout(LayoutKind.Auto)]. Поля: <>1__state, <>t__builder (AsyncTaskMethodBuilder<int>), <>u__1 (TaskAwaiter). Заглушка и MoveNext целиком — на вкладке «C# без сахара» в §0.2.

2. Метод MoveNext машины объявлен private, а реализацию интерфейса задаёт директива .override method instance void [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine::MoveNext(). В C# так написать нельзя, поэтому ILSpy показывает это как явную реализацию интерфейса, вызывающую приватный метод.

3. Разбор — на вкладке «asm» в §0.2, первый листинг. Машина лежит на стеке заглушки ([rsp+0x20], 24 байта). Create(), AsyncTaskMethodBuilder<int>.Start и .Task встроены. От них остались обнуление, прямой вызов AsyncMethodBuilderCore.Start и ленивое создание Task.

4. Четыре, по одной инстанциации на пару «тип результата + тип машины»:

AsyncStateMachineBox`1[int, Samples+<GetValueAsync>d__0]
AsyncStateMachineBox`1[VoidTaskResult, Program+<<Main>$>d__0]     ← top-level Main тоже async-метод
AsyncStateMachineBox`1[int, Samples+<CalcAsync>d__3]
AsyncStateMachineBox`1[int, CalcMachine]                          ← ручная машина из главы 2

Бокс — генерик над конкретной структурой машины, поэтому для каждого async-метода, который хоть раз приостановился, JIT компилирует свой бокс. SumAsync в списке нет: она ни разу не приостановилась (Task.FromResult уже завершён), и бокс не понадобился. Ручная CalcMachine упакована рантаймом точно так же, как сгенерированная компилятором: builder'у всё равно, кто написал машину.

5. local — обычная локальная переменная, живущая через await. Компилятор поднимает её в поле и сохраняет имя: <local>5__2. В SumAsync поле с исходным именем не создаётся: через await живёт не сама sum, а промежуточное значение выражения sum += await ... (левая часть вычислена до await). Оно сохраняется во временное поле <>7__wrap3. Подробно — в главе 2.

0.9. Учебник: сборка и просмотр

Текст курса — markdown-файлы chapters/NN-тема/README.md. Из них генератор Zensical (преемник Material for MkDocs, та же разметка) собирает сайт с навигацией, поиском, вкладками и вставками кода лабораторных. Настройки — zensical.toml в корне.

1
2
3
4
python -m venv .venv
.\.venv\Scripts\python -m pip install -r requirements-docs.txt
.\.venv\Scripts\zensical serve          # http://localhost:8000, пересобирается при каждом сохранении
.\.venv\Scripts\zensical build          # статический сайт в папку site/
  • Строки 1–2: отдельное окружение Python в .venv (в .gitignore) с зафиксированной версией Zensical. Нужен Python 3.11 и новее.
  • Строка 3: живой просмотр. Если порт 8000 занят, укажите другой: --dev-addr localhost:8001.
  • Строка 4: статическая сборка в site/ (в .gitignore). Открывать site/index.html двойным щелчком не стоит: ссылки вида /02-state-machine/ через file:// не работают. Раздайте папку так: python -m http.server 8000 -d site.

Что попадает в сайт. Код лабораторных (*.cs, *.csproj), bin/, obj/, disasm/ и решение исключены через плагин exclude в zensical.toml. В текст код попадает только через вставки:

--8<-- "02-state-machine/start/Samples.cs:8:12"

(В исходнике главы строка пишется без точки с запятой; здесь ; экранирует её, чтобы показать саму директиву, а не вставленный код.)

Это строки 8–12 файла chapters/02-state-machine/start/Samples.cs. Номера строк в листинге книги задаются тем же числом (linenums="8"), поэтому совпадают с файлом.