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

1. Главная идея: async — не про потоки

О главе

Цель: разделить «Task как работу» и «Task как обещание»; увидеть, на каких потоках продолжается метод и откуда эти потоки берутся.

Лабораторная: start/ — эксперимент §1.3 с местами для предсказаний. final/ — то же плюс типы задач изнутри (§1.5) и 10 000 ожиданий (§1.6). Код — в конце главы.

Статус: ✅ проверено на Windows 11, 8 ядер, рантайм 10.0.12. Все листинги «под капотом» сняты с этой же лабораторной (tools/disasm.ps1 chapters\01-mental-model\start -Type Program -Method '*DemoAsync*:MoveNext') и из CoreLib 10.0.12.

1.1. Что такое Task

Task<T> — объект-обещание (promise): «результат появится позже, а пока вот ссылка, по которой можно подождать, подписаться на завершение или забрать исключение». Это обычный объект в куче с состоянием (WaitingForActivation, RanToCompletion, Faulted, Canceled и др.), списком продолжений и результатом.

Две разные идеи, которые путают:

Что это Пример
Task как «работа на потоке» вычисление, запущенное на потоке пула Task.Run(() => Compute())
Task как «обещание» ожидание события без потока Task.Delay, сетевой ввод-вывод, TaskCompletionSource

async/await построен на втором: пока вы ждёте, поток не занят. Он возвращается в пул и обслуживает другую работу. Когда операция завершится, продолжение метода выполнит какой-то поток пула.

1.2. Что делает async, а что await

  • async — разрешение для компилятора: «этот метод превращай в машину состояний и разреши в нём await». Сам по себе async не создаёт потоков и не делает метод асинхронным: без await метод выполнится синхронно (компилятор предупредит CS1998).
  • await expr — «если expr уже завершён, иди дальше прямо сейчас; если нет, подпишись на его завершение, верни управление вызывающему и продолжи отсюда позже».

Как это выглядит в скомпилированном коде нашего эксперимента — в §1.4. Подробно машину состояний разберём в главе 2.

1.3. Первый эксперимент: потоки до и после await

Код лабораторной start/Program.cs. Номера — это номера строк в файле:

start/Program.cs
Console.WriteLine($"Main start       : thread {Environment.CurrentManagedThreadId}");   // PREDICT:
await DemoAsync();
Console.WriteLine($"Main end         : thread {Environment.CurrentManagedThreadId}");   // PREDICT:

static async Task DemoAsync()
{
    Console.WriteLine($"  before await   : thread {Environment.CurrentManagedThreadId}"); // PREDICT:
    await Task.Delay(200);
    Console.WriteLine($"  after Delay    : thread {Environment.CurrentManagedThreadId}"); // PREDICT:

    await Task.CompletedTask;                       // уже завершён
    Console.WriteLine($"  after Completed: thread {Environment.CurrentManagedThreadId}"); // PREDICT:

    await Task.Yield();                             // принудительно уйти и вернуться
    Console.WriteLine($"  after Yield    : thread {Environment.CurrentManagedThreadId}"); // PREDICT:
}

Что такое Task.Yield() (и при чём тут yield return)

В строке 20 стоит await Task.Yield(). Это метод, а не ключевое слово. Английское yield значит «уступить»: «прервись, дай поработать другим, продолжу позже».

Устроен он так (YieldAwaitable.YieldAwaiter, CoreLib 10.0.12, фрагменты):

CoreLib 10.0.12: YieldAwaitable.YieldAwaiter (сокращено)
public bool IsCompleted => false;

void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox box)
{
    SynchronizationContext current = SynchronizationContext.Current;
    if (current != null && current.GetType() != typeof(SynchronizationContext))
    {
        current.Post(…, box);                                     // есть контекст — Post в него
        return;
    }
    TaskScheduler current2 = TaskScheduler.Current;
    if (current2 == TaskScheduler.Default)
    {
        ThreadPool.UnsafeQueueUserWorkItemInternal(box, preferLocal: false);   // обычный случай — очередь пула
        return;
    }
    Task.Factory.StartNew(…, box, default, TaskCreationOptions.PreferFairness, current2);
}
  • Строка 1: IsCompleted всегда false. Поэтому await Task.Yield() не может пройти «без остановки», как await Task.CompletedTask: метод всегда приостанавливается и возвращает вызывающему незавершённый Task.
  • Строки 3–18: «подписка» здесь не ждёт никакого события. Она просто ставит бокс машины (шаг 2 ниже) в очередь: в SynchronizationContext, если он есть (строки 5–10), иначе в пул потоков (строки 12–16), иначе в нестандартный планировщик (строка 17). Когда до бокса дойдёт очередь, вызовется его MoveNext.
  • Смену потока Yield не гарантирует: гарантируется только «продолжение пойдёт через очередь, а не синхронно». Ниже это видно в прогонах.

Зачем он нужен: уступить очередь (например, в UI — чтобы успели обработаться другие сообщения), не выполнять долгую синхронную часть метода внутри вызывающего, или (как в этой лабораторной и в главе 2) получить самый короткий await, который гарантированно приостанавливает метод, без таймера и других задач.

Не путайте с yield return. Это ключевые слова итераторов (IEnumerable<T>), и они тоже превращаются в машину состояний, но возобновляет её не уведомление, а сам вызывающий:

yield return await
Что значит пауза отдать значение вызывающему ждать результат
Кто возобновляет вызывающий следующим MoveNext(), синхронно завершение того, чего ждали
Подробнее глава 11 (там они встречаются вместе: IAsyncEnumerable<T>) вся книга

А Thread.Yield() — то же «уступи» на уровне ОС: отдать остаток кванта времени другому потоку.

Предскажите

Какой номер потока напечатают строки 7, 13, 15, 18, 21 и 9? Впишите в комментарии PREDICT и только потом запускайте:

cd chapters
dotnet run -c Release --project 01-mental-model\start
Факт: вывод на стенде автора (раскройте после предсказания)

Windows 11, 8 ядер. Шесть прогонов подряд:

1
2
3
4
5
6
Main start       : thread 2
  before await   : thread 2
  after Delay    : thread 5
  after Completed: thread 5
  after Yield    : thread 7
Main end         : thread 7
  • Строка 7 исходника → поток 2, а не 1. Номер 1 в .NET 10 на Windows достаётся потоку финализатора: он стартует раньше Main (проверено: финализатор печатает id=1, background=True). В первой версии курса на Linux главный поток был 1. Номера потоков — не контракт, не полагайтесь на них.
  • Строка 13 → поток 2. До первого await метод DemoAsync выполняется синхронно, прямо внутри вызова из строки 8.
  • Строка 15 → поток пула 5. Это продолжение после await Task.Delay(200) из строки 14. Откуда именно взялся этот поток — §1.4.
  • Строка 18 → тот же поток 5. Task.CompletedTask в строке 17 уже завершён, await не останавливается, продолжение идёт синхронно.
  • Строка 21 → обычно другой поток (7), но не всегда. В одном прогоне из шести здесь был поток 5. Task.Yield() в строке 20 ставит продолжение в очередь пула, и его забирает первый свободный поток: обычно уже проснувшийся соседний, а иногда сам поток 5, успевший вернуться в пул. В первой версии курса на виртуалке с 2 ядрами всегда оставался тот же поток. Yield гарантирует только одно: продолжение пойдёт через очередь, а не синхронно. Смену потока он не гарантирует.
  • Строка 9 → поток 7. Продолжение await DemoAsync() из строки 8 выполнилось на том потоке, который завершил DemoAsync.

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

1.4. Под капотом: откуда поток после Task.Delay

Парадокс эксперимента: «ожидание не занимает поток», но после await Task.Delay(200) (строка 14) код всё-таки продолжился на потоке пула №5 (строка 15). Откуда он взялся и почему именно из пула? Проследим путь этих 200 мс шаг за шагом: по скомпилированному коду этой лабораторной и по исходникам рантайма 10.0.12.

Представьте кухонный таймер. Вы поставили его на 200 мс и ушли заниматься другим. Таймер не стоит у плиты и не держит вас за руку, он просто тикает. Когда он звенит, к плите подходит тот, кто свободен, и продолжает готовить с того места, где вы остановились. В .NET всё устроено так же. Дальше — кто здесь кто.

Действующие лица

Кто Что это Сколько их
Поток Main (2) вызвал DemoAsync и дошёл до await один
Машина состояний DemoAsync то, во что компилятор превратил метод DemoAsync по одной на каждый вызов; пока не приостановилась — на стеке
Бокс машины объект в куче: копия машины + это же Task, который вернул DemoAsync (разбор — ниже) создаётся при первой приостановке вызова
DelayPromise задача, которую вернул Task.Delay(200); наследник Task по одной на каждый Delay
Запись таймера (TimerQueueTimer) «через 200 мс вызвать колбэк для этой задачи» по одной на каждый Delay
Поток .NET Timer будильник: спит до ближайшего срока один на весь процесс
Поток пула (5) исполнитель: выполняет колбэки и продолжения сколько понадобится

Во что компилятор превратил DemoAsync

Чтобы говорить о шагах предметно, сначала посмотрим, во что скомпилирован метод DemoAsync (строки 11–22). Компилятор оставил на его месте маленькую «заглушку», а всё тело перенёс в метод MoveNext сгенерированной структуры. Вот эта структура: декомпиляция ILSpy без восстановления async, Release.

DemoAsync после компиляции: машина состояний
private struct <<<Main>$>g__DemoAsync|0_0>d : IAsyncStateMachine    // вместо метода — структура; на стеке, пока не было паузы
{
    public int <>1__state;                          // где остановились: -1 идёт, 0/1/2 — номер await, -2 конец
    public AsyncTaskMethodBuilder <>t__builder;     // связь с Task, который вернёт DemoAsync
    private TaskAwaiter <>u__1;                     // awaiter для await на Task (строки 14 и 17)
    private YieldAwaitable.YieldAwaiter <>u__2;     // awaiter для await Task.Yield() (строка 20)

    private void MoveNext()                         // всё тело DemoAsync; зовётся на старте и после каждой паузы
    {
        int num = <>1__state;                       // текущее состояние: на каком шаге мы
        try                                         // любое исключение тела уйдёт в catch (строка 71)
        {
            TaskAwaiter awaiter2;                   // локальная копия awaiter'а для Task
            YieldAwaitable.YieldAwaiter awaiter;    // локальная копия awaiter'а для Yield
            switch (num)                            // прыжок на нужное место по номеру состояния
            {
            default:                                // первый вход
                Console.WriteLine($"  before await   : thread {Environment.CurrentManagedThreadId}");           // исходник, строка 13: печать до первого await
                awaiter2 = Task.Delay(200).GetAwaiter();                                                        // исходник, строка 14: запустить Delay и взять его awaiter
                if (!awaiter2.IsCompleted)          // Delay уже завершена? Тогда пауза не нужна
                {
                    num = (<>1__state = 0);         // запомнить, где остановились: ждём первый await
                    <>u__1 = awaiter2;              // сохранить awaiter в поле: после паузы он понадобится
                    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);                                // ref this = сама машина: builder копирует её в бокс (куча) и подписывает бокс на Delay
                    return;                         // выйти из MoveNext: поток свободен, метод «спит»
                }
                goto IL_009e;                       // завершена сразу: идти дальше без паузы
            case 0:                                 // возобновление после Delay
                awaiter2 = <>u__1;                  // достать сохранённый awaiter
                <>u__1 = default;                   // очистить поле, чтобы не держать лишнее
                num = (<>1__state = -1);            // снова «выполняется»
                goto IL_009e;                       // общий путь с быстрым случаем
            case 1:                                 // возобновление после CompletedTask
                awaiter2 = <>u__1;                  // достать сохранённый awaiter
                <>u__1 = default;                   // очистить поле
                num = (<>1__state = -1);            // снова «выполняется»
                goto IL_0125;                       // общий путь с быстрым случаем
            case 2:                                 // возобновление после Yield
                awaiter = <>u__2;                   // достать сохранённый awaiter Yield
                <>u__2 = default;                   // очистить поле
                num = (<>1__state = -1);            // снова «выполняется»
                break;                              // выйти из switch к строке 68
            IL_009e:                                // сюда приходят после Delay: сразу или после паузы
                awaiter2.GetResult();               // забрать результат Delay (бросит, если она упала)
                Console.WriteLine($"  after Delay    : thread {Environment.CurrentManagedThreadId}");           // исходник, строка 15: печать после Delay
                awaiter2 = Task.CompletedTask.GetAwaiter();                                                     // исходник, строка 17: взять awaiter у CompletedTask
                if (!awaiter2.IsCompleted)          // CompletedTask завершена всегда: ветка паузы не выполнится
                {
                    num = (<>1__state = 1);         // запомнить: ждём второй await
                    <>u__1 = awaiter2;              // сохранить awaiter
                    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);                                // бокс уже есть: берётся тот же
                    return;                         // выйти из MoveNext
                }
                goto IL_0125;                       // к продолжению без паузы
            IL_0125:                                // сюда приходят после CompletedTask
                awaiter2.GetResult();               // забрать результат (исключений нет)
                Console.WriteLine($"  after Completed: thread {Environment.CurrentManagedThreadId}");           // исходник, строка 18: печать после CompletedTask
                awaiter = Task.Yield().GetAwaiter();                                                            // исходник, строка 20: взять awaiter у Yield
                if (!awaiter.IsCompleted)           // IsCompleted у Yield всегда false: пауза будет
                {
                    num = (<>1__state = 2);         // запомнить: ждём третий await
                    <>u__2 = awaiter;               // сохранить awaiter Yield
                    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);                                 // бокс уже есть: берётся тот же; продолжение — в очередь пула
                    return;                         // выйти из MoveNext
                }
                break;                              // путь без паузы (для Yield недостижим)
            }
            awaiter.GetResult();                    // забрать результат Yield
            Console.WriteLine($"  after Yield    : thread {Environment.CurrentManagedThreadId}");               // исходник, строка 21: печать после Yield
        }
        catch (Exception exception)                 // любое необработанное исключение тела
        {
            <>1__state = -2;                        // метод завершён (с ошибкой)
            <>t__builder.SetException(exception);   // записать исключение в Task
            return;                                 // выйти, SetResult не вызывать
        }
        <>1__state = -2;                            // метод завершён успешно
        <>t__builder.SetResult();                   // завершить Task, который вернул DemoAsync
    }
}

Комментарии добавлены мной, а блоки с метками IL_009e и IL_0125 переставлены в порядке исходника: ILSpy выводит их в обратном порядке, логика та же. Явные реализации IAsyncStateMachine в конце структуры опущены.

Как строки исходника разложились по MoveNext:

Исходник MoveNext Что там
строка 13, before await строка 18 выполняется при первом входе (default)
строка 14, await Task.Delay(200) строки 19–27, 28–32, 44 получить awaiter → не готово? запомнить состояние 0 и уйти → при повторном входе case 0 → GetResult()
строка 15, after Delay строка 45 код после первого await
строка 17, await Task.CompletedTask строки 46–54, 33–37, 56 то же устройство, состояние 1
строка 18, after Completed строка 57
строка 20, await Task.Yield() строки 58–66, 38–42, 68 то же, состояние 2, свой тип awaiter'а в поле <>u__2
строка 21, after Yield строка 69
конец метода строки 77–78 SetResult() завершает Task, который вернул DemoAsync

Каждый await превратился в одинаковую конструкцию из двух половин: «подписаться и уйти» и «вернуться по номеру состояния».

Факт: для await Task.CompletedTask компилятор тоже построил полный путь приостановки

Строки 46–54 и ветка case 1 (строки 33–37) сгенерированы, хотя Task.CompletedTask всегда завершён. Компилятор не знает этого заранее. Решение «идти дальше без остановки» принимается во время выполнения в строке 47: IsCompleted == true, и return не случается. Поэтому в эксперименте строка 18 исходника напечатала тот же поток.

Что такое «бокс машины»

Машина состояний <<<Main>$>g__DemoAsync|0_0>d — это структура (struct, значимый тип). Пока метод не приостанавливался, она живёт на стеке: в локальной переменной заглушки, которую компилятор оставил на месте DemoAsync. Вот эта заглушка из той же сборки:

DemoAsync после компиляции: заглушка
1
2
3
4
5
6
7
8
9
[AsyncStateMachine(typeof(<<<Main>$>g__DemoAsync|0_0>d))]
static Task DemoAsync()
{
    <<<Main>$>g__DemoAsync|0_0>d stateMachine = default;
    stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}
  • Строка 4: машина — локальная переменная, то есть лежит на стеке.
  • Строка 7: Start синхронно вызывает её MoveNext (строки 8–79 листинга выше).
  • Строка 8: вызывающий получает builder.Task. Что это за объект, станет ясно ниже.

Но после await на незавершённой задаче MoveNext выходит, заглушка возвращает управление, и её стек исчезает вместе с машиной. Значит, до выхода машину нужно перенести туда, где она переживёт метод, — в кучу. Объект в куче, в который копируется машина, и называется боксом машины состояний (state machine box). Название — по аналогии с упаковкой (boxing) значимого типа в объект. Но это не object o = machine, а отдельный generic-класс рантайма.

В самом MoveNext этого переноса не видно, и так задумано. Он спрятан в одной строке — строке 24 листинга MoveNext: <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this). Второй аргумент ref this — ссылка на саму машину, лежащую на стеке заглушки. Builder создаёт бокс, копирует в него машину по этой ссылке, записывает бокс в своё поле и подписывает его на завершение Delay. Поэтому компилятор перед вызовом сохраняет в машине номер состояния и awaiter (строки 22–23): в бокс уедет копия, и она должна уже содержать всё нужное. Исходный экземпляр на стеке после этого не используется, а return в строке 25 завершает MoveNext и заглушку. Дальше все вызовы MoveNext идут уже на копии в боксе. Посмотрим, как бокс устроен и где именно создаётся.

Что это за класс. В CoreLib это AsyncTaskMethodBuilder<TResult>.AsyncStateMachineBox<TStateMachine>. Вот он, без отладочного свойства DebuggerDisplay и атрибута [MethodImpl]:

CoreLib 10.0.12: AsyncTaskMethodBuilder<TResult>.AsyncStateMachineBox<TStateMachine>
private class AsyncStateMachineBox<TStateMachine> : Task<TResult>, IAsyncStateMachineBox  // бокс — это и есть Task, который вернёт async-метод
    where TStateMachine : IAsyncStateMachine                    // тип машины известен компилятору: своя версия класса на каждый метод
{
    private static readonly ContextCallback s_callback = ExecutionContextCallback;        // один статический делегат на тип: чтобы не создавать его на каждый вызов

    public TStateMachine StateMachine;                          // копия вашей структуры-машины: состояние, builder, awaiter'ы, локальные

    public Action MoveNextAction => (Action)(m_action ?? (m_action = new Action(MoveNext)));  // Action на MoveNext для awaiter'ов без поддержки бокса; создаётся лениво, один раз

    public ref ExecutionContext Context => ref Unsafe.As<object, ExecutionContext>(ref m_stateObject);  // ExecutionContext лежит в унаследованном поле Task m_stateObject: без нового поля

    private static void ExecutionContextCallback(object s)      // то, что выполняется внутри восстановленного контекста
    {
        Unsafe.As<AsyncStateMachineBox<TStateMachine>>(s).StateMachine.MoveNext();        // приведение без проверки типа и прямой вызов MoveNext машины, без интерфейса
    }

    public AsyncStateMachineBox()                               // конструктор бокса
    {
        m_stateFlags |= 2048;                                   // флаг 0x800 HiddenState: m_stateObject занят контекстом, а не AsyncState
    }

    internal sealed override void ExecuteFromThreadPool(Thread threadPoolThread)          // вызывается пулом, когда достаёт бокс из очереди как рабочий элемент
    {
        MoveNext(threadPoolThread);                             // MoveNext с потоком пула: от него зависит способ подмены контекста
    }

    public void MoveNext()                                      // вызов «снаружи»: из инлайн-продолжения или через MoveNextAction
    {
        MoveNext(null);                                         // потока пула нет
    }

    private void MoveNext(Thread threadPoolThread)              // единая реализация MoveNext бокса
    {
        bool flag = TplEventSource.Log.IsEnabled();             // включена ли трассировка событий TPL (обычно нет)
        if (flag)
        {
            TplEventSource.Log.TraceSynchronousWorkBegin(Id, CausalitySynchronousWork.Execution);  // событие «синхронная работа началась»
        }
        ExecutionContext context = Context;                     // контекст, захваченный при приостановке
        if (context == null)                                    // контекст не захвачен (так бывает только при SuppressFlow)
        {
            StateMachine.MoveNext();                            // прямой вызов MoveNext вашей машины
        }
        else if (threadPoolThread == null)                      // вызвали не из пула (например, инлайн из SetResult)
        {
            ExecutionContext.RunInternal(context, s_callback, this);                      // поставить контекст на поток, выполнить, вернуть прежний
        }
        else                                                    // вызвал пул потоков
        {
            ExecutionContext.RunFromThreadPoolDispatchLoop(threadPoolThread, context, s_callback, this);  // то же, но после работы поток пула очищается (SynchronizationContext тоже)
        }
        if (IsCompleted)                                        // машина дошла до конца (результат, исключение или отмена)?
        {
            ClearStateUponCompletion();                         // тогда освободить всё, что бокс удерживал
        }
        if (flag)                                               // трассировка включена?
        {
            TplEventSource.Log.TraceSynchronousWorkEnd(CausalitySynchronousWork.Execution);  // событие «синхронная работа закончилась»
        }
    }

    public void ClearStateUponCompletion()                      // очистка после завершения метода
    {
        if (System.Threading.Tasks.Task.s_asyncDebuggingEnabled)                          // режим отладки async включён? (обычно нет)
        {
            System.Threading.Tasks.Task.RemoveFromActiveTasks(this);                      // снять бокс с учёта отладчика
        }
        StateMachine = default;                                 // обнулить копию машины: не держать ваши объекты, пока кто-то держит Task
        Context = null;                                         // отпустить ExecutionContext
        if (AsyncMethodBuilderCore.TrackAsyncMethodCompletion)                            // отладочный бокс с финализатором? (обычно нет)
        {
            GC.SuppressFinalize(this);                          // финализатор больше не нужен
        }
    }

    IAsyncStateMachine IAsyncStateMachineBox.GetStateMachineObject()                      // для отладчика и трассировки
    {
        return StateMachine;                                    // отдаёт машину отладчику как IAsyncStateMachine (упакованную копию)
    }
}
  • Строка 1: бокс — наследник Task<TResult>. Это главное. Бокс и есть та задача, которую получает вызывающий. Отдельного объекта «задача» плюс отдельного «контейнера для машины» нет: один объект совмещает обе роли.
  • Строки 1–2: класс generic по типу машины. Для каждого async-метода получается свой тип бокса: AsyncStateMachineBox<<<<Main>$>g__DemoAsync|0_0>d>. Поэтому машина хранится в нём как есть, без упаковки в object, а вызов StateMachine.MoveNext() — прямой, без виртуального вызова через интерфейс.
  • Строка 6: поле StateMachine — копия вашей структуры: номер состояния, builder, awaiter'ы, поднятые параметры и локальные переменные.
  • Строка 10: Context — захваченный ExecutionContext (AsyncLocal и т. п., глава 7). Хранится в унаследованном от Task поле m_stateObject, чтобы не заводить новое.
  • Строки 27–30, 32–60: MoveNext бокса — то, что вызывается, когда ожидаемая задача завершилась. Он восстанавливает контекст выполнения (строки 44–51) и вызывает MoveNext машины (строка 42 или 14).
  • Строки 22–25: ExecuteFromThreadPool. Бокс умеет быть рабочим элементом пула потоков, поэтому продолжение можно поставить в очередь пула без дополнительного объекта-обёртки.
  • Строки 52–55 и 62–74: когда метод завершён, бокс очищает себя: StateMachine = default (строка 68), чтобы не держать ссылки на ваши объекты дольше нужного.

Где бокс создаётся. В AwaitUnsafeOnCompleted (строка 24 MoveNext) builder вызывает GetStateMachineBox. Его мы уже видели вызовом в ассемблере шага 1 (строка 26 листинга asm). Метод целиком:

CoreLib 10.0.12: AsyncTaskMethodBuilder<TResult>.GetStateMachineBox
private static IAsyncStateMachineBox GetStateMachineBox<TStateMachine>(                         // зовётся из AwaitUnsafeOnCompleted на каждой паузе
    ref TStateMachine stateMachine, [NotNull] ref Task<TResult> taskField)                      // stateMachine — машина на стеке; taskField — поле m_task builder'а
    where TStateMachine : IAsyncStateMachine
{
    ExecutionContext executionContext = ExecutionContext.Capture();                             // снимок контекста на момент этой паузы (чтение ссылки, глава 7)
    if (taskField is AsyncStateMachineBox<TStateMachine> asyncStateMachineBox)                  // бокс уже создан на прошлой паузе?
    {
        if (asyncStateMachineBox.Context != executionContext)   // между паузами могли записать AsyncLocal
        {
            asyncStateMachineBox.Context = executionContext;    // тогда обновить снимок в боксе
        }
        return asyncStateMachineBox;                            // тот же бокс, новых аллокаций нет
    }
    if (taskField is AsyncStateMachineBox<IAsyncStateMachine> asyncStateMachineBox2)            // бокс без типа машины, созданный заранее для отладчика (редкий путь)
    {
        if (asyncStateMachineBox2.StateMachine == null)         // машина в него ещё не записана?
        {
            Debugger.NotifyOfCrossThreadDependency();           // сообщить отладчику о зависимости от других потоков
            asyncStateMachineBox2.StateMachine = stateMachine;                                  // записать машину в бокс (копия)
        }
        asyncStateMachineBox2.Context = executionContext;       // сохранить контекст
        return asyncStateMachineBox2;                           // вернуть этот бокс
    }
    Debugger.NotifyOfCrossThreadDependency();                   // тот же сигнал отладчику (в обычном запуске безвредно)
    AsyncStateMachineBox<TStateMachine> asyncStateMachineBox3 =                                 // первая пауза вызова: бокса ещё нет, создаём
        (AsyncStateMachineBox<TStateMachine>)(taskField = (AsyncMethodBuilderCore.TrackAsyncMethodCompletion  // taskField = …: бокс сразу становится builder.Task
            ? CreateDebugFinalizableAsyncStateMachineBox<TStateMachine>()                       // режим отладки с финализатором (обычно выключен)
            : new AsyncStateMachineBox<TStateMachine>()));      // обычно: одна аллокация на весь вызов
    asyncStateMachineBox3.StateMachine = stateMachine;          // копия машины со стека в бокс; дальше живёт только копия
    asyncStateMachineBox3.Context = executionContext;           // ExecutionContext этой паузы — в бокс
    if (TplEventSource.Log.IsEnabled())                         // трассировка событий TPL включена? (обычно нет)
    {
        AsyncMethodBuilderCore.LogTraceOperationBegin(asyncStateMachineBox3, stateMachine.GetType());  // событие «началась асинхронная операция»
    }
    if (System.Threading.Tasks.Task.s_asyncDebuggingEnabled)    // включён режим отладки async-задач? (обычно нет)
    {
        System.Threading.Tasks.Task.AddToActiveTasks(asyncStateMachineBox3);                    // записать бокс в список активных задач отладчика
    }
    return asyncStateMachineBox3;                               // builder подпишет этот бокс на ожидаемую задачу
}
  • Строка 2: taskField — ссылка на поле m_task builder'а, который сидит внутри машины. Бокс записывается прямо туда.
  • Строка 5: захватить текущий ExecutionContext.
  • Строки 6–13: бокс уже есть (это второй, третий… await того же вызова). Новый объект не создаётся, обновляется только контекст. Сколько бы await ни было в методе, бокс создаётся не больше одного раза за вызов.
  • Строки 14–23: редкий путь, когда задачу запросили раньше, чем машина приостановилась (например, отладчиком). Пропустим.
  • Строки 25–28: первая приостановка — та самая аллокация. new AsyncStateMachineBox<TStateMachine>() в куче, и сразу же запись в taskField = ...: с этого момента builder.Task возвращает бокс.
  • Строка 29: копирование структуры StateMachine = stateMachine со стека в бокс. Поэтому компилятор в строках 22–23 MoveNext сохраняет номер состояния и awaiter до вызова AwaitUnsafeOnCompleted: копия должна уже содержать всё нужное для возобновления. После копирования экземпляр на стеке больше не используется. Текущий MoveNext доходит до return (строка 25), а все следующие вызовы идут уже на копии в боксе (строка 42 листинга бокса).
  • Строка 30: сохранить контекст в боксе.

А Task из DemoAsync — это Task, а не Task<VoidTaskResult>? Для методов, возвращающих Task, builder AsyncTaskMethodBuilder внутри хранит Task<VoidTaskResult> и передаёт всю работу обобщённому builder'у (поэтому в ассемблере шага 1 вызывается AsyncTaskMethodBuilder1[VoidTaskResult]:GetStateMachineBox`):

CoreLib 10.0.12: AsyncTaskMethodBuilder (для async Task), фрагменты
public struct AsyncTaskMethodBuilder
{
    private Task<VoidTaskResult> m_task;

    public Task Task => m_task ?? InitializeTaskAsPromise();

    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine
    {
        AsyncTaskMethodBuilder<VoidTaskResult>.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine, ref m_task);
    }

    public void SetResult()
    {
        if (m_task == null)
        {
            m_task = System.Threading.Tasks.Task.s_cachedCompleted;
        }
        else
        {
            AsyncTaskMethodBuilder<VoidTaskResult>.SetExistingTaskResult(m_task, default);
        }
    }
}
  • Строка 3: единственное поле builder'а — m_task. Сам builder — структура размером с одну ссылку.
  • Строка 5: builder.Task из строки 8 заглушки. Если машина приостанавливалась, в m_task уже лежит бокс (строка 26 GetStateMachineBox), и вызывающий получает его. Если нет, задача создаётся только теперь.
  • Строка 10: ref m_task передаётся как taskField в GetStateMachineBox.
  • Строки 15–18: метод завершился, ни разу не приостановившись. Тогда бокс не нужен, и даже новая задача не создаётся: отдаётся общий закешированный завершённый Task.s_cachedCompleted.

Убедимся вживую. Четвёртая часть final/Program.cs смотрит на задачу, которую вернул async-метод, и через отражение читает поле StateMachine (строка 67):

final/Program.cs
Console.WriteLine();
Console.WriteLine("== 4. Бокс машины состояний ==");
Task pending = WaitAsync(300);          // приостановится на await: машина уедет в кучу
Describe("ждёт", pending);
await pending;
Describe("завершена", pending);         // мы внутри SetResult: бокс ещё не очистил себя
await Task.Delay(50);
Describe("позже", pending);             // MoveNext бокса вернулся и обнулил StateMachine
Describe("без паузы", WaitAsync(0));    // завершится синхронно: бокс не нужен
Console.WriteLine($"  без паузы дважды — один и тот же объект: {ReferenceEquals(WaitAsync(0), WaitAsync(0))}");

static async Task WaitAsync(int ms)
{
    if (ms > 0)
        await Task.Delay(ms);
}

static void Describe(string title, Task task)
{
    Type type = task.GetType();
    Console.WriteLine($"  {title,-9}: {TypeName(type)}, Status={task.Status}");
    FieldInfo? field = type.GetField("StateMachine");             // поле бокса с копией машины
    if (field is null)
        return;
    object machine = field.GetValue(task)!;
    foreach (FieldInfo f in machine.GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
== 4. Бокс машины состояний ==
  ждёт     : AsyncTaskMethodBuilder<VoidTaskResult>.AsyncStateMachineBox<<<<Main>$>g__WaitAsync|0_3>d>, Status=WaitingForActivation
             StateMachine.<>1__state = 0
             StateMachine.<>t__builder = System.Runtime.CompilerServices.AsyncTaskMethodBuilder
             StateMachine.ms = 300
             StateMachine.<>u__1 = System.Runtime.CompilerServices.TaskAwaiter
  завершена: AsyncTaskMethodBuilder<VoidTaskResult>.AsyncStateMachineBox<<<<Main>$>g__WaitAsync|0_3>d>, Status=RanToCompletion
             StateMachine.<>1__state = -2
             StateMachine.<>t__builder = System.Runtime.CompilerServices.AsyncTaskMethodBuilder
             StateMachine.ms = 300
             StateMachine.<>u__1 = System.Runtime.CompilerServices.TaskAwaiter
  позже    : AsyncTaskMethodBuilder<VoidTaskResult>.AsyncStateMachineBox<<<<Main>$>g__WaitAsync|0_3>d>, Status=RanToCompletion
             StateMachine.<>1__state = 0
             StateMachine.<>t__builder = System.Runtime.CompilerServices.AsyncTaskMethodBuilder
             StateMachine.ms = 0
             StateMachine.<>u__1 = System.Runtime.CompilerServices.TaskAwaiter
  без паузы: Task<VoidTaskResult>, Status=RanToCompletion
  без паузы дважды — один и тот же объект: True
  • Строки 2–6 вывода (строка 50 кода): пока WaitAsync(300) ждёт, Task, который мы держим в переменной pending, — это и есть бокс. Внутри него копия машины: состояние 0 (стоит на первом await), параметр ms = 300 (параметры метода тоже становятся полями машины), сохранённый awaiter.
  • Строки 7–11 вывода (строка 52 кода): задача завершена, состояние -2, но машина ещё не очищена. Наше продолжение после await pending выполняется внутри SetResult бокса (шаг 6 §1.4), раньше, чем MoveNext бокса успел дойти до ClearStateUponCompletion (строки 52–55 листинга бокса).
  • Строки 12–16 вывода (строка 54 кода): через 50 мс MoveNext бокса вернулся и выполнил StateMachine = default. Все поля обнулены, ссылки отпущены.
  • Строки 17–18 вывода (строки 55–56 кода): метод завершился без паузы, и бокса нет. Это обычный Task<VoidTaskResult>, причём один и тот же объект при каждом вызове: закешированный s_cachedCompleted (строка 17 листинга builder'а).

Итого: бокс машины состояний

Бокс — объект класса AsyncTaskMethodBuilder<TResult>.AsyncStateMachineBox<TStateMachine> в куче. Он наследует Task<TResult>, в поле StateMachine хранит копию вашей машины-структуры, а в Context — захваченный ExecutionContext. Создаётся при первой реальной приостановке вызова (не больше одного раза за вызов). С этого момента это и есть Task, который вернул ваш async-метод. Если метод ни разу не приостановился, бокса нет совсем.

Теперь — шаги во времени.

Шаг 1. t = 0, поток Main: Task.Delay создаёт обещание с таймером

MoveNext вызван впервые (из заглушки DemoAsync, синхронно в потоке 2), состояние -1, поэтому выполняется ветка default. Строка 18 печатает before await, а строка 19 вызывает Task.Delay(200).

Task.Delay(200) не запускает никакой работы. Он создаёт объект DelayPromise и регистрирует для него запись в очереди таймеров. Вот этот класс из CoreLib целиком:

CoreLib 10.0.12: Task.DelayPromise
private class DelayPromise : Task
{
    private static readonly TimerCallback s_timerCallback = TimerCallback;

    private readonly ITimer _timer;

    internal DelayPromise(uint millisecondsDelay, TimeProvider timeProvider)
    {
        if (TplEventSource.Log.IsEnabled())
        {
            TplEventSource.Log.TraceOperationBegin(Id, "Task.Delay", 0L);
        }
        if (s_asyncDebuggingEnabled)
        {
            AddToActiveTasks(this);
        }
        if (millisecondsDelay == uint.MaxValue)
        {
            return;
        }
        if (timeProvider == TimeProvider.System)
        {
            _timer = new TimerQueueTimer(s_timerCallback, this, millisecondsDelay, uint.MaxValue, flowExecutionContext: false);
        }
        else
        {
            using (ExecutionContext.SuppressFlow())
            {
                _timer = timeProvider.CreateTimer(s_timerCallback, this, TimeSpan.FromMilliseconds(millisecondsDelay), Timeout.InfiniteTimeSpan);
            }
        }
        if (IsCompleted)
        {
            _timer.Dispose();
        }
    }

    private static void TimerCallback(object state)
    {
        ((DelayPromise)state).CompleteTimedOut();
    }

    private void CompleteTimedOut()
    {
        if (TrySetResult())
        {
            Cleanup();
            if (s_asyncDebuggingEnabled)
            {
                RemoveFromActiveTasks(this);
            }
            if (TplEventSource.Log.IsEnabled())
            {
                TplEventSource.Log.TraceOperationEnd(Id, AsyncCausalityStatus.Completed);
            }
        }
    }

    protected virtual void Cleanup()
    {
        _timer?.Dispose();
    }
}
  • Строка 1: DelayPromise — наследник Task. Это та самая задача, которую получает await.
  • Строки 3 и 5: у задачи есть только статический колбэк и поле с таймером. Ни потока, ни делегата с работой.
  • Строки 17–20: Task.Delay(Timeout.Infinite) (это uint.MaxValue) вообще не заводит таймер — такая задача ждёт вечно.
  • Строка 23: главное — запись в очереди таймеров: «через millisecondsDelay мс вызови s_timerCallback, передав ему this». Последний аргумент flowExecutionContext: false: таймеру не нужен контекст выполнения вызывающего.

То же самое видно и в машинном коде MoveNext этой лабораторной. JIT встроил вызов Task.Delay прямо в MoveNext:

asm: MoveNext DemoAsync, первый await (FullOpts, x64)
       mov      rcx, rbx
       call     [System.Console:WriteLine(System.String)]             ; "before await"
       mov      rcx, 0x7FF8952DB268
       call     [CORINFO_HELP_GET_NONGCSTATIC_BASE]
       mov      ecx, 0x80000D20
       mov      rbx, gword ptr [rcx]                                  ; TimeProvider.System
       mov      rcx, 0x7FF8952DB430
       call     CORINFO_HELP_NEWSFAST                                 ; выделить объект DelayPromise
       mov      rsi, rax
       mov      rcx, rsi
       mov      r8, rbx
       mov      edx, 200                                              ; 200 мс
       call     [System.Threading.Tasks.Task+DelayPromise:.ctor(uint,System.TimeProvider):this]
       mov      gword ptr [rbp-0x40], rsi                             ; awaiter2 = задача
       mov      rcx, gword ptr [rbp-0x40]
       test     dword ptr [rcx+0x34], 0x1600000                       ; IsCompleted: флаги завершения в Task
       jne      G_M000_IG20                                           ; готово → без остановки
       xor      edx, edx
       mov      rcx, bword ptr [rbp+0x10]
       mov      dword ptr [rcx], edx                                  ; <>1__state = 0
       lea      rcx, bword ptr [rcx+0x10]
       mov      rdx, gword ptr [rbp-0x40]
       call     CORINFO_HELP_CHECKED_ASSIGN_REF                       ; <>u__1 = awaiter2
       mov      rcx, bword ptr [rbp+0x10]
       lea      rdx, bword ptr [rcx+0x08]
       call     [AsyncTaskMethodBuilder`1[VoidTaskResult]:GetStateMachineBox[Program+<<<Main>$>g__DemoAsync|0_0>d](byref,byref)]
       mov      rdx, rax                                              ; бокс машины в куче
       lea      rcx, [rbp-0x40]
       call     [AsyncTaskMethodBuilder`1[VoidTaskResult]:AwaitUnsafeOnCompleted[TaskAwaiter](byref,IAsyncStateMachineBox)]
       jmp      G_M000_IG48                                           ; return
  • Строки 7–13: new DelayPromise(200, TimeProvider.System) — Task.Delay встроен целиком.
  • Строки 16–17: IsCompleted из строки 20 MoveNext — это одна проверка битов в поле состояния Task (смещение 0x34).
  • Строки 18–30: строки 22–25 MoveNext: состояние 0, сохранить awaiter, упаковать машину, подписаться, выйти. Подробно — в шаге 2.

Потоков на этом шаге не создано. Исключение одно: если это первый таймер в процессе, рантайм один раз заводит поток-будильник .NET Timer (шаг 3).

Задача создана, но не завершена: таймер только начал тикать. Значит, await должен подписаться на неё.

Шаг 2. t = 0, поток Main: await подписывается и уходит

Строка 20 MoveNext спрашивает IsCompleted и получает false. Дальше строки 22–25:

  • строка 22: <>1__state = 0 — запомнить, что остановились на первом await;
  • строка 23: <>u__1 = awaiter2 — сохранить awaiter в поле, ведь локальные переменные сейчас пропадут;
  • строка 24: AwaitUnsafeOnCompleted(ref awaiter2, ref this). Builder вызывает GetStateMachineBox (строка 26 листинга asm): создаёт бокс в куче, копирует в него машину со стека и записывает бокс в своё поле m_task (строки 25–29 листинга GetStateMachineBox). Затем кладёт бокс в список продолжений DelayPromise: «когда завершишься, вызови мой MoveNext»;
  • строка 25: return. MoveNext выходит, заглушка DemoAsync возвращает в строку 8 исходника незавершённый Task.

Стек DemoAsync исчез, всё её состояние лежит в боксе: номер состояния и awaiter.

Кто держит бокс, пока мы ждём

Бокс лежит в куче, а ссылка на него осталась только в двух местах: у вызывающего и в списке продолжений задачи Delay. Разберём по порядку.

Что с чем связалось на строке 24. awaiter2 — это TaskAwaiter, структура с единственным полем: ссылкой на задачу Delay(200) (DelayPromise). Это не «awaiter таймера»: запись таймера лежит внутри задачи, а не в awaiter'е. Машина к awaiter'у не привязывается. Builder кладёт бокс в поле m_continuationObject задачи DelayPromise (глава 4): когда она завершится, она вызовет MoveNext этого бокса.

Цепочка от корня. Запись таймера тоже лежит в куче. Её удерживает статическая очередь таймеров рантайма:

CoreLib 10.0.12: кто на кого ссылается (поля, сокращено)
1
2
3
4
5
6
7
8
9
// TimerQueue
public static TimerQueue[] Instances { get; } = CreateTimerQueues();   // статика: корень для сборщика мусора
private TimerQueueTimer _shortTimers;                                  // списки записей таймеров
private TimerQueueTimer _longTimers;

// TimerQueueTimer
internal TimerQueueTimer _next;                                        // следующая запись в списке
private readonly TimerCallback _timerCallback;                         // у Task.Delay — общий статический s_timerCallback
private readonly object _state;                                        // сюда попал DelayPromise (аргумент this из шага 1)
flowchart LR
    R["TimerQueue.Instances<br/>(статика, корень)"] --> T["TimerQueueTimer<br/>_state"]
    T --> D["DelayPromise (Task)<br/>m_continuationObject"]
    D --> B["бокс машины<br/>StateMachine: &lt;&gt;1__state = 0,<br/>&lt;&gt;u__1, builder"]
    B -. "&lt;&gt;u__1 → TaskAwaiter → m_task" .-> D
    M["Main: await DemoAsync()<br/>держит Task = тот же бокс"] --> B
  • Корень — статическая очередь таймеров. Пока таймер не сработал, запись в ней живёт, а с ней DelayPromise (через _state) и бокс (через m_continuationObject).
  • Колбэк таймера — статический делегат s_timerCallback. Он получает DelayPromise и ничего не знает о вашей машине. Бокс «просыпается» потому, что лежит в списке продолжений завершаемой задачи (шаг 5).
  • Кольцо «задача → бокс → <>u__1 → задача» сборщику мусора не мешает: он идёт от корней, а не считает ссылки.
  • Второй держатель — вызывающий. builder.m_task и есть бокс, поэтому Task, который DemoAsync вернул в Main, — тот же самый объект. Для fire-and-forget вызова это не важно: бокс удержит цепочка от таймера.
  • Машина на стеке после строки 24 больше не нужна. Её кадр исчезает на return (строка 25), все следующие вызовы MoveNext идут на копии в боксе.

Проверка (отдельная программа, не часть лабораторной). Задачу, которую вернул async-метод, нигде не храним, на неё только WeakReference:

WeakReference box = Start(out WeakReference delay);
Collect();                                     // три раза GC.Collect + WaitForPendingFinalizers
Console.WriteLine($"во время Delay: бокс жив = {box.IsAlive}");
var cont = typeof(Task).GetField("m_continuationObject", BindingFlags.Instance | BindingFlags.NonPublic)!
                       .GetValue((Task)delay.Target!);
Console.WriteLine($"в продолжениях Delay: {cont?.GetType().Name}, тот же объект: {ReferenceEquals(cont, box.Target)}");
await Task.Delay(600);
Collect();
Console.WriteLine($"после завершения: бокс жив = {box.IsAlive}");

static WeakReference Start(out WeakReference delay)
{
    var d = Task.Delay(300);
    delay = new WeakReference(d);
    return new WeakReference(Demo(d));         // результат никто не держит
}
static async Task Demo(Task d) { await d; Console.WriteLine("  продолжение выполнено"); }
1
2
3
4
во время Delay: бокс жив = True
в продолжениях Delay: AsyncStateMachineBox`1, тот же объект: True
  продолжение выполнено
после завершения: бокс жив = False

Задача Delay и бокс пережили сборку мусора, хотя на них никто не ссылался, кроме таймера, а после завершения продолжения были собраны. Бокс — это и есть объект, лежащий в списке продолжений Delay. Снято на Ubuntu 26.04, рантайм 10.0.12.

А что делает поток Main?

В консольном приложении он дальше просто ждёт. Компилятор обернул код верхнего уровня (строки 7–9) в метод <Main>$, а настоящую точку входа сделал обычным синхронным Main, который блокируется на GetResult(). Декомпиляция этой же сборки:

1
2
3
4
5
[SpecialName]
private static void Main(string[] args)
{
    <Main>$(args).GetAwaiter().GetResult();
}

И то же в IL:

.method private hidebysig specialname static void '<Main>' (string[] args) cil managed
{
    .entrypoint
    .locals init ([0] valuetype TaskAwaiter)

    IL_0000: ldarg.0
    IL_0001: call class Task Program::'<Main>$'(string[])
    IL_0006: callvirt instance valuetype TaskAwaiter Task::GetAwaiter()
    IL_000b: stloc.0
    IL_000c: ldloca.s 0
    IL_000e: call instance void TaskAwaiter::GetResult()
    IL_0013: ret
}
  • Строка 3 IL: .entrypoint — именно этот метод вызывает рантайм при старте.
  • Строка 7 IL: вызывается ваш async-код и сразу возвращает незавершённый Task.
  • Строка 11 IL: поток 2 блокируется здесь до конца программы.

Поэтому продолжение и не может вернуться на поток 2: тот занят ожиданием. В сервере (ASP.NET Core) такой поток вернулся бы в пул и обслуживал бы другие запросы.

Следующие 200 мс никакой ваш код не выполняется. Кто же тогда помнит, что через 200 мс надо что-то сделать?

Шаг 3. t = 0…200 мс: тишина, спит только будильник

Помнит поток .NET Timer. Рантайм создаёт его один раз, при первом таймере в процессе:

CoreLib 10.0.12: TimerQueue, создание будильника
1
2
3
4
Thread thread = new Thread(TimerThread);
thread.Name = ".NET Timer";
thread.IsBackground = true;
thread.UnsafeStart();

Фоновый поток (строка 3: не мешает процессу завершиться) с понятным именем — его видно в окне Threads отладчика. А вот всё, что он делает, — метод целиком:

CoreLib 10.0.12: TimerQueue.TimerThread
private static void TimerThread()
{
    AutoResetEvent autoResetEvent = s_timerEvent;
    Lock obj = s_timerEventLock;
    List<TimerQueue> list = s_scheduledTimersToFire;
    List<TimerQueue> list2;
    using (obj.EnterScope())
    {
        list2 = s_scheduledTimers;
    }
    int num = -1;
    while (true)
    {
        autoResetEvent.WaitOne(num);
        long tickCount = TickCount64;
        num = int.MaxValue;
        using (obj.EnterScope())
        {
            for (int num2 = list2.Count - 1; num2 >= 0; num2--)
            {
                TimerQueue timerQueue = list2[num2];
                long num3 = timerQueue._scheduledDueTimeMs - tickCount;
                if (num3 <= 0)
                {
                    timerQueue._isScheduled = false;
                    list.Add(timerQueue);
                    int num4 = list2.Count - 1;
                    if (num2 != num4)
                    {
                        list2[num2] = list2[num4];
                    }
                    list2.RemoveAt(num4);
                }
                else if (num3 < num)
                {
                    num = (int)num3;
                }
            }
        }
        if (list.Count > 0)
        {
            foreach (TimerQueue item in list)
            {
                ThreadPool.UnsafeQueueHighPriorityWorkItemInternal(item);
            }
            list.Clear();
        }
        if (num == int.MaxValue)
        {
            num = -1;
        }
    }
}
  • Строка 14: поток спит на событии с таймаутом num мс. Сейчас таймаут — 200 мс до нашего срока. Если кто-то заведёт таймер с более ранним сроком, событие разбудит поток раньше.
  • Строки 19–38: проснувшись, поток проходит по очередям таймеров. Те, чей срок наступил (строка 23), откладывает в список list (строка 26). Для остальных считает, сколько спать до ближайшего (строки 34–37).
  • Строки 40–47: истёкшие очереди отдаются в пул потоков как работа с высоким приоритетом. Сами колбэки этот поток не выполняет.

Сейчас ни один поток не работает на DemoAsync. Спящий будильник обслуживает и один Delay, и 10 000 (§1.6).

Проходит 200 мс, WaitOne в строке 14 возвращается по таймауту, и будильник просыпается.

Шаг 4. t = 200 мс, поток .NET Timer: «звонок» уходит в пул

Будильник находит нашу очередь таймеров истёкшей (строки 22–26 листинга TimerThread) и ставит её в пул (строка 44), после чего снова засыпает в строке 14. Он не выполняет колбэки сам, поэтому медленный колбэк одного таймера не задержит срабатывание остальных: будильник всегда свободен.

Работа лежит в очереди пула. Её забирает первый свободный поток пула — в нашем прогоне №5.

Шаг 5. t ≈ 200 мс, поток пула 5: срабатывает таймер, завершается задача

Поток 5 выполняет TimerQueue.FireNextTimers(). Метод длинный, ниже его суть: многоточием ... заменены строки про периодические таймеры и пересчёт следующего срока.

CoreLib 10.0.12: TimerQueue.FireNextTimers (сокращено)
private void FireNextTimers()
{
    TimerQueueTimer timerQueueTimer = null;
    using (SharedLock.EnterScope())
    {
        ...
        TimerQueueTimer timerQueueTimer2 = _shortTimers;
        ...
            while (timerQueueTimer2 != null)
            {
                ...
                long num3 = timerQueueTimer2._dueTime - num2;
                if (num3 <= 0)
                {
                    ...
                    DeleteTimer(timerQueueTimer2);
                    if (timerQueueTimer == null)
                    {
                        timerQueueTimer = timerQueueTimer2;
                    }
                    else
                    {
                        ThreadPool.UnsafeQueueUserWorkItemInternal(timerQueueTimer2, preferLocal: false);
                    }
                }
                ...
            }
        ...
    }
    timerQueueTimer?.Fire();
}
  • Строки 12–13: таймер истёк.
  • Строка 16: одноразовый таймер (как у Delay) удаляется из очереди.
  • Строки 17–20: первый истёкший таймер запоминается, чтобы выполнить его самому.
  • Строки 21–24: каждый следующий ставится в пул отдельной задачей. Именно это даст всплеск потоков в §1.6.
  • Строка 30: первый таймер срабатывает прямо на этом потоке.

Fire() вызывает колбэк таймера — статический TimerCallback из DelayPromise (строки 38–41 листинга DelayPromise), а тот — CompleteTimedOut (строки 43–57). Ключевая строка 45: TrySetResult().

TrySetResult переводит задачу в RanToCompletion и обходит список её продолжений. А там лежит наш бокс из шага 2.

Шаг 6. t ≈ 200 мс, тот же поток 5: продолжение DemoAsync

Продолжение вызывается прямо внутри TrySetResult, синхронно, на том же потоке 5. Бокс вызывает MoveNext:

  • строка 10: num = <>1__state — там 0, сохранённый в шаге 2;
  • строки 28–32: ветка case 0: достать awaiter из поля, очистить поле, вернуть состояние -1, перейти к IL_009e;
  • строка 44: GetResult() — await завершён (при ошибке исключение было бы брошено здесь);
  • строка 45: печать — это строка 15 исходника:
  after Delay    : thread 5

Синхронно, а не через очередь, потому что в консоли нет SynchronizationContext, который потребовал бы вернуться в «свой» поток. Как выбирается место продолжения, разберём в главе 5.

Дальше тот же поток 5 идёт по строкам 46–57 (await Task.CompletedTask не останавливается) и доходит до Task.Yield() в строке 58. Тот уже по-настоящему уходит через очередь пула — отсюда возможная смена потока в строке 21 исходника.

Вся цепочка на одной схеме

sequenceDiagram
    autonumber
    participant M as Поток Main (2)
    participant D as DelayPromise + таймер
    participant T as Поток «.NET Timer»
    participant P as Поток пула (5)

    rect rgba(120,120,200,.08)
    Note over M,D: t = 0
    M->>D: MoveNext стр. 19: Task.Delay(200) → задача и запись таймера
    M->>D: MoveNext стр. 22–25: бокс → в список продолжений, return
    Note over M: Main ждёт в GetResult()
    end

    rect rgba(120,120,120,.06)
    Note over T: t = 0…200 мс: WaitOne, ни один поток не занят DemoAsync
    end

    rect rgba(80,180,140,.10)
    Note over T,P: t = 200 мс
    T->>P: срок наступил → очередь таймеров в пул
    P->>D: FireNextTimers → TimerCallback → CompleteTimedOut
    D->>P: TrySetResult → бокс.MoveNext()
    Note over P: MoveNext case 0 → стр. 45: «after Delay: thread 5»
    end

Одной строкой: .NET Timer → очередь пула → FireNextTimers → TimerCallback → TrySetResult → MoveNext вашего метода, и всё после шага 4 — на одном и том же потоке пула.

Итого

Во время ожидания не занят ни один поток: есть объект DelayPromise, запись в очереди таймеров и один на весь процесс спящий поток .NET Timer. Поток пула появляется только в момент завершения: он выполняет колбэк таймера, а заодно и ваше продолжение. Поэтому после await Task.Delay вы оказываетесь на потоке пула.

На Windows рантайм умеет работать и поверх пула потоков ОС (настройка UseWindowsThreadPool). Тогда таймер заводится через CreateThreadpoolTimer, без потока .NET Timer. По умолчанию используется собственный пул .NET (проверено на Windows 11, рантайм 10.0.12: потоки пула называются .NET TP Worker, UseWindowsThreadPool выключен; включить можно переменной DOTNET_ThreadPool_UseWindowsThreadPool=1).

1.5. Task как работа и Task как обещание: что внутри

Вторая часть final/Program.cs запускает три задачи — работу, обещание с ручным завершением и Task.Delay — и печатает их настоящие типы:

final/Program.cs
Console.WriteLine("== 2. Task как работа и Task как обещание ==");
Task<int> work = Task.Run(() =>
{
    Console.WriteLine($"  тело Task.Run  : {Where()}");          // код, который занимает поток
    return 1;
});
var tcs = new TaskCompletionSource<int>();
using var timer = new Timer(_ => tcs.SetResult(2), null, 100, Timeout.Infinite);
Task delay = Task.Delay(100);
Console.WriteLine($"  обещание до    : {tcs.Task.Status}");     // WaitingForActivation: кода внутри нет вообще
await Task.WhenAll(work, tcs.Task, delay);
Console.WriteLine($"  обещание после : {tcs.Task.Status}, результат {tcs.Task.Result}");
Console.WriteLine($"  типы           : Task.Run -> {Name(work)}, TCS -> {Name(tcs.Task)}, Task.Delay -> {Name(delay)}");

Вывод:

1
2
3
4
5
== 2. Task как работа и Task как обещание ==
  тело Task.Run  : thread  8 (pool=True)
  обещание до    : WaitingForActivation
  обещание после : RanToCompletion, результат 2
  типы           : Task.Run -> Task<Int32>, TCS -> Task<Int32>, Task.Delay -> Task+DelayPromise
  • Task.Run (строки 17–21) — обычный Task<int>, внутри которого лежит ваш делегат. Пул выполняет его на своём потоке (строка 2 вывода): это «работа».
  • TaskCompletionSource<int>.Task (строки 22–23) — тоже обычный Task<int>, но без делегата. Его завершает тот, кто вызовет SetResult: здесь — колбэк таймера System.Threading.Timer через 100 мс (строка 23). До этого задача в состоянии WaitingForActivation (строка 25 кода, строка 3 вывода) и кода внутри не содержит вообще.
  • Task.Delay (строка 24) — свой наследник Task, DelayPromise: специализированное обещание с таймером. Его устройство — в листинге шага 1 §1.4.

Один и тот же тип Task<int> может означать и «работу», и «обещание». Различие не в типе, а в том, кто и как его завершает.

1.6. 10 000 ожиданий: потоки нужны не для ожидания, а для продолжений

Третья часть final/Program.cs запускает 10 000 Task.Delay(500) одновременно и смотрит на пул потоков до, во время и после ожидания:

final/Program.cs
Console.WriteLine("== 3. 10 000 ожиданий ==");
int before = ThreadPool.ThreadCount;
var continuationThreads = new ConcurrentDictionary<int, byte>();
var sw = Stopwatch.StartNew();
Task all = Task.WhenAll(Enumerable.Range(0, 10_000).Select(async _ =>
{
    await Task.Delay(500);
    continuationThreads.TryAdd(Environment.CurrentManagedThreadId, 0);   // кто выполнил продолжение
}));
await Task.Delay(250);
int during = ThreadPool.ThreadCount;                                      // середина ожидания
await all;
Console.WriteLine($"  10 000 x Delay(500): {sw.ElapsedMilliseconds} мс");
Console.WriteLine($"  потоков пула: до {before}, во время ожидания {during}, после {ThreadPool.ThreadCount}");
Console.WriteLine($"  продолжения выполнили {continuationThreads.Count} разных потоков");
  • Строка 32: сколько потоков в пуле до начала.
  • Строки 35–39: 10 000 асинхронных лямбд. Каждая ждёт 500 мс, а потом записывает, на каком потоке выполнилось её продолжение (строка 38).
  • Строки 40–41: через 250 мс, в середине ожидания, снова считаем потоки.
  • Строка 42: дожидаемся всех.

Вывод:

1
2
3
4
== 3. 10 000 ожиданий ==
  10 000 x Delay(500): 524 мс
  потоков пула: до 3, во время ожидания 3, после 9
  продолжения выполнили 8 разных потоков

Факт: во время ожидания потоков не прибавилось

Пока 10 000 задач ждут, в пуле те же 3 потока (строка 41 кода, строка 3 вывода). Рост до 8–9 происходит после, в момент, когда 10 000 таймеров истекают почти одновременно.

Это прямо следует из FireNextTimers (листинг шага 5 §1.4): первый истёкший таймер выполняется на текущем потоке (строка 30), а каждый следующий ставится в пул отдельной задачей (строки 21–24). Пул получает всплеск из 10 000 коротких работ. Пока потоков меньше MinThreads (по умолчанию — число ядер, здесь 8), пул создаёт их сразу, без задержки. Выше минимума он добавлял бы их медленно (глава 13).

В первой версии курса я написал «стало 9» и не объяснил, откуда рост. Замер «во время» показал, что ожидание само по себе потоков не требует.

Сравните с 10 000 Task.Run(() => Thread.Sleep(500)): там каждый «ожидающий» занимал бы поток, и при 8 ядрах это растянулось бы на минуты (10 000 × 0,5 с ÷ 8 потоков ≈ 10 минут, пока пул медленно добавляет потоки). Это эксперимент главы 13.

1.7. Итоги

  • async — указание компилятору построить машину состояний. Потоков он не создаёт.
  • await на незавершённой задаче запоминает состояние, подписывает машину на завершение задачи и возвращает управление. Поток свободен.
  • «Ожидание» — это объект (DelayPromise, сокет, TaskCompletionSource) плюс механизм, который его завершит: таймер, порт завершения ввода-вывода, чужой код.
  • Поток нужен в момент завершения: продолжение выполняется на том потоке, который завершил задачу (если нет контекста синхронизации, глава 5).
  • Номера потоков и смена потока после Yield — не контракт.

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

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

start/Program.cs
// Глава 01, §1.3. На каком потоке продолжается метод после await?
//
// 1. Не запуская, впишите номер потока в каждый комментарий PREDICT.
// 2. Запустите: dotnet run -c Release
// 3. Сравните с фактом и прочитайте разбор в README.md (§1.3).

Console.WriteLine($"Main start       : thread {Environment.CurrentManagedThreadId}");   // PREDICT:
await DemoAsync();
Console.WriteLine($"Main end         : thread {Environment.CurrentManagedThreadId}");   // PREDICT:

static async Task DemoAsync()
{
    Console.WriteLine($"  before await   : thread {Environment.CurrentManagedThreadId}"); // PREDICT:
    await Task.Delay(200);
    Console.WriteLine($"  after Delay    : thread {Environment.CurrentManagedThreadId}"); // PREDICT:

    await Task.CompletedTask;                       // уже завершён
    Console.WriteLine($"  after Completed: thread {Environment.CurrentManagedThreadId}"); // PREDICT:

    await Task.Yield();                             // принудительно уйти и вернуться
    Console.WriteLine($"  after Yield    : thread {Environment.CurrentManagedThreadId}"); // PREDICT:
}
final/Program.cs
// Глава 1, итог. Три опыта:
//   1) потоки до и после await (§1.3);
//   2) Task как работа (Task.Run) против Task как обещания (TaskCompletionSource, Task.Delay) — и какие это типы на самом деле;
//   3) 10 000 одновременных ожиданий: потоки нужны не для ожидания, а для продолжений;
//   4) бокс машины состояний: что за объект на самом деле возвращает async-метод.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;

Console.WriteLine("== 1. Потоки до и после await ==");
Console.WriteLine($"Main start       : {Where()}");
await DemoAsync();
Console.WriteLine($"Main end         : {Where()}");

Console.WriteLine();
Console.WriteLine("== 2. Task как работа и Task как обещание ==");
Task<int> work = Task.Run(() =>
{
    Console.WriteLine($"  тело Task.Run  : {Where()}");          // код, который занимает поток
    return 1;
});
var tcs = new TaskCompletionSource<int>();
using var timer = new Timer(_ => tcs.SetResult(2), null, 100, Timeout.Infinite);
Task delay = Task.Delay(100);
Console.WriteLine($"  обещание до    : {tcs.Task.Status}");     // WaitingForActivation: кода внутри нет вообще
await Task.WhenAll(work, tcs.Task, delay);
Console.WriteLine($"  обещание после : {tcs.Task.Status}, результат {tcs.Task.Result}");
Console.WriteLine($"  типы           : Task.Run -> {Name(work)}, TCS -> {Name(tcs.Task)}, Task.Delay -> {Name(delay)}");

Console.WriteLine();
Console.WriteLine("== 3. 10 000 ожиданий ==");
int before = ThreadPool.ThreadCount;
var continuationThreads = new ConcurrentDictionary<int, byte>();
var sw = Stopwatch.StartNew();
Task all = Task.WhenAll(Enumerable.Range(0, 10_000).Select(async _ =>
{
    await Task.Delay(500);
    continuationThreads.TryAdd(Environment.CurrentManagedThreadId, 0);   // кто выполнил продолжение
}));
await Task.Delay(250);
int during = ThreadPool.ThreadCount;                                      // середина ожидания
await all;
Console.WriteLine($"  10 000 x Delay(500): {sw.ElapsedMilliseconds} мс");
Console.WriteLine($"  потоков пула: до {before}, во время ожидания {during}, после {ThreadPool.ThreadCount}");
Console.WriteLine($"  продолжения выполнили {continuationThreads.Count} разных потоков");

Console.WriteLine();
Console.WriteLine("== 4. Бокс машины состояний ==");
Task pending = WaitAsync(300);          // приостановится на await: машина уедет в кучу
Describe("ждёт", pending);
await pending;
Describe("завершена", pending);         // мы внутри SetResult: бокс ещё не очистил себя
await Task.Delay(50);
Describe("позже", pending);             // MoveNext бокса вернулся и обнулил StateMachine
Describe("без паузы", WaitAsync(0));    // завершится синхронно: бокс не нужен
Console.WriteLine($"  без паузы дважды — один и тот же объект: {ReferenceEquals(WaitAsync(0), WaitAsync(0))}");

static async Task WaitAsync(int ms)
{
    if (ms > 0)
        await Task.Delay(ms);
}

static void Describe(string title, Task task)
{
    Type type = task.GetType();
    Console.WriteLine($"  {title,-9}: {TypeName(type)}, Status={task.Status}");
    FieldInfo? field = type.GetField("StateMachine");             // поле бокса с копией машины
    if (field is null)
        return;
    object machine = field.GetValue(task)!;
    foreach (FieldInfo f in machine.GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
        Console.WriteLine($"             StateMachine.{f.Name} = {f.GetValue(machine)}");
}

static string TypeName(Type type)
{
    string Short(Type t) => t.IsGenericType ? t.Name[..t.Name.IndexOf('`')] : t.Name;
    Type[] args = type.GetGenericArguments();
    if (!type.IsNested || !type.DeclaringType!.IsGenericTypeDefinition)
        return args.Length == 0 ? type.Name : $"{Short(type)}<{string.Join(", ", args.Select(a => a.Name))}>";
    int outer = type.DeclaringType.GetGenericArguments().Length;    // у вложенного типа сначала идут аргументы внешнего
    return $"{Short(type.DeclaringType)}<{string.Join(", ", args[..outer].Select(a => a.Name))}>"
         + $".{Short(type)}<{string.Join(", ", args[outer..].Select(a => a.Name))}>";
}

static string Where() =>
    $"thread {Environment.CurrentManagedThreadId,2} (pool={Thread.CurrentThread.IsThreadPoolThread})";

static string Name(Task t)
{
    Type type = t.GetType();
    string name = type.IsNested ? $"{type.DeclaringType!.Name}+{type.Name}" : type.Name;
    return type.IsGenericType
        ? $"{name[..name.IndexOf('`')]}<{string.Join(", ", type.GetGenericArguments().Select(a => a.Name))}>"
        : name;
}

static async Task DemoAsync()
{
    Console.WriteLine($"  before await   : {Where()}");
    await Task.Delay(200);
    Console.WriteLine($"  after Delay    : {Where()}");

    await Task.CompletedTask;
    Console.WriteLine($"  after Completed: {Where()}");

    await Task.Yield();
    Console.WriteLine($"  after Yield    : {Where()}");
}