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

3. Awaiter и builder

О главе

Цель: понять, с чем на самом деле разговаривает await (awaiter) и кто связывает машину состояний с Task (builder); увидеть их работу изнутри и измерить, сколько памяти стоит вызов async-метода.

Лабораторная: start/ — заготовки своего awaitable (await TimeSpan, Switch.ToThreadPool). final/ — рабочие реализации, builder-«шпион» (§3.4) и замер аллокаций (§3.5). Код — в конце главы.

Статус: ✅ проверено на Windows 11, рантайм 10.0.12. Листинги CoreLib — декомпиляция System.Private.CoreLib 10.0.12 (.\tools\disasm.ps1 -Assembly corelib -Type …), листинги лабораторной — .\tools\disasm.ps1 chapters\03-awaiters-builders\final -Type Program.

Термины

Как и Task, эти слова не переводим: это имена типов и понятий из документации, исходников рантайма и вывода отладчика.

  • awaiter — объект, который await получает из GetAwaiter(): через него узнаёт, готов ли результат, подписывается на завершение и забирает результат. В русских текстах встречаются «ожидатель» и «объект ожидания».
  • builder (async method builder) — тип вроде AsyncTaskMethodBuilder<T>, связывающий машину состояний с возвращаемой задачей. Название — от паттерна проектирования Builder («Строитель»): он «достраивает» результат метода, Task.
  • awaitable («ожидаемое») — выражение, к которому применим await, то есть у которого есть GetAwaiter().

Где без падежа фраза не строится, склоняем через апостроф: «у awaiter'а», «поле builder'а».

3.1. Что на самом деле можно await

await работает не с Task как таковым, а с паттерном. От выражения expr в await expr компилятор требует:

  1. Метод GetAwaiter() — экземплярный или метод расширения. Он возвращает awaiter.
  2. У awaiter'а:
    • свойство bool IsCompleted;
    • метод GetResult() — возвращает результат await (или void);
    • реализация интерфейса INotifyCompletion с методом OnCompleted(Action), а лучше ещё и ICriticalNotifyCompletion с UnsafeOnCompleted(Action).

Это паттерн, а не интерфейс: отдельного IAwaitable нет. Поэтому GetAwaiter() можно добавить чему угодно методом расширения, и это что-то станет «ожидаемым».

Из одного await компилятор делает в MoveNext всегда одну и ту же конструкцию. Вот она для await Task.Delay(100) (код без сахара из машины состояний):

Во что превращается await (из MoveNext)
awaiter = Task.Delay(100).GetAwaiter();                       // 1. получить awaiter
if (!awaiter.IsCompleted)                                     // 2. уже готово?
{
    num = (<>1__state = 0);                                   // 3. запомнить, где остановились
    <>u__1 = awaiter;                                         //    и awaiter
    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);   // 4. подписаться через builder
    return;                                                   // 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'а и что передать в качестве продолжения.

3.2. Свой awaitable

await TimeSpan

Раз GetAwaiter() может быть методом расширения, «ожидать» можно и TimeSpan:

final/Program.cs
7
8
9
Console.WriteLine("== 1. Свой awaitable ==");
await TimeSpan.FromMilliseconds(300);
Console.WriteLine("  прошло 300 мс");
final/Program.cs
static class TimeSpanAwaitExtensions
{
    public static TaskAwaiter GetAwaiter(this TimeSpan delay) => Task.Delay(delay).GetAwaiter();
}

Строка 38: GetAwaiter для TimeSpan просто возвращает awaiter задачи Task.Delay. Компилятор видит TimeSpan.FromMilliseconds(300L).GetAwaiter() и дальше работает с обычным TaskAwaiter.

Свой awaiter: Switch.ToThreadPool()

Более честный пример — awaiter, написанный с нуля. Его задача: после await оказаться на потоке пула.

final/Program.cs
// Похоже устроен Task.Yield(): awaiter с IsCompleted == false, который ставит продолжение в очередь.
readonly struct Switch : INotifyCompletion
{
    public static Switch ToThreadPool() => default;
    public Switch GetAwaiter() => this;
    public bool IsCompleted => Thread.CurrentThread.IsThreadPoolThread;
    public void GetResult() { }
    public void OnCompleted(Action continuation) => ThreadPool.QueueUserWorkItem(_ => continuation());
}
  • Строка 45: структура сама себе awaiter: GetAwaiter() возвращает this.
  • Строка 46: «уже готово», если мы и так на потоке пула. Тогда await пройдёт без остановки.
  • Строка 47: результата нет, GetResult() ничего не возвращает.
  • Строка 48: продолжение ставится в очередь пула потоков.

Используется так:

final/Program.cs
Console.WriteLine($"  до Switch: на потоке пула: {Thread.CurrentThread.IsThreadPoolThread}");
await Switch.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();          // TaskAwaiter
if (!awaiter3.IsCompleted)
{
    num = (<>1__state = 0);
    <>u__1 = awaiter3;
    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter3, ref this);   // ICriticalNotifyCompletion → Unsafe
    return;
}
...
awaiter2 = Switch.ToThreadPool().GetAwaiter();                    // Switch
if (!awaiter2.IsCompleted)
{
    num = (<>1__state = 1);
    <>u__2 = awaiter2;
    <>t__builder.AwaitOnCompleted(ref awaiter2, ref this);         // только INotifyCompletion
    return;
}

Строки 6 и 15. Чем они отличаются — в §3.4. Ещё одна деталь: у Main три awaiter'а трёх типов (TaskAwaiter, Switch, TaskAwaiter<int>), и у машины три поля <>u__1, <>u__2, <>u__3 — по одному на тип.

3.3. TaskAwaiter изнутри

Самый частый awaiter — TaskAwaiter, его возвращает Task.GetAwaiter(). Вот он из CoreLib 10.0.12 (без XML-комментариев):

CoreLib 10.0.12: TaskAwaiter
public readonly struct TaskAwaiter : ICriticalNotifyCompletion, INotifyCompletion, ITaskAwaiter
{
    internal readonly Task m_task;

    public bool IsCompleted => m_task.IsCompleted;

    internal TaskAwaiter(Task task)
    {
        m_task = task;
    }

    public void OnCompleted(Action continuation)
    {
        OnCompletedInternal(m_task, continuation, continueOnCapturedContext: true, flowExecutionContext: true);
    }

    public void UnsafeOnCompleted(Action continuation)
    {
        OnCompletedInternal(m_task, continuation, continueOnCapturedContext: true, flowExecutionContext: false);
    }

    [StackTraceHidden]
    public void GetResult()
    {
        ValidateEnd(m_task);
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    [StackTraceHidden]
    internal static void ValidateEnd(Task task, ConfigureAwaitOptions options = ConfigureAwaitOptions.None)
    {
        if (task.IsWaitNotificationEnabledOrNotRanToCompletion)
        {
            HandleNonSuccessAndDebuggerNotification(task, options);
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    [StackTraceHidden]
    private static void HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
    {
        if (!task.IsCompleted)
        {
            task.InternalWait(-1, default);
        }
        task.NotifyDebuggerOfWaitCompletionIfNecessary();
        if (!task.IsCompletedSuccessfully)
        {
            if ((options & ConfigureAwaitOptions.SuppressThrowing) == 0)
            {
                ThrowForNonSuccess(task);
            }
            task.MarkExceptionsAsHandled();
        }
    }

    [StackTraceHidden]
    private static void ThrowForNonSuccess(Task task)
    {
        switch (task.Status)
        {
        case TaskStatus.Canceled:
            task.GetCancellationExceptionDispatchInfo()?.Throw();
            throw new TaskCanceledException(task);
        case TaskStatus.Faulted:
        {
            List<ExceptionDispatchInfo> exceptionDispatchInfos = task.GetExceptionDispatchInfos();
            if (exceptionDispatchInfos.Count > 0)
            {
                exceptionDispatchInfos[0].Throw();
                break;
            }
            throw task.Exception;
        }
        }
    }

    internal static void OnCompletedInternal(Task task, Action continuation, bool continueOnCapturedContext, bool flowExecutionContext)
    {
        ArgumentNullException.ThrowIfNull(continuation, "continuation");
        if (TplEventSource.Log.IsEnabled() || Task.s_asyncDebuggingEnabled)
        {
            continuation = OutputWaitEtwEvents(task, continuation);
        }
        task.SetContinuationForAwait(continuation, continueOnCapturedContext, flowExecutionContext);
    }

    internal static void UnsafeOnCompletedInternal(Task task, IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext)
    {
        if (TplEventSource.Log.IsEnabled() || Task.s_asyncDebuggingEnabled)
        {
            task.SetContinuationForAwait(OutputWaitEtwEvents(task, stateMachineBox.MoveNextAction), continueOnCapturedContext, flowExecutionContext: false);
        }
        else
        {
            task.UnsafeSetContinuationForAwait(stateMachineBox, continueOnCapturedContext);
        }
    }
}
  • Строки 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'а.

3.4. Builder: связь машины с Task

Builder (async method builder) — публичный тип, через который машина состояний работает со своей задачей. Компилятор выбирает его по возвращаемому типу метода:

Возвращаемый тип Builder
Task AsyncTaskMethodBuilder (внутри — AsyncTaskMethodBuilder<VoidTaskResult>)
Task<T> AsyncTaskMethodBuilder<T>
ValueTask / ValueTask<T> AsyncValueTaskMethodBuilder / <T>
void AsyncVoidMethodBuilder
свой тип с [AsyncMethodBuilder(typeof(…))] указанный в атрибуте
любой из перечисленных, но метод помечен [AsyncMethodBuilder(typeof(…))] (C# 10) указанный на методе

Что builder должен уметь (это тоже паттерн, а не интерфейс):

Член Кто и когда вызывает
static Create() заглушка, до запуска машины
Start(ref machine) заглушка: запускает первый MoveNext синхронно
Task (свойство) заглушка: вернуть задачу вызывающему
AwaitUnsafeOnCompleted(ref awaiter, ref machine) MoveNext, await на незавершённом awaiter'е с ICriticalNotifyCompletion
AwaitOnCompleted(ref awaiter, ref machine) то же для awaiter'а только с INotifyCompletion
SetResult(result) MoveNext, return
SetException(exception) MoveNext, необработанное исключение
SetStateMachine(machine) исторически — при упаковке машины; современный рантайм его не вызывает

Builder-«шпион»

Проще всего увидеть, кто кого вызывает, — подменить builder. С C# 10 атрибут [AsyncMethodBuilder] можно повесить на отдельный метод, и компилятор возьмёт для него указанный builder. Наш LoggingBuilder<T> делает всё то же, что стандартный (передаёт вызовы AsyncTaskMethodBuilder<T>), но печатает каждый вызов:

final/LoggingBuilder.cs (фрагмент)
public struct LoggingBuilder<T>
{
    private AsyncTaskMethodBuilder<T> _inner;          // настоящая работа — у стандартного builder'а

    public static LoggingBuilder<T> Create()
    {
        Trace.Log("builder.Create()");
        return new LoggingBuilder<T> { _inner = AsyncTaskMethodBuilder<T>.Create() };
    }

    public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
    {
        Trace.Log($"builder.Start() → MoveNext(), state машины = {State(stateMachine)}");
        _inner.Start(ref stateMachine);
        Trace.Log("builder.Start() вернулся в заглушку");
    }
final/LoggingBuilder.cs (фрагмент)
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine
    {
        Trace.Log($"builder.AwaitUnsafeOnCompleted({typeof(TAwaiter).Name}), state = {State(stateMachine)}");
        _inner.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
    }

Строки 7, 12, 18, 43: внутри лежит настоящий builder, вся работа передаётся ему. Метод, к которому подключён шпион, и место вызова:

final/Program.cs
[AsyncMethodBuilder(typeof(LoggingBuilder<>))]
static async Task<int> TracedAsync()
{
    Trace.Log("тело: до первого await");
    await Task.CompletedTask;                     // уже завершён: builder не понадобится
    Trace.Log("тело: после await Task.CompletedTask");
    await Task.Delay(100);                        // настоящая приостановка
    Trace.Log("тело: после await Task.Delay");
    return 42;
}
final/Program.cs
Trace.Log("вызываю TracedAsync()");
Task<int> traced = TracedAsync();
Trace.Log($"TracedAsync() вернул задачу, IsCompleted={traced.IsCompleted}");
Trace.Log($"await → {await traced}");

Вывод:

  [поток  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).

Что делает AwaitUnsafeOnCompleted внутри

Вот метод из AsyncTaskMethodBuilder<TResult> (CoreLib 10.0.12), которому передаёт работу и наш шпион:

CoreLib 10.0.12: AsyncTaskMethodBuilder<TResult>, подписка на awaiter
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
    where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine
{
    AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine, ref m_task);
}

internal static void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine,
    [NotNull] ref Task<TResult> taskField)
    where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine
{
    IAsyncStateMachineBox stateMachineBox = GetStateMachineBox(ref stateMachine, ref taskField);
    AwaitUnsafeOnCompleted(ref awaiter, stateMachineBox);
}

internal static void AwaitUnsafeOnCompleted<TAwaiter>(ref TAwaiter awaiter, IAsyncStateMachineBox box)
    where TAwaiter : ICriticalNotifyCompletion
{
    TAwaiter val = default;
    if (val != null && awaiter is ITaskAwaiter)
    {
        TaskAwaiter.UnsafeOnCompletedInternal(Unsafe.As<TAwaiter, TaskAwaiter>(ref awaiter).m_task, box, continueOnCapturedContext: true);
        return;
    }
    val = default;
    if (val != null && awaiter is IConfiguredTaskAwaiter)
    {
        ref ConfiguredTaskAwaitable.ConfiguredTaskAwaiter reference = ref Unsafe.As<TAwaiter, ConfiguredTaskAwaitable.ConfiguredTaskAwaiter>(ref awaiter);
        TaskAwaiter.UnsafeOnCompletedInternal(reference.m_task, box, (reference.m_options & ConfigureAwaitOptions.ContinueOnCapturedContext) != 0);
        return;
    }
    val = default;
    if (val != null && awaiter is IStateMachineBoxAwareAwaiter)
    {
        try
        {
            ((IStateMachineBoxAwareAwaiter)(object)awaiter).AwaitUnsafeOnCompleted(box);
            return;
        }
        catch (Exception exception)
        {
            System.Threading.Tasks.Task.ThrowAsync(exception, null);
            return;
        }
    }
    try
    {
        Action moveNextAction = box.MoveNextAction;
        awaiter.UnsafeOnCompleted(moveNextAction);
    }
    catch (Exception exception2)
    {
        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()

Task.Yield() — пример встроенного awaiter'а, который знает про бокс. Его awaiter YieldAwaitable.YieldAwaiter из CoreLib 10.0.12 (без кода трассировки):

CoreLib 10.0.12: YieldAwaitable.YieldAwaiter (сокращено)
public readonly struct YieldAwaiter : ICriticalNotifyCompletion, INotifyCompletion, IStateMachineBoxAwareAwaiter
{
    public bool IsCompleted => false;

    void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox box)
    {
        SynchronizationContext current = SynchronizationContext.Current;
        if (current != null && current.GetType() != typeof(SynchronizationContext))
        {
            current.Post((object s) => ((IAsyncStateMachineBox)s).MoveNext(), box);
            return;
        }
        TaskScheduler current2 = TaskScheduler.Current;
        if (current2 == TaskScheduler.Default)
        {
            ThreadPool.UnsafeQueueUserWorkItemInternal(box, preferLocal: false);
            return;
        }
        Task.Factory.StartNew((object s) => ((IAsyncStateMachineBox)s).MoveNext(), box, default,
            TaskCreationOptions.PreferFairness, current2);
    }

    public void GetResult()
    {
    }
}
  • Строка 3: IsCompleted всегда false, поэтому await Task.Yield() всегда приостанавливается.
  • Строки 7–12: если есть SynchronizationContext (UI), продолжение отправляется туда.
  • Строки 13–18: иначе в обычном случае сам бокс ставится в очередь пула потоков как рабочий элемент: делегат не нужен, бокс умеет выполняться пулом.
  • Строки 19–20: если код выполняется внутри нестандартного TaskScheduler, продолжение уходит туда.
  • Строки 23–25: GetResult() пустой.

Наш Switch из §3.2 — упрощённая версия того же: без контекстов и с делегатом вместо бокса.

3.5. Сколько стоит вызов: аллокации

Пятая часть final/ (Allocations.cs) измеряет, сколько байт в куче выделяет сам вызов async-метода, до возврата Task:

final/Allocations.cs
    static void Measure(string title, Func<Task<int>, Task> call)
    {
        // У каждого вызова своя незавершённая задача: если подписать несколько вызовов на одну,
        // со второго подписчика задача заводит список продолжений, и его аллокация испортит замер.
        call(new TaskCompletionSource<int>().Task);          // прогрев: JIT и статические поля аллоцируют сами
        Task<int> pending = new TaskCompletionSource<int>().Task;
        long before = GC.GetAllocatedBytesForCurrentThread();
        Task task = call(pending);
        long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
        Console.WriteLine($"  {title,-36}: {bytes,4} байт, {task.GetType().Name}");
    }

#pragma warning disable CS1998   // async без await: нарочно
    static async Task VoidSync() { }
    static async Task<int> IntSync(int value) => value;
#pragma warning restore CS1998

    static async Task<int> Suspend(Task<int> task) => await task;

    static async Task<long> FatSuspend(Task<int> task)
    {
        long a = 1, b = 2, c = 3, d = 4, e = 5, f = 6, g = 7, h = 8;   // все нужны после await → поля машины
        int r = await task;
        return a + b + c + d + e + f + g + h + r;
    }
  • Строки 18–19: первый вызов — прогрев: при первом вызове JIT и статические инициализаторы аллоцируют сами и испортили бы замер.
  • Строки 20–22: GC.GetAllocatedBytesForCurrentThread() до и после второго вызова — точное число байт, выделенных этим потоком.
  • Строки 16–17 и 19: у каждого вызова своя незавершённая задача (TaskCompletionSource, без таймеров и потоков). Почему это важно, см. «Факт» ниже.
  • Строки 33–38: восемь long, которые нужны после await, становятся полями машины.

Результат (три прогона, числа совпали):

1
2
3
4
5
  async Task, без паузы               :    0 байт, Task`1
  async Task<int> = 1, без паузы      :    0 байт, Task`1
  async Task<int> = 1000, без паузы   :   72 байт, Task`1
  async Task<int>, пауза, 1 поле      :  104 байт, AsyncStateMachineBox`1
  async Task<long>, пауза, 9 полей    :  168 байт, AsyncStateMachineBox`1
  • Строка 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). Каждое поднятое значение увеличивает бокс.

Кеш в Task.FromResult (CoreLib 10.0.12):

CoreLib 10.0.12: Task.FromResult и TaskCache
public static Task<TResult> FromResult<TResult>(TResult result)
{
    if (result == null)
        return Task<TResult>.s_defaultResultTask;
    if (typeof(TResult) == typeof(bool))
        return (Task<TResult>)(object)(result ? TaskCache.s_trueTask : TaskCache.s_falseTask);
    if (typeof(TResult) == typeof(int))
    {
        int num = (int)(object)result;
        if ((uint)(num - -1) < 10u)
            return (Task<TResult>)(object)TaskCache.s_int32Tasks[num - -1];
    }
    else if (/* TResult — примитив без ссылок, и значение — нули */)
        return Task<TResult>.s_defaultResultTask;
    return new Task<TResult>(result);
}

internal static class TaskCache
{
    internal static readonly Task<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).

3.7. Итоги

  • 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 байт, растёт с числом поднятых значений).

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

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

start/Program.cs
// Глава 03, §3.2. Свой awaitable.
// Задание 1: допишите TimeSpanAwaitExtensions.GetAwaiter, чтобы работал await TimeSpan.
// Задание 2: допишите Switch, чтобы после await код оказывался на потоке пула.
// Пока заготовки бросают NotImplementedException.
using System.Runtime.CompilerServices;

try
{
    await TimeSpan.FromMilliseconds(300);
    Console.WriteLine("прошло 300 мс");
}
catch (NotImplementedException e) { Console.WriteLine($"1) {e.Message}"); }

try
{
    Console.WriteLine($"до Switch: на потоке пула: {Thread.CurrentThread.IsThreadPoolThread}");
    await Switch.ToThreadPool();
    Console.WriteLine($"после Switch: на потоке пула: {Thread.CurrentThread.IsThreadPoolThread}");
}
catch (NotImplementedException e) { Console.WriteLine($"2) {e.Message}"); }

static class TimeSpanAwaitExtensions
{
    // TODO: верните awaiter задачи Task.Delay(delay).
    public static TaskAwaiter GetAwaiter(this TimeSpan delay) =>
        throw new NotImplementedException("TimeSpanAwaitExtensions.GetAwaiter");
}

readonly struct Switch : INotifyCompletion
{
    public static Switch ToThreadPool() => default;
    public Switch GetAwaiter() => this;               // сам себе awaiter

    // TODO: «уже на месте», если текущий поток — поток пула.
    public bool IsCompleted => throw new NotImplementedException("Switch.IsCompleted");

    public void GetResult() { }

    // TODO: поставьте continuation в очередь пула потоков.
    public void OnCompleted(Action continuation) => throw new NotImplementedException("Switch.OnCompleted");
}
final/Program.cs
// Глава 3, итог.
//   1) await работает по паттерну, а не по интерфейсу: свой awaitable (§3.2);
//   2) builder-«шпион» показывает, какие вызовы делает машина состояний (§3.4);
//   3) сколько байт стоит вызов async-метода с паузой и без (§3.5).
using System.Runtime.CompilerServices;

Console.WriteLine("== 1. Свой awaitable ==");
await TimeSpan.FromMilliseconds(300);
Console.WriteLine("  прошло 300 мс");
Console.WriteLine($"  до Switch: на потоке пула: {Thread.CurrentThread.IsThreadPoolThread}");
await Switch.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<>))]
static async Task<int> TracedAsync()
{
    Trace.Log("тело: до первого await");
    await Task.CompletedTask;                     // уже завершён: builder не понадобится
    Trace.Log("тело: после await Task.CompletedTask");
    await Task.Delay(100);                        // настоящая приостановка
    Trace.Log("тело: после await Task.Delay");
    return 42;
}

static class TimeSpanAwaitExtensions
{
    public static TaskAwaiter GetAwaiter(this TimeSpan delay) => Task.Delay(delay).GetAwaiter();
}

// Похоже устроен Task.Yield(): awaiter с IsCompleted == false, который ставит продолжение в очередь.
readonly struct Switch : INotifyCompletion
{
    public static Switch ToThreadPool() => default;
    public Switch GetAwaiter() => this;
    public bool IsCompleted => Thread.CurrentThread.IsThreadPoolThread;
    public void GetResult() { }
    public void OnCompleted(Action continuation) => ThreadPool.QueueUserWorkItem(_ => continuation());
}
final/LoggingBuilder.cs
using System.Runtime.CompilerServices;

// §3.4. Builder-«шпион»: делает всё то же, что AsyncTaskMethodBuilder<T>, но печатает каждый вызов.
// Подключается к одному методу атрибутом [AsyncMethodBuilder(typeof(LoggingBuilder<>))] (C# 10).
public struct LoggingBuilder<T>
{
    private AsyncTaskMethodBuilder<T> _inner;          // настоящая работа — у стандартного builder'а

    public static LoggingBuilder<T> Create()
    {
        Trace.Log("builder.Create()");
        return new LoggingBuilder<T> { _inner = AsyncTaskMethodBuilder<T>.Create() };
    }

    public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
    {
        Trace.Log($"builder.Start() → MoveNext(), state машины = {State(stateMachine)}");
        _inner.Start(ref stateMachine);
        Trace.Log("builder.Start() вернулся в заглушку");
    }

    public Task<T> Task
    {
        get
        {
            Task<T> task = _inner.Task;
            Trace.Log($"builder.Task → {task.GetType().Name}, Status={task.Status}");
            return task;
        }
    }

    public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine
    {
        Trace.Log($"builder.AwaitOnCompleted({typeof(TAwaiter).Name}), state = {State(stateMachine)}");
        _inner.AwaitOnCompleted(ref awaiter, ref stateMachine);
    }

    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine
    {
        Trace.Log($"builder.AwaitUnsafeOnCompleted({typeof(TAwaiter).Name}), state = {State(stateMachine)}");
        _inner.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
    }

    public void SetResult(T result)
    {
        Trace.Log($"builder.SetResult({result})");
        _inner.SetResult(result);
    }

    public void SetException(Exception exception)
    {
        Trace.Log($"builder.SetException({exception.GetType().Name})");
        _inner.SetException(exception);
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        Trace.Log("builder.SetStateMachine()");
        _inner.SetStateMachine(stateMachine);
    }

    // Номер состояния машины: поле <>1__state сгенерированной структуры.
    private static int State<TStateMachine>(TStateMachine stateMachine) =>
        (int)typeof(TStateMachine).GetField("<>1__state")!.GetValue(stateMachine)!;
}

public static class Trace
{
    public static void Log(string text) =>
        Console.WriteLine($"  [поток {Environment.CurrentManagedThreadId,2}] {text}");
}
final/Allocations.cs
// §3.5. Сколько байт в куче стоит вызов async-метода. Меряем только сам вызов (до возврата Task),
// поэтому продолжения на других потоках в замер не попадают.
public static class Allocations
{
    public static void Run()
    {
        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);
    }

    static void Measure(string title, Func<Task<int>, Task> call)
    {
        // У каждого вызова своя незавершённая задача: если подписать несколько вызовов на одну,
        // со второго подписчика задача заводит список продолжений, и его аллокация испортит замер.
        call(new TaskCompletionSource<int>().Task);          // прогрев: JIT и статические поля аллоцируют сами
        Task<int> pending = new TaskCompletionSource<int>().Task;
        long before = GC.GetAllocatedBytesForCurrentThread();
        Task task = call(pending);
        long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
        Console.WriteLine($"  {title,-36}: {bytes,4} байт, {task.GetType().Name}");
    }

#pragma warning disable CS1998   // async без await: нарочно
    static async Task VoidSync() { }
    static async Task<int> IntSync(int value) => value;
#pragma warning restore CS1998

    static async Task<int> Suspend(Task<int> task) => await task;

    static async Task<long> FatSuspend(Task<int> task)
    {
        long a = 1, b = 2, c = 3, d = 4, e = 5, f = 6, g = 7, h = 8;   // все нужны после await → поля машины
        int r = await task;
        return a + b + c + d + e + f + g + h + r;
    }
}