Цель: понять, с чем на самом деле разговаривает await (awaiter) и кто связывает машину состояний с Task (builder); увидеть их работу изнутри и измерить, сколько памяти стоит вызов async-метода.
Лабораторная:start/ — заготовки своего awaitable (await TimeSpan, Switch.ToThreadPool). final/ — рабочие реализации, builder-«шпион» (§3.4) и замер аллокаций (§3.5). Код — в конце главы.
Как и Task, эти слова не переводим: это имена типов и понятий из документации, исходников рантайма и вывода отладчика.
awaiter — объект, который await получает из GetAwaiter(): через него узнаёт, готов ли результат, подписывается на завершение и забирает результат. В русских текстах встречаются «ожидатель» и «объект ожидания».
builder (async method builder) — тип вроде AsyncTaskMethodBuilder<T>, связывающий машину состояний с возвращаемой задачей. Название — от паттерна проектирования Builder («Строитель»): он «достраивает» результат метода, Task.
awaitable («ожидаемое») — выражение, к которому применим await, то есть у которого есть GetAwaiter().
Где без падежа фраза не строится, склоняем через апостроф: «у awaiter'а», «поле builder'а».
await работает не с Task как таковым, а с паттерном. От выражения expr в await expr компилятор требует:
Метод GetAwaiter() — экземплярный или метод расширения. Он возвращает awaiter.
У awaiter'а:
свойство bool IsCompleted;
метод GetResult() — возвращает результат await (или void);
реализация интерфейса INotifyCompletion с методом OnCompleted(Action), а лучше ещё и ICriticalNotifyCompletion с UnsafeOnCompleted(Action).
Это паттерн, а не интерфейс: отдельного IAwaitable нет. Поэтому GetAwaiter() можно добавить чему угодно методом расширения, и это что-то станет «ожидаемым».
Из одного await компилятор делает в MoveNext всегда одну и ту же конструкцию. Вот она для await Task.Delay(100) (код без сахара из машины состояний):
awaiter=Task.Delay(100).GetAwaiter();// 1. получить awaiterif(!awaiter.IsCompleted)// 2. уже готово?{num=(<>1__state=0);// 3. запомнить, где остановились<>u__1=awaiter;// и awaiter<>t__builder.AwaitUnsafeOnCompleted(refawaiter,refthis);// 4. подписаться через builderreturn;// 5. уйти}// ... при возобновлении: awaiter = <>u__1; <>u__1 = default; <>1__state = -1;awaiter.GetResult();// 6. забрать результат или исключение
Строки 1, 2, 10 — три члена паттерна: GetAwaiter(), IsCompleted, GetResult().
Строка 6 — четвёртый член вызывается не напрямую, а через builder (§3.4). Builder сам решит, звать OnCompleted или UnsafeOnCompleted awaiter'а и что передать в качестве продолжения.
Строка 38: GetAwaiter для TimeSpan просто возвращает awaiter задачи Task.Delay. Компилятор видит TimeSpan.FromMilliseconds(300L).GetAwaiter() и дальше работает с обычным TaskAwaiter.
// Похоже устроен Task.Yield(): awaiter с IsCompleted == false, который ставит продолжение в очередь.readonlystructSwitch:INotifyCompletion{publicstaticSwitchToThreadPool()=>default;publicSwitchGetAwaiter()=>this;publicboolIsCompleted=>Thread.CurrentThread.IsThreadPoolThread;publicvoidGetResult(){}publicvoidOnCompleted(Actioncontinuation)=>ThreadPool.QueueUserWorkItem(_=>continuation());}
Строка 45: структура сама себе awaiter: GetAwaiter() возвращает this.
Строка 46: «уже готово», если мы и так на потоке пула. Тогда await пройдёт без остановки.
Строка 47: результата нет, GetResult() ничего не возвращает.
Строка 48: продолжение ставится в очередь пула потоков.
Console.WriteLine($" до Switch: на потоке пула: {Thread.CurrentThread.IsThreadPoolThread}");awaitSwitch.ToThreadPool();Console.WriteLine($" после Switch: на потоке пула: {Thread.CurrentThread.IsThreadPoolThread}");
Задание
В start/Program.cs у TimeSpanAwaitExtensions.GetAwaiter и у Switch вместо тел стоит throw new NotImplementedException(). Допишите их и запустите из папки chapters: dotnet run -c Release --project 03-awaiters-builders\start. Пока заготовки не реализованы, программа печатает, какой член не готов.
Факт: для Switch компилятор вызывает другой метод builder'а
Switch реализует только INotifyCompletion, а TaskAwaiter — ещё и ICriticalNotifyCompletion. В декомпиляции Main этой программы видно, что компилятор выбирает метод builder'а по этому признаку:
awaiter3=TimeSpan.FromMilliseconds(300L).GetAwaiter();// TaskAwaiterif(!awaiter3.IsCompleted){num=(<>1__state=0);<>u__1=awaiter3;<>t__builder.AwaitUnsafeOnCompleted(refawaiter3,refthis);// ICriticalNotifyCompletion → Unsafereturn;}...awaiter2=Switch.ToThreadPool().GetAwaiter();// Switchif(!awaiter2.IsCompleted){num=(<>1__state=1);<>u__2=awaiter2;<>t__builder.AwaitOnCompleted(refawaiter2,refthis);// только INotifyCompletionreturn;}
Строки 6 и 15. Чем они отличаются — в §3.4. Ещё одна деталь: у Main три awaiter'а трёх типов (TaskAwaiter, Switch, TaskAwaiter<int>), и у машины три поля <>u__1, <>u__2, <>u__3 — по одному на тип.
Строки 1 и 3: awaiter — readonly struct с единственным полем — задачей. Он не аллоцирует.
Строка 5: IsCompleted просто спрашивает задачу. В машинном коде это одна проверка битов в поле состояния Task.
Строки 22–26, 28–36: GetResult() на быстром пути (задача успешно завершена) ничего не делает: одна проверка флага в строке 32.
Строки 38–55: медленный путь. Если задача не завершена — блокирующее ожидание (строки 42–45: так работает task.GetAwaiter().GetResult() из синхронного кода). Если задача неуспешна, бросить исключение, если только не задан SuppressThrowing (.NET 8, глава 5).
Строки 57–75: ThrowForNonSuccess. При отмене — TaskCanceledException (строки 63–64). При ошибке — первое исключение задачи, переброшенное через ExceptionDispatchInfo с исходным стеком (строка 70). Поэтому await не оборачивает исключение в AggregateException и отдаёт только первое из нескольких (глава 8).
Строки 12–20:OnCompleted и UnsafeOnCompleted различаются одним флагом — flowExecutionContext (строки 14 и 19). «Безопасный» вариант сам захватывает ExecutionContext и восстанавливает его перед вызовом продолжения. «Небезопасный» этого не делает, предполагая, что этим займётся вызывающий.
Строки 88–98: UnsafeOnCompletedInternal — путь, которым пользуется builder. В обычном режиме (без трассировки и async-отладки) он передаёт задаче сам бокс машины (строка 96), а не делегат. Отдельного объекта-продолжения не создаётся.
[StackTraceHidden] (строки 22, 29, 39, 57): эти методы не показываются в стеке исключения. Поэтому в трассировке после await вы видите свой код, а не внутренности awaiter'а.
Builder (async method builder) — публичный тип, через который машина состояний работает со своей задачей. Компилятор выбирает его по возвращаемому типу метода:
Проще всего увидеть, кто кого вызывает, — подменить builder. С C# 10 атрибут [AsyncMethodBuilder] можно повесить на отдельный метод, и компилятор возьмёт для него указанный builder. Наш LoggingBuilder<T> делает всё то же, что стандартный (передаёт вызовы AsyncTaskMethodBuilder<T>), но печатает каждый вызов:
publicstructLoggingBuilder<T>{privateAsyncTaskMethodBuilder<T>_inner;// настоящая работа — у стандартного builder'аpublicstaticLoggingBuilder<T>Create(){Trace.Log("builder.Create()");returnnewLoggingBuilder<T>{_inner=AsyncTaskMethodBuilder<T>.Create()};}publicvoidStart<TStateMachine>(refTStateMachinestateMachine)whereTStateMachine:IAsyncStateMachine{Trace.Log($"builder.Start() → MoveNext(), state машины = {State(stateMachine)}");_inner.Start(refstateMachine);Trace.Log("builder.Start() вернулся в заглушку");}
publicvoidAwaitUnsafeOnCompleted<TAwaiter,TStateMachine>(refTAwaiterawaiter,refTStateMachinestateMachine)whereTAwaiter:ICriticalNotifyCompletionwhereTStateMachine:IAsyncStateMachine{Trace.Log($"builder.AwaitUnsafeOnCompleted({typeof(TAwaiter).Name}), state = {State(stateMachine)}");_inner.AwaitUnsafeOnCompleted(refawaiter,refstateMachine);}
Строки 7, 12, 18, 43: внутри лежит настоящий builder, вся работа передаётся ему. Метод, к которому подключён шпион, и место вызова:
[AsyncMethodBuilder(typeof(LoggingBuilder<>))]staticasyncTask<int>TracedAsync(){Trace.Log("тело: до первого await");awaitTask.CompletedTask;// уже завершён: builder не понадобитсяTrace.Log("тело: после await Task.CompletedTask");awaitTask.Delay(100);// настоящая приостановкаTrace.Log("тело: после await Task.Delay");return42;}
[поток 5] вызываю TracedAsync()
[поток 5] builder.Create()
[поток 5] builder.Start() → MoveNext(), state машины = -1
[поток 5] тело: до первого await
[поток 5] тело: после await Task.CompletedTask
[поток 5] builder.AwaitUnsafeOnCompleted(TaskAwaiter), state = 1
[поток 5] builder.Start() вернулся в заглушку
[поток 5] builder.Task → AsyncStateMachineBox`1, Status=WaitingForActivation
[поток 5] TracedAsync() вернул задачу, IsCompleted=False
[поток 5] тело: после await Task.Delay
[поток 5] builder.SetResult(42)
[поток 5] await → 42
Строки 2–3: заглушка вызвала Create и Start. Start синхронно вызвал MoveNext с состоянием -1.
Строки 4–5: первый await (строка 29 исходника) — на Task.CompletedTask. Он завершён, поэтому builder не вызывался вовсе: между строками 4 и 5 вывода нет ни одного обращения к builder'у.
Строка 6: второй await (строка 31 исходника) — Task.Delay не готов, вызывается AwaitUnsafeOnCompleted (awaiter TaskAwaiter реализует ICriticalNotifyCompletion). Состояние уже 1: компилятор пронумеровал await по порядку (0 — CompletedTask, 1 — Delay) и записал номер до вызова builder'а.
Строки 7–8:Start вернулся, заглушка спросила Task. Это уже бокс машины (AsyncStateMachineBox1) — builder создал его внутриAwaitUnsafeOnCompleted`.
Строки 10–11: через 100 мс тело продолжилось, и перед этим builder не вызывался. Возобновление идёт мимо builder'а: бокс сам вызывает MoveNext своей копии машины. Builder снова появляется только в SetResult.
Факт: возобновление не проходит через builder
Builder участвует в запуске (Create, Start, Task), в подписке (AwaitUnsafeOnCompleted) и в завершении (SetResult/SetException). Повторный вход в MoveNext после await делает бокс напрямую. Поэтому, если вам нужно «перехватить каждое возобновление», свой builder не поможет. Это задача awaiter'а (своё OnCompleted) или SynchronizationContext (глава 5).
publicvoidAwaitUnsafeOnCompleted<TAwaiter,TStateMachine>(refTAwaiterawaiter,refTStateMachinestateMachine)whereTAwaiter:ICriticalNotifyCompletionwhereTStateMachine:IAsyncStateMachine{AwaitUnsafeOnCompleted(refawaiter,refstateMachine,refm_task);}internalstaticvoidAwaitUnsafeOnCompleted<TAwaiter,TStateMachine>(refTAwaiterawaiter,refTStateMachinestateMachine,[NotNull]refTask<TResult>taskField)whereTAwaiter:ICriticalNotifyCompletionwhereTStateMachine:IAsyncStateMachine{IAsyncStateMachineBoxstateMachineBox=GetStateMachineBox(refstateMachine,reftaskField);AwaitUnsafeOnCompleted(refawaiter,stateMachineBox);}internalstaticvoidAwaitUnsafeOnCompleted<TAwaiter>(refTAwaiterawaiter,IAsyncStateMachineBoxbox)whereTAwaiter:ICriticalNotifyCompletion{TAwaiterval=default;if(val!=null&&awaiterisITaskAwaiter){TaskAwaiter.UnsafeOnCompletedInternal(Unsafe.As<TAwaiter,TaskAwaiter>(refawaiter).m_task,box,continueOnCapturedContext:true);return;}val=default;if(val!=null&&awaiterisIConfiguredTaskAwaiter){refConfiguredTaskAwaitable.ConfiguredTaskAwaiterreference=refUnsafe.As<TAwaiter,ConfiguredTaskAwaitable.ConfiguredTaskAwaiter>(refawaiter);TaskAwaiter.UnsafeOnCompletedInternal(reference.m_task,box,(reference.m_options&ConfigureAwaitOptions.ContinueOnCapturedContext)!=0);return;}val=default;if(val!=null&&awaiterisIStateMachineBoxAwareAwaiter){try{((IStateMachineBoxAwareAwaiter)(object)awaiter).AwaitUnsafeOnCompleted(box);return;}catch(Exceptionexception){System.Threading.Tasks.Task.ThrowAsync(exception,null);return;}}try{ActionmoveNextAction=box.MoveNextAction;awaiter.UnsafeOnCompleted(moveNextAction);}catch(Exceptionexception2){System.Threading.Tasks.Task.ThrowAsync(exception2,null);}}// А вот «безопасный» AwaitOnCompleted (для awaiter'ов только с INotifyCompletion), тело:// awaiter.OnCompleted(GetStateMachineBox(ref stateMachine, ref taskField).MoveNextAction);
Последний try (строки 45–53) немного упрощён: ILSpy выводит там служебную копию awaiter'а для ссылочных типов, логика та же.
Строка 11: GetStateMachineBox — при первой приостановке создаёт бокс в куче и копирует в него машину, при следующих возвращает тот же бокс. Подробности ниже, во врезке.
Строки 18–23: быстрый путь для TaskAwaiter. Условие default(TAwaiter) != null && awaiter is ITaskAwaiter выглядит странно, но это приём для JIT. Метод generic, и для каждого типа-структуры TAwaiter JIT компилирует отдельную версию. В ней это условие — константа, и лишние ветки выбрасываются целиком. Для TaskAwaiter от метода остаются строки 21–22: передать задаче сам бокс как продолжение.
Строки 24–30: то же для ConfigureAwait(…) — учитывается флаг «возвращаться ли в контекст» (глава 5).
Строки 31–44: awaiter'ы, которые знают про бокс (IStateMachineBoxAwareAwaiter, внутренний интерфейс рантайма): YieldAwaitable, awaiter'ы ValueTask и т. п. Им тоже передаётся бокс без делегата.
Строки 45–53: все остальные awaiter'ы, в том числе ваши. Для них берётся box.MoveNextAction — делегат Action, который при первом обращении создаётся и кешируется в боксе. Это ещё одна аллокация по сравнению со встроенными awaiter'ами.
Строки 56–57:AwaitOnCompleted («безопасный», для Switch) всегда идёт через делегат и вызывает OnCompleted awaiter'а. А тот сам захватывает и восстанавливает ExecutionContext, хотя бокс уже хранит свой. Это лишняя работа. Поэтому свой awaiter лучше делать с ICriticalNotifyCompletion.
Напоминание: что такое бокс машины и как его создаёт GetStateMachineBox
Машина состояний в Release — структура, и пока метод не приостановился, она живёт на стеке заглушки. При первой реальной приостановке builder создаёт в куче объект класса AsyncTaskMethodBuilder<TResult>.AsyncStateMachineBox<TStateMachine>. Это наследник Task<TResult> с полем StateMachine (копия вашей структуры) и полем для захваченного ExecutionContext. Объект записывается в поле m_task builder'а, поэтому builder.Task возвращает именно его. Второй и следующие await того же вызова переиспользуют этот объект: проверка taskField is AsyncStateMachineBox<TStateMachine> возвращает существующий бокс. Если метод ни разу не приостановился, бокса нет. Полные листинги класса бокса и GetStateMachineBox и опыт, где бокс виден через отражение, — в главе 1, §1.4.
Task.Yield() — пример встроенного awaiter'а, который знает про бокс. Его awaiter YieldAwaitable.YieldAwaiter из CoreLib 10.0.12 (без кода трассировки):
staticvoidMeasure(stringtitle,Func<Task<int>,Task>call){// У каждого вызова своя незавершённая задача: если подписать несколько вызовов на одну,// со второго подписчика задача заводит список продолжений, и его аллокация испортит замер.call(newTaskCompletionSource<int>().Task);// прогрев: JIT и статические поля аллоцируют самиTask<int>pending=newTaskCompletionSource<int>().Task;longbefore=GC.GetAllocatedBytesForCurrentThread();Tasktask=call(pending);longbytes=GC.GetAllocatedBytesForCurrentThread()-before;Console.WriteLine($" {title,-36}: {bytes,4} байт, {task.GetType().Name}");}#pragma warning disable CS1998 // async без await: нарочноstaticasyncTaskVoidSync(){}staticasyncTask<int>IntSync(intvalue)=>value;#pragma warning restore CS1998staticasyncTask<int>Suspend(Task<int>task)=>awaittask;staticasyncTask<long>FatSuspend(Task<int>task){longa=1,b=2,c=3,d=4,e=5,f=6,g=7,h=8;// все нужны после await → поля машиныintr=awaittask;returna+b+c+d+e+f+g+h+r;}
Строки 18–19: первый вызов — прогрев: при первом вызове JIT и статические инициализаторы аллоцируют сами и испортили бы замер.
Строки 20–22:GC.GetAllocatedBytesForCurrentThread() до и после второго вызова — точное число байт, выделенных этим потоком.
Строки 16–17 и 19: у каждого вызова своя незавершённая задача (TaskCompletionSource, без таймеров и потоков). Почему это важно, см. «Факт» ниже.
Строки 33–38: восемь long, которые нужны после await, становятся полями машины.
Строка 1: async Task без паузы — 0 байт. Машина осталась на стеке, бокса нет, а SetResult() отдал общий закешированный завершённый Task.
Строка 2: Task<int> со значением 1 — тоже 0 байт.SetResult(result) при m_task == null вызывает Task.FromResult(result), а у того есть кеш (листинг ниже).
Строка 3: значение 1000 — 72 байта. Вне кеша создаётся новый Task<int>: 72 байта на x64.
Строка 4: с паузой — 104 байта. Это бокс: Task<int> плюс встроенная в него машина (состояние, builder, параметр-задача, awaiter).
Строка 5: восемь лишних полей long — ровно +64 байта (168 − 104 = 8 × 8). Каждое поднятое значение увеличивает бокс.
publicstaticTask<TResult>FromResult<TResult>(TResultresult){if(result==null)returnTask<TResult>.s_defaultResultTask;if(typeof(TResult)==typeof(bool))return(Task<TResult>)(object)(result?TaskCache.s_trueTask:TaskCache.s_falseTask);if(typeof(TResult)==typeof(int)){intnum=(int)(object)result;if((uint)(num--1)<10u)return(Task<TResult>)(object)TaskCache.s_int32Tasks[num--1];}elseif(/* TResult — примитив без ссылок, и значение — нули */)returnTask<TResult>.s_defaultResultTask;returnnewTask<TResult>(result);}internalstaticclassTaskCache{internalstaticreadonlyTask<int>[]s_int32Tasks=CreateInt32Tasks();// 10 задач: от -1 до 8...}
Листинг переписан короче: в оригинале арифметика с указателями, логика та же.
Строки 3–4:null — общая задача.
Строки 5–6:Task<bool> — две общие задачи.
Строки 7–12:Task<int> со значениями от −1 до 8 — из массива на 10 элементов (строка 20).
Строки 13–14: нулевое значение любого «простого» типа (long, double, Guid…) — общая задача.
Строка 15: во всех остальных случаях — новая задача.
Факт: первая версия замера показала, что 9 полей «дешевле» одного
В первом варианте опыта все вызовы подписывались на одну и ту же незавершённую задачу. Получилось 192 байта для одного поля и 168 для девяти. Причина: первое продолжение задача хранит прямо в своём поле, а со второго подписчика заводит список продолжений, и его аллокация попала в замер второго вызова. Когда у каждого вызова своя задача (строки 16–19), цифры стали честными: 104 и 168. Мораль: измеряя аллокации, следите, что именно вы измеряете. Как устроен список продолжений Task — в главе 4.
Только Release
В Debug машина — класс, и заглушка создаёт её через newпри каждом вызове, даже без паузы. Замер в Debug покажет аллокацию и там, где в Release её нет.
3.6. Где выполняется продолжение и почему стек «рваный»¶
Две особенности, из-за которых отладка async-кода поначалу кажется странной:
Первый MoveNext выполняется внутри вызова заглушки. Пока нет незавершённого await, метод работает синхронно на потоке вызывающего (в выводе шпиона строки 2–7 — всё ещё внутри TracedAsync()).
После возобновления MoveNext вызывается из стека того, кто завершил задачу: колбэка таймера, обработчика ввода-вывода, чужого SetResult или рабочего элемента пула. В стеке вызовов в этот момент нет вашего вызывающего метода: он давно вернулся. Отладчики восстанавливают логическую цепочку отдельно (окно «Parallel Stacks» → Tasks, async call stack в Visual Studio и Rider). Исключения склеивают куски стека через ExceptionDispatchInfo, отсюда строки --- End of stack trace from previous location --- (глава 8).
await требует паттерн: GetAwaiter() → IsCompleted, GetResult(), OnCompleted (лучше и UnsafeOnCompleted). Интерфейса «awaitable» нет, GetAwaiter может быть методом расширения.
Подписку на продолжение компилятор делает через builder: AwaitUnsafeOnCompleted для awaiter'ов с ICriticalNotifyCompletion, AwaitOnCompleted для остальных.
Builder выбирается по возвращаемому типу; с C# 10 — и атрибутом на методе. Он участвует в запуске, подписке и завершении, но не в возобновлении.
Для встроенных awaiter'ов (TaskAwaiter, ConfiguredTaskAwaiter, Yield, ValueTask) builder передаёт продолжением сам бокс. Для чужих — делегат MoveNextAction.
Синхронное завершение почти бесплатно: 0 байт для async Task, для кешированных значений bool и int от −1 до 8. Реальная приостановка — один бокс (от ~100 байт, растёт с числом поднятых значений).
// Глава 03, §3.2. Свой awaitable.// Задание 1: допишите TimeSpanAwaitExtensions.GetAwaiter, чтобы работал await TimeSpan.// Задание 2: допишите Switch, чтобы после await код оказывался на потоке пула.// Пока заготовки бросают NotImplementedException.usingSystem.Runtime.CompilerServices;try{awaitTimeSpan.FromMilliseconds(300);Console.WriteLine("прошло 300 мс");}catch(NotImplementedExceptione){Console.WriteLine($"1) {e.Message}");}try{Console.WriteLine($"до Switch: на потоке пула: {Thread.CurrentThread.IsThreadPoolThread}");awaitSwitch.ToThreadPool();Console.WriteLine($"после Switch: на потоке пула: {Thread.CurrentThread.IsThreadPoolThread}");}catch(NotImplementedExceptione){Console.WriteLine($"2) {e.Message}");}staticclassTimeSpanAwaitExtensions{// TODO: верните awaiter задачи Task.Delay(delay).publicstaticTaskAwaiterGetAwaiter(thisTimeSpandelay)=>thrownewNotImplementedException("TimeSpanAwaitExtensions.GetAwaiter");}readonlystructSwitch:INotifyCompletion{publicstaticSwitchToThreadPool()=>default;publicSwitchGetAwaiter()=>this;// сам себе awaiter// TODO: «уже на месте», если текущий поток — поток пула.publicboolIsCompleted=>thrownewNotImplementedException("Switch.IsCompleted");publicvoidGetResult(){}// TODO: поставьте continuation в очередь пула потоков.publicvoidOnCompleted(Actioncontinuation)=>thrownewNotImplementedException("Switch.OnCompleted");}
// Глава 3, итог.// 1) await работает по паттерну, а не по интерфейсу: свой awaitable (§3.2);// 2) builder-«шпион» показывает, какие вызовы делает машина состояний (§3.4);// 3) сколько байт стоит вызов async-метода с паузой и без (§3.5).usingSystem.Runtime.CompilerServices;Console.WriteLine("== 1. Свой awaitable ==");awaitTimeSpan.FromMilliseconds(300);Console.WriteLine(" прошло 300 мс");Console.WriteLine($" до Switch: на потоке пула: {Thread.CurrentThread.IsThreadPoolThread}");awaitSwitch.ToThreadPool();Console.WriteLine($" после Switch: на потоке пула: {Thread.CurrentThread.IsThreadPoolThread}");Console.WriteLine();Console.WriteLine("== 2. Builder-шпион ==");Trace.Log("вызываю TracedAsync()");Task<int>traced=TracedAsync();Trace.Log($"TracedAsync() вернул задачу, IsCompleted={traced.IsCompleted}");Trace.Log($"await → {await traced}");Console.WriteLine();Console.WriteLine("== 3. Аллокации одного вызова ==");Allocations.Run();[AsyncMethodBuilder(typeof(LoggingBuilder<>))]staticasyncTask<int>TracedAsync(){Trace.Log("тело: до первого await");awaitTask.CompletedTask;// уже завершён: builder не понадобитсяTrace.Log("тело: после await Task.CompletedTask");awaitTask.Delay(100);// настоящая приостановкаTrace.Log("тело: после await Task.Delay");return42;}staticclassTimeSpanAwaitExtensions{publicstaticTaskAwaiterGetAwaiter(thisTimeSpandelay)=>Task.Delay(delay).GetAwaiter();}// Похоже устроен Task.Yield(): awaiter с IsCompleted == false, который ставит продолжение в очередь.readonlystructSwitch:INotifyCompletion{publicstaticSwitchToThreadPool()=>default;publicSwitchGetAwaiter()=>this;publicboolIsCompleted=>Thread.CurrentThread.IsThreadPoolThread;publicvoidGetResult(){}publicvoidOnCompleted(Actioncontinuation)=>ThreadPool.QueueUserWorkItem(_=>continuation());}
usingSystem.Runtime.CompilerServices;// §3.4. Builder-«шпион»: делает всё то же, что AsyncTaskMethodBuilder<T>, но печатает каждый вызов.// Подключается к одному методу атрибутом [AsyncMethodBuilder(typeof(LoggingBuilder<>))] (C# 10).publicstructLoggingBuilder<T>{privateAsyncTaskMethodBuilder<T>_inner;// настоящая работа — у стандартного builder'аpublicstaticLoggingBuilder<T>Create(){Trace.Log("builder.Create()");returnnewLoggingBuilder<T>{_inner=AsyncTaskMethodBuilder<T>.Create()};}publicvoidStart<TStateMachine>(refTStateMachinestateMachine)whereTStateMachine:IAsyncStateMachine{Trace.Log($"builder.Start() → MoveNext(), state машины = {State(stateMachine)}");_inner.Start(refstateMachine);Trace.Log("builder.Start() вернулся в заглушку");}publicTask<T>Task{get{Task<T>task=_inner.Task;Trace.Log($"builder.Task → {task.GetType().Name}, Status={task.Status}");returntask;}}publicvoidAwaitOnCompleted<TAwaiter,TStateMachine>(refTAwaiterawaiter,refTStateMachinestateMachine)whereTAwaiter:INotifyCompletionwhereTStateMachine:IAsyncStateMachine{Trace.Log($"builder.AwaitOnCompleted({typeof(TAwaiter).Name}), state = {State(stateMachine)}");_inner.AwaitOnCompleted(refawaiter,refstateMachine);}publicvoidAwaitUnsafeOnCompleted<TAwaiter,TStateMachine>(refTAwaiterawaiter,refTStateMachinestateMachine)whereTAwaiter:ICriticalNotifyCompletionwhereTStateMachine:IAsyncStateMachine{Trace.Log($"builder.AwaitUnsafeOnCompleted({typeof(TAwaiter).Name}), state = {State(stateMachine)}");_inner.AwaitUnsafeOnCompleted(refawaiter,refstateMachine);}publicvoidSetResult(Tresult){Trace.Log($"builder.SetResult({result})");_inner.SetResult(result);}publicvoidSetException(Exceptionexception){Trace.Log($"builder.SetException({exception.GetType().Name})");_inner.SetException(exception);}publicvoidSetStateMachine(IAsyncStateMachinestateMachine){Trace.Log("builder.SetStateMachine()");_inner.SetStateMachine(stateMachine);}// Номер состояния машины: поле <>1__state сгенерированной структуры.privatestaticintState<TStateMachine>(TStateMachinestateMachine)=>(int)typeof(TStateMachine).GetField("<>1__state")!.GetValue(stateMachine)!;}publicstaticclassTrace{publicstaticvoidLog(stringtext)=>Console.WriteLine($" [поток {Environment.CurrentManagedThreadId,2}] {text}");}
// §3.5. Сколько байт в куче стоит вызов async-метода. Меряем только сам вызов (до возврата Task),// поэтому продолжения на других потоках в замер не попадают.publicstaticclassAllocations{publicstaticvoidRun(){Measure("async Task, без паузы",_=>VoidSync());Measure("async Task<int> = 1, без паузы",_=>IntSync(1));Measure("async Task<int> = 1000, без паузы",_=>IntSync(1000));Measure("async Task<int>, пауза, 1 поле",Suspend);Measure("async Task<long>, пауза, 9 полей",FatSuspend);}staticvoidMeasure(stringtitle,Func<Task<int>,Task>call){// У каждого вызова своя незавершённая задача: если подписать несколько вызовов на одну,// со второго подписчика задача заводит список продолжений, и его аллокация испортит замер.call(newTaskCompletionSource<int>().Task);// прогрев: JIT и статические поля аллоцируют самиTask<int>pending=newTaskCompletionSource<int>().Task;longbefore=GC.GetAllocatedBytesForCurrentThread();Tasktask=call(pending);longbytes=GC.GetAllocatedBytesForCurrentThread()-before;Console.WriteLine($" {title,-36}: {bytes,4} байт, {task.GetType().Name}");}#pragma warning disable CS1998 // async без await: нарочноstaticasyncTaskVoidSync(){}staticasyncTask<int>IntSync(intvalue)=>value;#pragma warning restore CS1998staticasyncTask<int>Suspend(Task<int>task)=>awaittask;staticasyncTask<long>FatSuspend(Task<int>task){longa=1,b=2,c=3,d=4,e=5,f=6,g=7,h=8;// все нужны после await → поля машиныintr=awaittask;returna+b+c+d+e+f+g+h+r;}}