Цель: уметь прочитать машину состояний, которую строит компилятор из любого 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-метод компилятор разбирает на две части.
Заглушка (stub) — метод с тем же именем и сигнатурой, но уже без async, помеченный атрибутом [AsyncStateMachine(typeof(...))]. Он создаёт машину, запускает её и возвращает Task.
Машина состояний — вложенный тип с непроизносимым именем <ИмяМетода>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 выполнение продолжится с места после него
Для читаемости убраны пространства имён ([System.Runtime]System.Runtime.CompilerServices. и т. п.), служебные комментарии ILSpy и атрибуты Nullable. Инструкции не тронуты. MoveNext объявлен private, а с интерфейсом его связывает директива .override.
; ===== заглушка: Samples:GetValueAsync() (FullOpts), x64, Windows =====G_M000_IG01:subrsp,56vxorpsxmm4,xmm4,xmm4vmovdqaxmmwordptr[rsp+0x20],xmm4; машина (24 байта) на стеке = default + Create()xoreax,eaxmovqwordptr[rsp+0x30],raxG_M000_IG02:movdwordptr[rsp+0x20],-1; state = -1learcx,[rsp+0x20]; ref stateMachine — адрес на стекеcall[AsyncMethodBuilderCore:Start[Samples+<GetValueAsync>d__0](byref)]movrax,gwordptr[rsp+0x28]; builder.Task: m_task уже есть?testrax,raxjeSHORTG_M000_IG04G_M000_IG03:addrsp,56retG_M000_IG04:learcx,[rsp+0x28]call[AsyncTaskMethodBuilder`1[int]:InitializeTaskAsPromise()]; нет — создать ленивоjmpSHORTG_M000_IG03; ===== машина: Samples+<GetValueAsync>d__0:MoveNext() (FullOpts) =====; поля машины: [rcx] = <>1__state, [rcx+0x08] = <>t__builder.m_task, [rcx+0x10] = <>u__1G_M000_IG01:pushrbppushrsipushrbxsubrsp,48learbp,[rsp+0x40]xoreax,eaxmovqwordptr[rbp-0x18],raxmovbwordptr[rbp+0x10],rcxG_M000_IG02:moveax,dwordptr[rcx]; num = <>1__stateG_M000_IG03:testeax,eaxjneSHORTG_M000_IG05; state != 0 → первый вызовmovrax,gwordptr[rcx+0x10]; возобновление: awaiter = <>u__1movgwordptr[rbp-0x18],raxxoreax,eaxmovqwordptr[rcx+0x10],rax; <>u__1 = defaultmovdwordptr[rcx],-1; state = -1G_M000_IG04:movrdx,gwordptr[rbp-0x18]; GetResult() встроен: смотрим флаги Taskmoveax,dwordptr[rdx+0x34]; Task.m_stateFlagsandeax,0x11000000cmpeax,0x1000000; RanToCompletion?jeG_M000_IG07jmpG_M000_IG06G_M000_IG05:movrcx,0x7FF8955453A8; Task.Delay(100) встроен:callCORINFO_HELP_GET_NONGCSTATIC_BASEmovecx,0x80000D10movrbx,gwordptr[rcx]; TimeProvider.Systemmovrcx,0x7FF895545570callCORINFO_HELP_NEWSFAST; new DelayPromisemovrsi,raxmovrcx,rsimovr8,rbxmovedx,100call[System.Threading.Tasks.Task+DelayPromise:.ctor(uint,System.TimeProvider):this]movgwordptr[rbp-0x18],rsi; GetAwaiter() встроен: awaiter = задачаmovrcx,gwordptr[rbp-0x18]testdwordptr[rcx+0x34],0x1600000; IsCompleted встроен: флаги «завершена»jneSHORTG_M000_IG04; уже готово → сразу к GetResultxoredx,edxmovrcx,bwordptr[rbp+0x10]movdwordptr[rcx],edx; state = 0learcx,bwordptr[rcx+0x10]movrdx,gwordptr[rbp-0x18]callCORINFO_HELP_CHECKED_ASSIGN_REF; <>u__1 = awaitermovrcx,bwordptr[rbp+0x10]leardx,bwordptr[rcx+0x08]call[System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1[int]:GetStateMachineBox[Samples+<GetValueAsync>d__0](byref,byref):System.Runtime.CompilerServices.IAsyncStateMachineBox]movrdx,rax; упаковать машину в кучу (бокс = Task)learcx,[rbp-0x18]call[System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1[int]:AwaitUnsafeOnCompleted[System.Runtime.CompilerServices.TaskAwaiter](byref,System.Runtime.CompilerServices.IAsyncStateMachineBox)]jmpSHORTG_M000_IG09; подписались — уйтиG_M000_IG06:movrcx,rdx; задача не RanToCompletion: бросить исключение/отменуxoredx,edxcall[System.Runtime.CompilerServices.TaskAwaiter:HandleNonSuccessAndDebuggerNotification(System.Threading.Tasks.Task,int)]nopG_M000_IG07:movrcx,bwordptr[rbp+0x10]movdwordptr[rcx],-2; state = -2learbx,bwordptr[rcx+0x08]cmpgwordptr[rbx],0; SetResult встроен: есть ли уже Task (бокс)?jneSHORTG_M000_IG11G_M000_IG08:movrcx,0x7FF8954F4160; нет (метод не приостанавливался):callCORINFO_HELP_NEWSFAST; new Task<int>movdwordptr[rax+0x34],0x1000000; m_stateFlags = RanToCompletionmovdwordptr[rax+0x38],42; m_result = 42movrcx,rbxmovrdx,raxcallCORINFO_HELP_CHECKED_ASSIGN_REFG_M000_IG09:nopG_M000_IG10:addrsp,48poprbxpoprsipoprbpretG_M000_IG11:movrbx,gwordptr[rbx]; да: завершить существующий Task (TrySetResult)testbyteptr[(reloc0x7ff895548618)],1jneSHORTG_M000_IG13G_M000_IG12:movrcx,0x7FF895548558callCORINFO_HELP_GET_GCSTATIC_BASEG_M000_IG13:movecx,0x80000D58movrcx,gwordptr[rcx]cmpbyteptr[rcx+0x9D],0; включена ли трассировка TPL?jeSHORTG_M000_IG15G_M000_IG14:movrcx,0x7FF895548558callCORINFO_HELP_GET_GCSTATIC_BASEmovecx,0x80000D58movrsi,gwordptr[rcx]movrcx,rbxcmpdwordptr[rcx],ecxcall[System.Threading.Tasks.Task:get_Id():int:this]movedx,eaxmovrcx,rsimovr8d,1cmpdwordptr[rcx],ecxcall[System.Threading.Tasks.TplEventSource:TraceOperationEnd(int,int):this]G_M000_IG15:moveax,dwordptr[rbx+0x34]; m_stateFlagsmovdwordptr[rbp-0x1C],eaxtesteax,0x5600000; уже завершена? тогда ошибкаjneSHORTG_M000_IG19G_M000_IG16:learsi,bwordptr[rbx+0x34]movecx,eaxorecx,0x4000000; флаг «завершается»lockcmpxchgdwordptr[rsi],ecx; атомарно: только один может завершить Taskcmpeax,dwordptr[rbp-0x1C]jeSHORTG_M000_IG18G_M000_IG17:movrcx,rbxmovedx,0x4000000movr8d,0x5600000call[System.Threading.Tasks.Task:AtomicStateUpdateSlow(int,int):bool:this]testeax,eaxjeSHORTG_M000_IG19G_M000_IG18:movdwordptr[rbx+0x38],42; m_result = 42movecx,dwordptr[rbx+0x34]orecx,0x1000000; флаг RanToCompletionxchgdwordptr[rsi],ecxmovrsi,gwordptr[rbx+0x28]testrsi,rsijeSHORTG_M000_IG21jmpSHORTG_M000_IG20G_M000_IG19:movecx,24call[System.ThrowHelper:ThrowInvalidOperationException(int)]int3G_M000_IG20:movrcx,rbxcall[System.Threading.Tasks.Task:NotifyParentIfPotentiallyAttachedTask():this]movrcx,rsicall[System.Threading.Tasks.Task+ContingentProperties:SetCompleted():this]G_M000_IG21:movrcx,rbxcall[System.Threading.Tasks.Task:FinishContinuations():this]; разбудить тех, кто ждёт этот TaskjmpG_M000_IG09; --- обработчик catch: SetException ---G_M000_IG22:subrsp,40G_M000_IG23:movrdx,rcxmovrcx,bwordptr[rbp+0x10]movdwordptr[rcx],-2addrcx,8call[System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1[int]:SetException(System.Exception):this]learax,G_M000_IG09G_M000_IG24:addrsp,40ret
Листинги взяты как есть, добавлены только комментарии ;. Что видно:
Почти всё встроено (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
Строка 4: машина — локальная переменная-структура, она на стеке.
Строки 5–6: builder создан, состояние -1 («ещё не начинали»).
Строка 7:Startсинхронно, на этом же потоке, вызывает MoveNext.
Первый вызов MoveNext (состояние -1):
Строка 21:num = -1. Строка 26:num != 0, значит, это первый вход.
Строка 28:Task.Delay(100) создаёт задачу, берём её awaiter.
Строка 29: задача ещё не завершена, идём в приостановку.
Строки 31–32: записать в поля номер состояния 0 и awaiter. Это делается до следующей строки, потому что там машина будет скопирована в кучу, и копия должна уже всё содержать.
Строка 33:AwaitUnsafeOnCompleted упаковывает машину в объект в куче (бокс машины, см. ниже) и подписывает его на завершение задачи Delay.
Строка 34:return. MoveNext выходит, Start возвращается в заглушку.
Снова в заглушке:
Строка 8: вернуть builder.Task. Машина приостанавливалась, поэтому это бокс: незавершённый Task<int>. Вызывающий получил задачу, а стек метода исчез: всё его состояние лежит в боксе.
Через 100 мс — второй вызов MoveNext (состояние 0). Его вызывает бокс на потоке, который завершил задачу Delay:
Строка 21:num = 0. Строка 26: ложь, идём в else.
Строки 39–41: достать awaiter из поля, очистить поле, вернуть состояние -1.
Строка 43:GetResult(). Если бы Delay завершился с ошибкой или отменой, исключение было бы брошено здесь и поймано в строке 46.
Строка 44:result = 42.
Строки 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.
Локальная переменная живёт на стеке MoveNext. Но после приостановки MoveNext выходит, и стек пропадает. Поэтому каждое значение, которое нужно после await, компилятор переносит в поле машины (говорят «поднимает», hoisting). Всё остальное остаётся обычными локальными переменными MoveNext.
Почему в примерах await Task.Yield()
Task.Yield() — самый короткий await, который всегда приостанавливает метод: его awaiter возвращает IsCompleted == false, а продолжение ставится в очередь (пула потоков или контекста). Для опыта с полями это удобно: не нужны ни таймер, ни другая задача. Разбор устройства Yield и отличие от yield return — в главе 1, §1.3. Здесь важно другое: какие переменные станут полями, решает компилятор по тексту программы, а не по тому, приостановится ли метод на самом деле. С await Task.CompletedTask (никогда не приостанавливает) поля были бы теми же.
publicstaticasyncTask<int>HoistAsync(intx){intbefore=x+1;// живёт через await → поле машиныinttemp=x*100;// используется только до await → обычная локальная MoveNextConsole.WriteLine(temp);awaitTask.Yield();returnbefore;}
Строка 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;.
Неочевидный случай: цикл и составное присваивание¶
На вкладке «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.
Debug-сборка той же лабораторной (dotnet build -c Debug) даёт другую машину. Сравните вкладки в §2.4 и заглушку метода GetValueAsync. Вкладки «Release» и «Debug» здесь те же, что в §2.4: переключая одну, вы переключаете все:
Отличаются две вещи: атрибут [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.
publicstaticasyncTask<string>TryCatchFinallyAsync(){try{awaitTask.Yield();thrownewInvalidOperationException("boom");}catch(InvalidOperationExceptione){awaitTask.Yield();// await в catch — с C# 6returne.Message;}finally{awaitTask.Yield();// await в finally — с C# 6}}
Три 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 var resource = … — это try { … } finally { await resource.DisposeAsync(); }, а значит, разворачивается тем же способом, что и finally выше. Фрагмент MoveNext:
try{...// await Task.Yield() — состояние 0awaiter2.GetResult();<>7__wrap3=1;}catch(objectobj){<>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(refawaiter,refthis);return;}gotoIL_0106;}...objectobj2=<>7__wrap2;if(obj2!=null){ExceptionDispatchInfo.Capture((obj2asException)??throwobj2).Throw();}
Строки 7–10: исключение тела откладывается, как в finally выше.
Строки 11–22:DisposeAsync() вызывается и ожидается (состояние 1). Его awaiter — ValueTaskAwaiter, поэтому у машины второе поле awaiter'а<>u__2: по одному полю на каждый тип awaiter'а.
Строки 24–28: отложенное исключение перебрасывается с исходным стеком.
Сам ресурс поднят в поле <resource>5__2: он нужен после await.
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 это невозможно.
publicstaticasyncTask<int>SpanAsync(int[]data){intsum=0;{Span<int>span=data;// ref struct: только до awaitforeach(intvinspan)sum+=v;}awaitTask.Yield();// Span<int> tooLate = data; await Task.Yield(); tooLate[0]++; // раскомментируйте: ошибка компиляцииreturnsum;}
span (строка 46) живёт в блоке, который заканчивается до await (строка 50). Раскомментируйте строку 51 — получите CS4007. В декомпиляции полем стала только sum (<sum>5__2): она нужна после await в строке 52.
Лучший способ понять конечный автомат — написать его. Публичные AsyncTaskMethodBuilder<T>, IAsyncStateMachine и TaskAwaiter доступны и вам: компилятор использует именно их.
Компилятор сделал из него машину с полями 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.
publicstaticTask<int>CalcManualAsync(intx){varmachine=newCalcMachine{X=x,State=-1,Builder=AsyncTaskMethodBuilder<int>.Create(),};machine.Builder.Start(refmachine);// первый MoveNext() идёт синхронно, здесь жеreturnmachine.Builder.Task;}}structCalcMachine:IAsyncStateMachine{publicintState;publicAsyncTaskMethodBuilder<int>Builder;publicintX;// параметрprivateint_local;// поднятая локальнаяprivateTaskAwaiter_awaiter;// awaiter между приостановкамиpublicvoidMoveNext(){intresult;try{TaskAwaiterawaiter;if(State==0){// возобновление после awaitawaiter=_awaiter;_awaiter=default;State=-1;}else{_local=X*2;// код до awaitTaskawaited=Manual.Mode=="completed"?Task.CompletedTask:Task.Delay(100);awaiter=awaited.GetAwaiter();if(!awaiter.IsCompleted){State=0;_awaiter=awaiter;Builder.AwaitUnsafeOnCompleted(refawaiter,refthis);// здесь структура упаковывается в кучуreturn;}}if(Manual.Mode=="throw")thrownewInvalidOperationException("ошибка внутри MoveNext");awaiter.GetResult();result=_local+1;// return local + 1}catch(Exceptione){State=-2;Builder.SetException(e);return;}State=-2;Builder.SetResult(result);}publicvoidSetStateMachine(IAsyncStateMachinemachine)=>Builder.SetStateMachine(machine);}
Порядок веток другой (у компилятора «первый вход» идёт первым, у нас — «возобновление»), но логика та же. Строки 46 и 56–57 — режимы эксперимента: аргумент командной строки выбирает, что будет ожидаться и бросится ли исключение.
Запуск в трёх режимах (из папки chapters: dotnet run -c Release --project 02-state-machine\final -- <режим>):
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).
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 на поток пула.
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).
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).
// §2.4–2.6: что компилятор делает со сложными конструкциями. Смотрим декомпиляцией:// .\tools\disasm.ps1 chapters\02-state-machine\start -Type Constructs -Mode cspublicstaticclassConstructs{// §2.4: какие локальные переменные становятся полями машины.publicstaticasyncTask<int>HoistAsync(intx){intbefore=x+1;// живёт через await → поле машиныinttemp=x*100;// используется только до await → обычная локальная MoveNextConsole.WriteLine(temp);awaitTask.Yield();returnbefore;}// §2.6: await в try, catch и finally.publicstaticasyncTask<string>TryCatchFinallyAsync(){try{awaitTask.Yield();thrownewInvalidOperationException("boom");}catch(InvalidOperationExceptione){awaitTask.Yield();// await в catch — с C# 6returne.Message;}finally{awaitTask.Yield();// await в finally — с C# 6}}// §2.6: await using — это try/finally с await DisposeAsync().publicstaticasyncTaskUsingAsync(){awaitusingvarresource=newResource();awaitTask.Yield();}// §2.6: Span<T> в async-методе разрешён с C# 13, если не живёт через await.publicstaticasyncTask<int>SpanAsync(int[]data){intsum=0;{Span<int>span=data;// ref struct: только до awaitforeach(intvinspan)sum+=v;}awaitTask.Yield();// Span<int> tooLate = data; await Task.Yield(); tooLate[0]++; // раскомментируйте: ошибка компиляцииreturnsum;}// §2.6: у async-лямбды своя машина состояний.publicstaticFunc<Task<int>>MakeLambda(intseed)=>async()=>{awaitTask.Yield();returnseed*2;};sealedclassResource:IAsyncDisposable{publicValueTaskDisposeAsync()=>ValueTask.CompletedTask;}}
usingSystem.Runtime.CompilerServices;// §2.7. Ручная «компиляция» Samples.CalcAsync.publicstaticclassManual{// Заглушка (stub): то, что компилятор оставляет на месте async-метода. Готова.publicstaticTask<int>CalcManualAsync(intx){varmachine=newCalcMachine{X=x,State=-1,Builder=AsyncTaskMethodBuilder<int>.Create(),};machine.Builder.Start(refmachine);returnmachine.Builder.Task;}}structCalcMachine:IAsyncStateMachine{publicintState;publicAsyncTaskMethodBuilder<int>Builder;publicintX;// параметрprivateint_local;// поднятая локальнаяprivateTaskAwaiter_awaiter;// awaiter между приостановкамиpublicvoidMoveNext(){// 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(newNotImplementedException("Реализуйте CalcMachine.MoveNext"));}publicvoidSetStateMachine(IAsyncStateMachinemachine)=>Builder.SetStateMachine(machine);}
// Настоящие 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, чтобы имена в декомпиляции были читаемыми.publicstaticclassSamples{// §2.2: простейший случай, один await.publicstaticasyncTask<int>GetValueAsync(){awaitTask.Delay(100);return42;}// §2.4: цикл с await, «поднятые» переменные.publicstaticasyncTask<int>SumAsync(int[]ids){intsum=0;foreach(varidinids)sum+=awaitLoadAsync(id);returnsum;}staticTask<int>LoadAsync(intid)=>Task.FromResult(id*10);// §2.7: этот метод мы «компилируем» руками в ManualMachine.cs.publicstaticasyncTask<int>CalcAsync(intx){intlocal=x*2;awaitTask.Delay(100);returnlocal+1;}}
// Глава 02, итог. Ручная машина состояний ведёт себя как настоящий async-метод.// Режимы: dotnet run -c Release -- delay | completed | throwManual.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{intr=awaitmanual;Console.WriteLine($"CalcManual(20) = {r}, thread {Environment.CurrentManagedThreadId}");}catch(InvalidOperationExceptione){Console.WriteLine($"исключение увидели только при await: {e.Message}");}
// §2.4–2.6: что компилятор делает со сложными конструкциями. Смотрим декомпиляцией:// .\tools\disasm.ps1 chapters\02-state-machine\start -Type Constructs -Mode cspublicstaticclassConstructs{// §2.4: какие локальные переменные становятся полями машины.publicstaticasyncTask<int>HoistAsync(intx){intbefore=x+1;// живёт через await → поле машиныinttemp=x*100;// используется только до await → обычная локальная MoveNextConsole.WriteLine(temp);awaitTask.Yield();returnbefore;}// §2.6: await в try, catch и finally.publicstaticasyncTask<string>TryCatchFinallyAsync(){try{awaitTask.Yield();thrownewInvalidOperationException("boom");}catch(InvalidOperationExceptione){awaitTask.Yield();// await в catch — с C# 6returne.Message;}finally{awaitTask.Yield();// await в finally — с C# 6}}// §2.6: await using — это try/finally с await DisposeAsync().publicstaticasyncTaskUsingAsync(){awaitusingvarresource=newResource();awaitTask.Yield();}// §2.6: Span<T> в async-методе разрешён с C# 13, если не живёт через await.publicstaticasyncTask<int>SpanAsync(int[]data){intsum=0;{Span<int>span=data;// ref struct: только до awaitforeach(intvinspan)sum+=v;}awaitTask.Yield();// Span<int> tooLate = data; await Task.Yield(); tooLate[0]++; // раскомментируйте: ошибка компиляцииreturnsum;}// §2.6: у async-лямбды своя машина состояний.publicstaticFunc<Task<int>>MakeLambda(intseed)=>async()=>{awaitTask.Yield();returnseed*2;};sealedclassResource:IAsyncDisposable{publicValueTaskDisposeAsync()=>ValueTask.CompletedTask;}}
usingSystem.Runtime.CompilerServices;// §2.7. Ручная «компиляция» Samples.CalcAsync.publicstaticclassManual{// Режим эксперимента: "delay" (по умолчанию), "completed" (await на завершённой задаче), "throw" (исключение).publicstaticstringMode="delay";publicstaticTask<int>CalcManualAsync(intx){varmachine=newCalcMachine{X=x,State=-1,Builder=AsyncTaskMethodBuilder<int>.Create(),};machine.Builder.Start(refmachine);// первый MoveNext() идёт синхронно, здесь жеreturnmachine.Builder.Task;}}structCalcMachine:IAsyncStateMachine{publicintState;publicAsyncTaskMethodBuilder<int>Builder;publicintX;// параметрprivateint_local;// поднятая локальнаяprivateTaskAwaiter_awaiter;// awaiter между приостановкамиpublicvoidMoveNext(){intresult;try{TaskAwaiterawaiter;if(State==0){// возобновление после awaitawaiter=_awaiter;_awaiter=default;State=-1;}else{_local=X*2;// код до awaitTaskawaited=Manual.Mode=="completed"?Task.CompletedTask:Task.Delay(100);awaiter=awaited.GetAwaiter();if(!awaiter.IsCompleted){State=0;_awaiter=awaiter;Builder.AwaitUnsafeOnCompleted(refawaiter,refthis);// здесь структура упаковывается в кучуreturn;}}if(Manual.Mode=="throw")thrownewInvalidOperationException("ошибка внутри MoveNext");awaiter.GetResult();result=_local+1;// return local + 1}catch(Exceptione){State=-2;Builder.SetException(e);return;}State=-2;Builder.SetResult(result);}publicvoidSetStateMachine(IAsyncStateMachinemachine)=>Builder.SetStateMachine(machine);}
// Настоящие 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, чтобы имена в декомпиляции были читаемыми.publicstaticclassSamples{// §2.2: простейший случай, один await.publicstaticasyncTask<int>GetValueAsync(){awaitTask.Delay(100);return42;}// §2.4: цикл с await, «поднятые» переменные.publicstaticasyncTask<int>SumAsync(int[]ids){intsum=0;foreach(varidinids)sum+=awaitLoadAsync(id);returnsum;}staticTask<int>LoadAsync(intid)=>Task.FromResult(id*10);// §2.7: этот метод мы «компилируем» руками в ManualMachine.cs.publicstaticasyncTask<int>CalcAsync(intx){intlocal=x*2;awaitTask.Delay(100);returnlocal+1;}}