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.
С нуля на новой машине:
- Строка 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
{
// ① заглушка: то, что осталось на месте метода. Уже без 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 ищет манифест от текущей папки вверх):
Если глобальная конфигурация 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);
}
}
- Запомнить контексты потока до запуска метода.
- Первый шаг метода — синхронно, на текущем потоке, до первого незавершённого
await. - Что бы метод ни сделал с контекстами, вызывающий получает поток в прежнем состоянии.
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: отдельное окружение 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–12 файла chapters/02-state-machine/start/Samples.cs. Номера строк в листинге книги задаются тем же числом (linenums="8"), поэтому совпадают с файлом.