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

5. Куда вернётся продолжение

О главе

Цель: понять, где и кем выполняется код после await: на каком потоке, через какую очередь, внутри чьего вызова. Разобрать по коду рантайма, как это решается (SynchronizationContext, TaskScheduler, инлайн), что на самом деле делает ConfigureAwait, и чем опасны синхронные продолжения.

Лабораторная: start/ — модель UI-потока (свой SynchronizationContext) и TaskCompletionSource. Нужно предсказать потоки и выполнить два TODO. final/ — все маршруты продолжения (§5.2–5.4), ConfigureAwait и ConfigureAwaitOptions (§5.5), синхронные продолжения: несколько подписчиков и защита стека (§5.6). Код — в конце главы.

Визуализация: Маршрут продолжения — Меняйте условия: контекст, планировщик, ConfigureAwait, способ завершения, и смотрите, какой маршрут выберет await.

Статус: ✅ проверено на Ubuntu 26.04 (2 ядра), рантайм 10.0.12. Листинги CoreLib — декомпиляция System.Private.CoreLib 10.0.12 (.\tools\disasm.ps1 -Assembly corelib -Type …), машинный код снят на Linux x64 и на Windows x64. Перепроверено на Windows 11 (8 ядер); различия — во вкладках «Linux» и «Windows».

После await на незавершённой задаче метод «засыпает», а его продолжение (следующий кусок MoveNext) потом кто-то должен вызвать. Вопрос главы: кто и где. Ответ складывается из трёх вещей:

  1. что было в момент подписки на потоке, который дошёл до await: SynchronizationContext и TaskScheduler;
  2. что сказали await через ConfigureAwait;
  3. что происходит в момент завершения задачи: кто её завершил, с какими опциями, насколько глубок стек.

5.1. Где принимается решение

Возьмём await из лабораторной (final/Program.cs, строка 50):

final/Program.cs
await failed.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);

Компилятор превращает его в ту же конструкцию, что и любой await (C# без сахара, .\tools\disasm.ps1 chapters\05-continuations\final -Mode cs, фрагмент MoveNext верхнего уровня):

Во что превращается await failed.ConfigureAwait(…) (из MoveNext)
<failed>5__3 = Task.FromException(new InvalidOperationException("boom"));
awaiter2 = <failed>5__3.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing).GetAwaiter();
if (!awaiter2.IsCompleted)
{
    num = (<>1__state = 6);
    <>u__2 = awaiter2;
    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
    return;
}
goto IL_03f8;
...
IL_03f8:
awaiter2.GetResult();
  • Строка 2: ConfigureAwait возвращает не задачу, а структуру ConfiguredTaskAwaitable, у неё свой awaiter — ConfiguredTaskAwaitable.ConfiguredTaskAwaiter. Это просто другой тип awaiter'а (паттерн awaitable из главы 3), а не «режим» задачи.
  • Строка 6: под этот тип awaiter'а у машины своё поле <>u__2 (для обычного TaskAwaiter — <>u__1).
  • Строка 7: подписка идёт, как всегда, через builder. Ни в коде компилятора, ни в машине состояний нет ни слова о потоках и контекстах. Всё решается внутри AwaitUnsafeOnCompleted и дальше, в рантайме.

Builder: какой awaiter пришёл

CoreLib 10.0.12: AsyncTaskMethodBuilder<TResult>.AwaitUnsafeOnCompleted (начало)
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;
    }
    ...                                   // IStateMachineBoxAwareAwaiter (Task.Yield), прочие awaiter'ы
}
  • Строки 4–8: обычный await task (TaskAwaiter, TaskAwaiter<T>) — «вернись в захваченный контекст»: continueOnCapturedContext: true.
  • Строки 10–15: await task.ConfigureAwait(…) — тот же вызов, но флаг берётся из опций: бит ContinueOnCapturedContext (= 1). ConfigureAwait(false) — это опции None, то есть false.
  • Строки 3–4, 9–10: странное default(TAwaiter) != null — приём для JIT. Для структуры-awaiter'а это константа true, проверка is для конкретного типа тоже вычисляется при компиляции, и от метода остаётся одна ветка. Это видно в машинном коде ниже.

Под капотом: от builder'а для ConfiguredTaskAwaiter остаются три инструкции

.\tools\disasm.ps1 chapters\05-continuations\final -Mode asm -Method '*AsyncTaskMethodBuilder*:AwaitUnsafeOnCompleted'. Логика одна, листинги на ОС разные: соглашение о вызовах другое (Linux — System V: аргументы в rdi, rsi, rdx; Windows x64: rcx, rdx, r8), а в Linux-версии есть ещё и кадр rbp.

JIT, FullOpts: AwaitUnsafeOnCompleted[ConfiguredTaskAwaiter]
G_M000_IG01:
       push     rbp
       mov      rbp, rsp
G_M000_IG02:
       mov      edx, dword ptr [rdi+0x08]
       and      edx, 1
       mov      rdi, gword ptr [rdi]
G_M000_IG03:
       pop      rbp
       tail.jmp [System.Runtime.CompilerServices.TaskAwaiter:UnsafeOnCompletedInternal(System.Threading.Tasks.Task,System.Runtime.CompilerServices.IAsyncStateMachineBox,bool)]
; Total bytes of code 20
  • Строки 5–6: edx = m_options & 1 — третий аргумент, continueOnCapturedContext. Поле m_options лежит в awaiter'е по смещению 8, сразу за ссылкой на задачу.
  • Строка 7: rdi = m_task — первый аргумент. Второй (box) уже лежит в rsi.
  • Строка 10: переход в UnsafeOnCompletedInternal без возврата (хвостовой вызов). Никаких is, никаких других веток.
JIT, FullOpts: AwaitUnsafeOnCompleted[ConfiguredTaskAwaiter]
1
2
3
4
5
6
7
8
G_M000_IG01:
G_M000_IG02:
       mov      r8d, dword ptr [rcx+0x08]
       and      r8d, 1
       mov      rcx, gword ptr [rcx]
G_M000_IG03:
       tail.jmp [System.Runtime.CompilerServices.TaskAwaiter:UnsafeOnCompletedInternal(System.Threading.Tasks.Task,System.Runtime.CompilerServices.IAsyncStateMachineBox,bool)]
; Total bytes of code 17
  • Строки 3–4: r8d = m_options & 1 — третий аргумент, continueOnCapturedContext. Смещение поля то же, 8.
  • Строка 5: rcx = m_task — первый аргумент. Второй (box) уже лежит в rdx.
  • Строка 7: тот же хвостовой переход. Кадра нет вообще (ни push rbp, ни pop rbp), поэтому 17 байт против 20.

Вывод одинаков на обеих ОС: три инструкции, одна ветка, хвостовой вызов.

Для обычного TaskAwaiter в том же файле 119 байт на Linux и 112 на Windows: туда встроен UnsafeOnCompletedInternal с проверкой «включена ли трассировка TPL», а затем tail.jmp в Task.UnsafeSetContinuationForAwait с edx = 1 (на Windows r8d = 1).

UnsafeSetContinuationForAwait: три маршрута

UnsafeOnCompletedInternal при выключенной трассировке сразу зовёт метод задачи, на которую подписываемся:

CoreLib 10.0.12: Task.UnsafeSetContinuationForAwait
internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext)
{
    if (continueOnCapturedContext)
    {
        SynchronizationContext current = SynchronizationContext.Current;
        TaskContinuation taskContinuation;
        if (current != null && current.GetType() != typeof(SynchronizationContext))
        {
            taskContinuation = new SynchronizationContextAwaitTaskContinuation(current, stateMachineBox.MoveNextAction, flowExecutionContext: false);
        }
        else
        {
            TaskScheduler internalCurrent = TaskScheduler.InternalCurrent;
            if (internalCurrent == null || internalCurrent == TaskScheduler.Default)
            {
                goto IL_0054;
            }
            taskContinuation = new TaskSchedulerAwaitTaskContinuation(internalCurrent, stateMachineBox.MoveNextAction, flowExecutionContext: false);
        }
        if (!AddTaskContinuation(taskContinuation, addBeforeOthers: false))
        {
            taskContinuation.Run(this, canInlineContinuationTask: false);
        }
        return;
    }
    goto IL_0054;
    IL_0054:
    if (!AddTaskContinuation(stateMachineBox, addBeforeOthers: false))
    {
        ThreadPool.UnsafeQueueUserWorkItemInternal(stateMachineBox, preferLocal: true);
    }
}

Это и есть весь алгоритм выбора места. Он выполняется в момент подписки, на потоке, который дошёл до await:

Маршрут Условие Что кладётся в продолжения задачи Строки
1. Контекст синхронизации continueOnCapturedContext и у потока есть SynchronizationContext не базового типа обёртка SynchronizationContextAwaitTaskContinuation: «при завершении сделай Post в этот контекст» 5–10
2. Планировщик контекста нет, но код выполняется внутри задачи с нестандартным TaskScheduler обёртка TaskSchedulerAwaitTaskContinuation: «запусти в этом планировщике» 13–18
3. Без контекста ConfigureAwait(false) или ни контекста, ни планировщика сам бокс машины, без обёрток и аллокаций 27–31
  • Строка 7: GetType() != typeof(SynchronizationContext). Экземпляр базового класса SynchronizationContext считается «контекста нет». Причина — в §5.2.
  • Строка 13: TaskScheduler.InternalCurrent — планировщик задачи, которая сейчас выполняется на этом потоке (null, если код вызван не из задачи). Публичный TaskScheduler.Current — то же самое, но вместо null возвращает Default.
  • Строки 20–23, 28–31: задача успела завершиться между проверкой IsCompleted в MoveNext и подпиской. Тогда продолжение запускается сразу, но не синхронно: через Post / планировщик (canInlineContinuationTask: false) или очередь пула. Синхронный вызов здесь был бы ошибкой: мы ещё внутри AwaitUnsafeOnCompleted текущего MoveNext, и следующий MoveNext начался бы до того, как текущий дошёл до return.
  • Маршрут 3 — тот самый случай из главы 4: бокс лежит прямо в m_continuationObject, а решение «инлайн или пул» принимается позже, при завершении (§5.4).
flowchart TD
    A["await task<br/>(задача не завершена)"] --> B{"continueOnCapturedContext?<br/>(нет ConfigureAwait(false))"}
    B -- нет --> N
    B -- да --> C{"SynchronizationContext.Current<br/>есть и не базовый?"}
    C -- да --> S["маршрут 1: при завершении<br/>Post в этот контекст"]
    C -- нет --> D{"выполняемся внутри задачи<br/>с нестандартным TaskScheduler?"}
    D -- да --> T["маршрут 2: при завершении<br/>задача в этом планировщике"]
    D -- нет --> N["маршрут 3: бокс в продолжениях задачи"]
    N --> E{"при завершении: инлайн разрешён?<br/>(нет RunContinuationsAsynchronously,<br/>стек не глубокий, у завершающего<br/>потока нет своего контекста)"}
    E -- да --> I["MoveNext прямо внутри<br/>SetResult завершающего потока"]
    E -- нет --> P["очередь пула потоков"]

Опыт: четыре маршрута

Первая часть final/Program.cs:

final/Program.cs
Console.WriteLine("== 1. Маршрут продолжения ==");
Console.WriteLine($"  консоль, до await          : {Here.Now}");
await Task.Delay(50);
Console.WriteLine($"  консоль, после await Delay : {Here.Now}");

var ui = new UiThread();
await ui.RunAsync(async () =>
{
    Console.WriteLine($"  UI, до await               : {Here.Now}");
    await Task.Delay(50);
    Console.WriteLine($"  UI, после await Delay      : {Here.Now}");
});

SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
Console.WriteLine($"  базовый контекст, до await : {Here.Now}");
await Task.Delay(50);
Console.WriteLine($"  базовый контекст, после    : {Here.Now}");

var exclusive = new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler;
await Task.Factory.StartNew(async () =>
{
    Console.WriteLine($"  свой планировщик, до await : {Here.Now}");
    await Task.Delay(50);
    Console.WriteLine($"  свой планировщик, после    : {Here.Now}");
}, CancellationToken.None, TaskCreationOptions.None, exclusive).Unwrap();

UiThread — модель UI-потока: отдельный поток с именем UI, очередь и SynchronizationContext, у которого Post кладёт делегат в эту очередь (§5.2). Here.Now печатает номер и вид потока, а также контекст и планировщик, если они не «по умолчанию».

Предскажите

В start/Program.cs у строк с Here.Now стоят комментарии PREDICT. Впишите, на каком потоке напечатается каждая (основной, пул, UI), и только потом запускайте:

cd chapters
dotnet run -c Release --project 05-continuations\start
== 1. Маршрут продолжения ==
  консоль, до await          : поток  1 (основной)
  консоль, после await Delay : поток  5 (пул)
  UI, до await               : поток  8 (UI), SynchronizationContext=UiThread
    [Post в очередь UI, вызван на: поток  5 (пул)]
  UI, после await Delay      : поток  8 (UI), SynchronizationContext=UiThread
  базовый контекст, до await : поток  5 (пул), SynchronizationContext=SynchronizationContext
  базовый контекст, после    : поток  5 (пул)
  свой планировщик, до await : поток  5 (пул), TaskScheduler=ConcurrentExclusiveTaskScheduler
  свой планировщик, после    : поток  5 (пул), TaskScheduler=ConcurrentExclusiveTaskScheduler
  • Строки 2–3 вывода (строки 7–9 кода): консоль. Контекста нет — маршрут 3. Продолжение выполнил поток пула, который обработал срабатывание таймера Task.Delay (подробно — глава 1, §1.4).
  • Строки 4–6 (строки 12–17): на «UI-потоке» — маршрут 1. Таймер сработал на потоке пула 5, тот сделал Post (строка 5 вывода печатает сам UiThread.Post), и продолжение выполнил поток UI, тот же, что до await. Ровно так WinForms/WPF возвращают вас в UI-поток после await.
  • Строки 7–8 (строки 19–22): new SynchronizationContext() поставлен, но после await его нет: контекст базового типа маршрут 1 не включает (строка 7 листинга UnsafeSetContinuationForAwait).
  • Строки 9–10 (строки 24–30): лямбда запущена задачей в ConcurrentExclusiveSchedulerPair.ExclusiveScheduler. Внутри неё TaskScheduler.Current — этот планировщик, и продолжение после await Task.Delay снова выполнено им (маршрут 2).
Факт: маршруты одинаковы на Linux и Windows, номера потоков нет (раскройте после предсказания)

Ubuntu 26.04 (2 ядра, десять прогонов start и final) и Windows 11 (8 ядер, два прогона final), рантайм 10.0.12. Маршруты одинаковы на обеих ОС и во всех прогонах, номера потоков пула меняются (Linux: 5, 6 или 7; Windows: 5, 7, 8, 9).

  • Основной поток: на Linux 1, на Windows 2: на Windows номер 1 достаётся потоку финализатора (глава 1, §1.3). Номера потоков — не контракт.
  • Строка 8: после await с базовым контекстом поток бывает и тем же (5), и другим (6, 7). Но контекста на нём нет никогда: рантайм не «переносит» SynchronizationContext через await, а потоки пула работают без него.

Интерактивная схема

Меняйте условия: контекст, планировщик, ConfigureAwait, способ завершения, и смотрите, какой маршрут выберет await. Открыть на весь экран.

5.2. SynchronizationContext: «выполни это там, где надо»

SynchronizationContext — абстракция «места выполнения» с двумя главными методами. Базовый класс целиком полезен для понимания:

CoreLib 10.0.12: SynchronizationContext (главное)
public static SynchronizationContext? Current => Thread.CurrentThread._synchronizationContext;

public static void SetSynchronizationContext(SynchronizationContext? syncContext)
{
    Thread.CurrentThread._synchronizationContext = syncContext;
}

public virtual void Send(SendOrPostCallback d, object? state)
{
    d(state);
}

public virtual void Post(SendOrPostCallback d, object? state)
{
    ThreadPool.QueueUserWorkItem((KeyValuePair<SendOrPostCallback, object> s) =>
    {
        s.Key(s.Value);
    }, new KeyValuePair<SendOrPostCallback, object>(d, state), preferLocal: false);
}
  • Строки 1–6: контекст — просто поле потока. Никакой магии: SetSynchronizationContext записывает объект в текущий поток, Current читает его.
  • Строки 8–11: Send — выполнить синхронно и дождаться.
  • Строки 13–19: Post — выполнить асинхронно. Базовая реализация отправляет делегат в пул потоков, то есть ведёт себя ровно как «контекста нет». Поэтому await и пропускает базовый тип (строка 7 UnsafeSetContinuationForAwait): обёртка и Post дали бы то же, что маршрут 3, только дороже.

Настоящие контексты переопределяют Post:

Контекст Post делает Кто ставит
WindowsFormsSynchronizationContext Control.BeginInvoke — сообщение в очередь окна WinForms для UI-потока
DispatcherSynchronizationContext Dispatcher.BeginInvoke WPF
AspNetSynchronizationContext выполнение «внутри запроса», по одному продолжению за раз старый ASP.NET (.NET Framework)
нет — консоль, ASP.NET Core, потоки пула, сервисы

Наш UiThread из лабораторной — такой же по устройству:

final/UiThread.cs
    public override void Post(SendOrPostCallback d, object? state)
    {
        Console.WriteLine($"    [Post в очередь UI, вызван на: {Here.Now}]");
        _queue.Add((d, state));
    }

    // Запустить async-обработчик на потоке UI и дождаться его завершения.
    public Task RunAsync(Func<Task> handler)
    {
        var started = new TaskCompletionSource<Task>(TaskCreationOptions.RunContinuationsAsynchronously);
        _queue.Add((_ => started.SetResult(handler()), null));
        return started.Task.Unwrap();
    }

    private void Loop()
    {
        SetSynchronizationContext(this);
        foreach (var (callback, state) in _queue.GetConsumingEnumerable())
            callback(state);
    }
  • Строки 16–20: Post только кладёт делегат в очередь. Выполнит его поток UI.
  • Строки 30–35: цикл потока UI: поставить себя контекстом этого потока (строка 32) и выполнять делегаты из очереди по одному. Цикл сообщений WinForms и диспетчер WPF устроены так же.
  • Строки 23–28: запуск обработчика на потоке UI, как будто пользователь нажал кнопку. Синхронная часть handler() выполняется на потоке UI, а Unwrap возвращает задачу, которая завершится вместе с обработчиком.

Обёртка маршрута 1

CoreLib 10.0.12: SynchronizationContextAwaitTaskContinuation (сокращено, без трассировки)
internal sealed override void Run(Task task, bool canInlineContinuationTask)
{
    if (canInlineContinuationTask && m_syncContext == SynchronizationContext.Current)
    {
        RunCallback(AwaitTaskContinuation.GetInvokeActionCallback(), m_action, ref Task.t_currentTask);
        return;
    }
    ...
    RunCallback(GetPostActionCallback(), this, ref Task.t_currentTask);
}

private static void PostAction(object state)
{
    var c = (SynchronizationContextAwaitTaskContinuation)state;
    ...
    c.m_syncContext.Post(s_postCallback, c.m_action);
}
  • Строки 3–7: если задачу завершили на том же контексте (например, UI-поток сам вызвал SetResult) и инлайн разрешён — продолжение выполняется сразу, без очереди.
  • Строки 9, 12–17: иначе — Post в захваченный контекст. m_action — делегат MoveNextAction бокса: когда поток UI дойдёт до него в очереди, он вызовет MoveNext вашего метода.

Это дороже маршрута 3: обёртка (объект в куче) и делегат MoveNextAction (создаётся один раз на бокс), плюс работа самого контекста (в WinForms — сообщение окну). И это причина дедлоков, если поток контекста заблокирован (глава 6).

5.3. TaskScheduler: маршрут для задач

Маршрут 2 срабатывает, когда await выполняется внутри задачи, запущенной на нестандартном планировщике (Task.Factory.StartNew(…, scheduler), ConcurrentExclusiveSchedulerPair, планировщики с ограничением параллелизма, TaskScheduler.FromCurrentSynchronizationContext()).

CoreLib 10.0.12: TaskSchedulerAwaitTaskContinuation.Run
internal sealed override void Run(Task ignored, bool canInlineContinuationTask)
{
    if (m_scheduler == TaskScheduler.Default)
    {
        base.Run(ignored, canInlineContinuationTask);
        return;
    }
    bool num = canInlineContinuationTask && (TaskScheduler.InternalCurrent == m_scheduler || Thread.CurrentThread.IsThreadPoolThread);
    Task task = CreateTask((object state) =>
    {
        try
        {
            ((Action)state)();
        }
        catch (Exception exception)
        {
            Task.ThrowAsync(exception, null);
        }
    }, m_action, m_scheduler);
    if (num)
    {
        TaskContinuation.InlineIfPossibleOrElseQueue(task, needsProtection: false);
        return;
    }
    try
    {
        task.ScheduleAndStart(needsProtection: false);
    }
    catch (TaskSchedulerException)
    {
    }
}
  • Строка 9: продолжение оборачивается в новую задачу на этом планировщике. Это самый дорогой из трёх маршрутов: обёртка плюс объект Task.
  • Строки 20–24: планировщику сначала предлагают выполнить её сразу (TryExecuteTaskInline), он может отказаться.
  • Строка 27: иначе — поставить в очередь планировщика (QueueTask).

Так ExclusiveScheduler в опыте гарантирует, что и продолжения после await идут «по одному», как и сама задача. Но только пока await без ConfigureAwait(false): с ним продолжение уйдёт из планировщика в пул.

Task.Factory.StartNew без планировщика берёт TaskScheduler.Current

Внутри такой задачи «невинный» Task.Factory.StartNew(…) без явного scheduler тоже попадёт в нестандартный планировщик, а не в пул. Task.Run всегда использует TaskScheduler.Default. Подробно — глава 10.

5.4. Без контекста: инлайн или пул

В консоли, ASP.NET Core, сервисах и на потоках пула работает маршрут 3: в продолжениях задачи лежит сам бокс. Где выполнится MoveNext, решает тот, кто завершает задачу, внутри TrySetResult → FinishContinuations → RunContinuations (глава 4, §4.4):

CoreLib 10.0.12: Task.RunContinuations (одно продолжение) и AwaitTaskContinuation
// Task.RunContinuations
bool flag2 = (m_stateFlags & 0x40) == 0 && RuntimeHelpers.TryEnsureSufficientExecutionStack();
...
AwaitTaskContinuation.RunOrScheduleAction(box, flag2);

// AwaitTaskContinuation
internal static void RunOrScheduleAction(IAsyncStateMachineBox box, bool allowInlining)
{
    Task t_currentTask = Task.t_currentTask;
    if (!allowInlining || !IsValidLocationForInlining)
    {
        ...
        ThreadPool.UnsafeQueueUserWorkItemInternal(box, preferLocal: true);
        return;
    }
    try
    {
        if (t_currentTask != null)
        {
            Task.t_currentTask = null;
        }
        box.MoveNext();
    }
    ...
}

internal static bool IsValidLocationForInlining
{
    get
    {
        SynchronizationContext current = SynchronizationContext.Current;
        if (current != null && current.GetType() != typeof(SynchronizationContext))
        {
            return false;
        }
        TaskScheduler internalCurrent = TaskScheduler.InternalCurrent;
        if (internalCurrent != null)
        {
            return internalCurrent == TaskScheduler.Default;
        }
        return true;
    }
}

Продолжение выполняется инлайн — прямо внутри SetResult, на потоке завершителя (строка 22), если выполнены три условия:

  1. у задачи нет RunContinuationsAsynchronously (бит 0x40, строка 2);
  2. стек не слишком глубок (TryEnsureSufficientExecutionStack, строка 2);
  3. у завершающего потока нет своего контекста или нестандартного планировщика (строки 31–41). Код из UI-потока не будет «втихую» выполнять чужие продолжения на себе.

Иначе бокс уходит в очередь пула (строка 13). preferLocal: true — в локальную очередь текущего потока пула, если завершитель сам поток пула. К этому вернёмся в опыте §5.6.

Отсюда частая формулировка «после await код продолжится на потоке, который завершил задачу». Для Task.Delay это поток пула, обработавший таймер. Для сокета — поток, обработавший завершение ввода-вывода (на Windows его приносит порт завершения IOCP, на Linux — цикл epoll; подробно — глава 13). Для TaskCompletionSource — любой код, вызвавший SetResult, и это источник проблем (§5.6).

5.5. ConfigureAwait

await task.ConfigureAwait(false);   // «не возвращайся в захваченный контекст»

Мы уже видели всё, что делает ConfigureAwait(false): ставит continueOnCapturedContext = false в вызове UnsafeSetContinuationForAwait. Маршруты 1 и 2 пропускаются, бокс кладётся в продолжения напрямую (маршрут 3). Отсюда свойства:

  • Действует только на этот await. Это не режим метода и не режим задачи, а другой тип awaiter'а. Следующий await без ConfigureAwait(false) снова посмотрит на контекст. Но если после первого await …ConfigureAwait(false) код оказался на потоке пула, контекста там уже нет, и следующие await его не найдут.
  • Не переключает поток сам по себе. Если задача уже завершена, IsCompleted вернёт true и подписки не будет вовсе: код продолжится синхронно на текущем потоке, в том числе на потоке UI.
  • Не влияет на ExecutionContext. AsyncLocal течёт через await всегда (глава 7).
  • Зачем нужен: (1) не дать дедлок, если вызывающий блокируется на задаче (глава 6); (2) не грузить UI-поток работой, которой он не нужен; (3) чуть дешевле: нет обёртки и Post.
  • В приложении на ASP.NET Core и в консоли не нужен: контекста нет. В библиотеках, которые могут вызвать из UI или старого ASP.NET, его ставят на каждый await (анализатор CA2007).

ConfigureAwaitOptions (.NET 8+)

Перегрузка ConfigureAwait(ConfigureAwaitOptions) — флаги вместо одного bool:

Флаг Значение Что делает
None 0 то же, что ConfigureAwait(false)
ContinueOnCapturedContext 1 то же, что ConfigureAwait(true)
SuppressThrowing 2 не бросать исключение на await упавшей или отменённой задачи
ForceYielding 4 всегда приостанавливаться, даже если задача уже завершена

Как это устроено в awaiter'е:

CoreLib 10.0.12: ConfiguredTaskAwaitable.ConfiguredTaskAwaiter и TaskAwaiter (фрагменты)
// ConfiguredTaskAwaiter
public bool IsCompleted
{
    get
    {
        if ((m_options & ConfigureAwaitOptions.ForceYielding) == 0)
        {
            return m_task.IsCompleted;
        }
        return false;
    }
}

public void GetResult()
{
    TaskAwaiter.ValidateEnd(m_task, m_options);
}

// TaskAwaiter: обработка неуспеха, общая для обоих awaiter'ов
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();
    }
}
  • Строки 6–10: ForceYielding просто врёт «ещё не готово». Машина приостанавливается, подписка идёт через UnsafeSetContinuationForAwait, а там AddTaskContinuation на завершённой задаче вернёт false, и продолжение запустится асинхронно: через Post в контекст (строки 20–23 листинга §5.1) или в пул (строки 28–31). Это замена await Task.Yield(), которую можно сочетать с ConfigureAwait(false).
  • Строки 29–33: SuppressThrowing не бросает, а помечает исключение обработанным (MarkExceptionsAsHandled): оно не всплывёт и в TaskScheduler.UnobservedTaskException.

Опыт: ConfigureAwait на «UI-потоке»

final/Program.cs
Console.WriteLine();
Console.WriteLine("== 2. ConfigureAwait ==");
await ui.RunAsync(async () =>
{
    await Task.Delay(50).ConfigureAwait(false);
    Console.WriteLine($"  Delay.ConfigureAwait(false)         : {Here.Now}");
});
await ui.RunAsync(async () =>
{
    await Task.CompletedTask.ConfigureAwait(false);
    Console.WriteLine($"  CompletedTask.ConfigureAwait(false) : {Here.Now}");
    await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.ForceYielding);
    Console.WriteLine($"  ForceYielding | ContinueOnCaptured  : {Here.Now}");
    await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
    Console.WriteLine($"  ForceYielding                       : {Here.Now}");
});

Task failed = Task.FromException(new InvalidOperationException("boom"));
await failed.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
Console.WriteLine($"  Task + SuppressThrowing      : не бросило, Status={failed.Status}");
try
{
    Task<int> failedInt = Task.FromException<int>(new InvalidOperationException("boom"));
    await failedInt.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
catch (ArgumentOutOfRangeException e)
{
    Console.WriteLine($"  Task<int> + SuppressThrowing : {e.GetType().Name}");
}
1
2
3
4
5
6
7
8
== 2. ConfigureAwait ==
  Delay.ConfigureAwait(false)         : поток  6 (пул)
  CompletedTask.ConfigureAwait(false) : поток  8 (UI), SynchronizationContext=UiThread
    [Post в очередь UI, вызван на: поток  8 (UI), SynchronizationContext=UiThread]
  ForceYielding | ContinueOnCaptured  : поток  8 (UI), SynchronizationContext=UiThread
  ForceYielding                       : поток  6 (пул)
  Task + SuppressThrowing      : не бросило, Status=Faulted
  Task<int> + SuppressThrowing : ArgumentOutOfRangeException
  • Строка 2 вывода (строки 36–37 кода): обработчик начался на потоке UI, но после await …ConfigureAwait(false) оказался в пуле. Post не было.
  • Строка 3 (строки 41–42): ConfigureAwait(false) на завершённой задаче — мы остались на потоке UI. Подписки не было, переключать поток некому.
  • Строки 4–5 (строки 43–44): ForceYielding с захватом контекста — поток UI сам себе сделал Post (строка 4 вывода) и продолжил из очереди. Это аналог await Task.Yield() в UI: «дай очереди сообщений обработать накопившееся».
  • Строка 6 (строки 45–46): ForceYielding без ContinueOnCapturedContext — гарантированный уход с текущего потока в пул. Удобно, чтобы снять тяжёлую работу с UI-потока, не создавая Task.Run.
  • Строка 7 (строки 49–51): SuppressThrowing — await упавшей задачи не бросил, статус Faulted остался.
  • Строка 8 (строки 52–60): тот же флаг на Task<int> — исключение ArgumentOutOfRangeException при вызове ConfigureAwait.

Факт: SuppressThrowing для Task<T> — ошибка во время выполнения, а не компиляции

В первой версии курса стояло «работает только для необобщённого Task [проверить]». Проверено: Task<TResult>.ConfigureAwait(ConfigureAwaitOptions) проверяет флаги и бросает исключение:

CoreLib 10.0.12: Task<TResult>.ConfigureAwait(ConfigureAwaitOptions)
1
2
3
4
5
6
7
8
9
public new ConfiguredTaskAwaitable<TResult> ConfigureAwait(ConfigureAwaitOptions options)
{
    if ((options & ~(ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.ForceYielding)) != 0)
    {
        ThrowForInvalidOptions(options);
    }
    return new ConfiguredTaskAwaitable<TResult>(this, options);
    ...
}

Текст исключения подсказывает выход: «…cast the Task<TResult> to its base class Task and await that with SuppressThrowing». Результата при таком await не будет: подавить исключение и получить T нельзя. Компилятор ошибку не даёт, но анализатор предупреждает: при сборке final видно warning CA2261: The ConfigureAwaitOptions.SuppressThrowing option is only supported with the non-generic Task.

Задание (TODO 1)

В start/Program.cs строка «UI, после второго await» печатается на потоке UI. Сделайте так, чтобы она печаталась на потоке пула, изменив только второй await. Найдите два способа: через bool и через ConfigureAwaitOptions.

5.6. Синхронные продолжения: чужой код внутри вашего SetResult

Маршрут 3 по умолчанию выполняет продолжение инлайн, внутри вызова, который завершил задачу. Для Task.Delay и ввода-вывода это хорошо: потоку пула не нужно второй раз ставить работу в очередь. Но для TaskCompletionSource «завершитель» — ваш код, и в него «ныряет» чужое продолжение.

final/Inline.cs
    public static async Task SetResultAsync(TaskCreationOptions options)
    {
        Console.WriteLine($"  -- TaskCompletionSource({options}) --");
        var tcs = new TaskCompletionSource<int>(options);
        Task consumer = ConsumerAsync(tcs.Task);
        var sw = Stopwatch.StartNew();
        Console.WriteLine($"    до SetResult        : {Here.Now}");
        tcs.SetResult(1);
        Console.WriteLine($"    после SetResult     : {Here.Now}, SetResult занял {sw.ElapsedMilliseconds} мс");
        await consumer;

        static async Task ConsumerAsync(Task<int> task)
        {
            await task;
            Console.WriteLine($"    продолжение Consumer: {Here.Now}");
            Thread.Sleep(1000);                     // долгая работа в продолжении
        }
    }
1
2
3
4
5
6
7
8
  -- TaskCompletionSource(None) --
    до SetResult        : поток  6 (пул)
    продолжение Consumer: поток  6 (пул)
    после SetResult     : поток  6 (пул), SetResult занял 1001 мс
  -- TaskCompletionSource(RunContinuationsAsynchronously) --
    до SetResult        : поток  6 (пул)
    после SetResult     : поток  6 (пул), SetResult занял 0 мс
    продолжение Consumer: поток  5 (пул)
  • Строки 1–4 вывода: tcs.SetResult(1) (строка 13 кода) выполнил продолжение ConsumerAsync (строки 20–21) на своём потоке и вернул управление только через секунду, когда оно закончило Thread.Sleep. Чем это опасно:
    • задержка: кто вызвал SetResult (обработчик сети, таймер, ваш производитель в очереди), стоит, пока работает чужой код;
    • дедлок: вы держите lock и вызываете SetResult, а продолжение пытается взять тот же lock на другом потоке и ждёт, или вызывает код, который ждёт вас;
    • реентерабельность: продолжение снова вызывает ваш объект, пока тот в середине операции и его состояние не согласовано.
  • Строки 5–8: с RunContinuationsAsynchronously бокс ушёл в очередь пула (бит 0x40 в строке 2 листинга RunContinuations), SetResult вернулся сразу.

Правило: если ваш код завершает задачи, которые ждут другие (библиотека, очередь, пул соединений, Channel-подобные структуры), создавайте TaskCompletionSource с TaskCreationOptions.RunContinuationsAsynchronously. Если причины поступить иначе нет.

Задание (TODO 2)

В start/Program.cs строка «после SetResult» печатается через секунду. Добейтесь, чтобы она печаталась сразу, изменив только создание TaskCompletionSource.

Факт: «асинхронно» не значит «на другом потоке»

В 20 прогонах final продолжение с RunContinuationsAsynchronously всегда выполнял другой поток пула. Но в черновой версии лабораторной в одном прогоне все три строки напечатал один и тот же поток 5: «до SetResult», «после SetResult» (0 мс) и только потом «продолжение Consumer».

Объяснение — в строке 13 листинга RunOrScheduleAction: preferLocal: true. Бокс попадает в локальную очередь текущего потока пула. Сразу после SetResult метод доходит до await consumer (строка 15) и освобождает поток. Кто первым возьмёт работу — сам этот поток из своей очереди или соседний, «укравший» её, — дело случая. Гарантируется только «не внутри SetResult», а не «на другом потоке» (как и с Task.Yield в главе 1).

Несколько подписчиков: инлайн только у одного

final/Inline.cs
    public static async Task ManySubscribersAsync()
    {
        Console.WriteLine("  -- три подписчика на одну задачу --");
        var tcs = new TaskCompletionSource();
        Task[] subscribers = [WaitAsync(1), WaitAsync(2), WaitAsync(3)];
        Console.WriteLine($"    SetResult на        : {Here.Now}");
        tcs.SetResult();
        await Task.WhenAll(subscribers);

        async Task WaitAsync(int n)
        {
            await tcs.Task;
            Console.WriteLine($"    подписчик {n}         : {Here.Now}");
        }
    }
1
2
3
4
5
  -- три подписчика на одну задачу --
    SetResult на        : поток  5 (пул)
    подписчик 2         : поток  7 (пул)
    подписчик 1         : поток  5 (пул)
    подписчик 3         : поток  9 (пул)

Во всех прогонах подписчик 1 выполнялся инлайн на потоке SetResult, а 2 и 3 — на других потоках пула. Так же на Windows: подписчик 1 всегда на потоке SetResult, остальные на других потоках пула, порядок строк меняется от запуска к запуску. Строки при этом идут в любом порядке: подписчики 2 и 3 работают параллельно с первым. Почему так, видно в ветке «список продолжений» RunContinuations:

CoreLib 10.0.12: Task.RunContinuations, список продолжений (сокращено)
Span<object> span = CollectionsMarshal.AsSpan(list);
if (flag2)
{
    bool flag3 = false;
    for (int i = 0; i < span.Length; i++)
    {
        object obj = span[i];
        ...                                             // ContinueWith без ExecuteSynchronously — сразу в очередь
        if (flag3)
        {
            span[i] = null;
            ...
            AwaitTaskContinuation.RunOrScheduleAction(box2, allowInlining: false);
        }
        flag3 = true;
    }
}
for (int j = 0; j < span.Length; j++)
{
    object obj2 = span[j];
    if (obj2 == null)
    {
        continue;
    }
    span[j] = null;
    ...
    AwaitTaskContinuation.RunOrScheduleAction(box3, flag2);
}
  • Строки 4–16: первый проход. Первое подходящее продолжение пропускается (flag3 ещё false), все остальные ставятся в пул (allowInlining: false, строка 13).
  • Строки 18–27: второй проход выполняет то, что осталось, — первое продолжение, инлайн (если flag2).

Если бы все продолжения шли инлайн по очереди, второй подписчик ждал бы, пока закончит первый, а SetResult — пока закончат все. Рантайм выполняет инлайн только одно продолжение, а остальные раздаёт пулу, чтобы они шли параллельно.

Защита стека

Если продолжение само завершает следующую задачу, инлайн вкладывается в инлайн: SetResult → MoveNext → SetResult → MoveNext → … Каждое звено добавляет кадры в стек одного потока. Проверка TryEnsureSufficientExecutionStack() (строка 2 листинга RunContinuations) смотрит, сколько стека осталось. Если мало, продолжение уходит в пул, и цепочка продолжается на «свежем» стеке.

final/Inline.cs
    // Номер звена, внутри SetResult которого сейчас находится этот поток (-2 — ни внутри какого).
    [ThreadStatic] private static int t_inside;

    public static async Task StackGuardAsync()
    {
        Console.WriteLine("  -- цепочка: продолжение звена k завершает звено k+1 --");
        const int Length = 100_000;
        var links = new TaskCompletionSource[Length];
        for (int i = 0; i < Length; i++)
            links[i] = new TaskCompletionSource();
        var hops = new List<int>();                 // звенья, которые выполнились не внутри SetResult предыдущего
        for (int k = 0; k < Length - 1; k++)
            _ = LinkAsync(k);

        // Отдельный поток со стеком 1 МБ: так результат меньше зависит от ОС.
        var starter = new Thread(() =>
        {
            t_inside = -1;
            links[0].SetResult();
            t_inside = -2;
        }, maxStackSize: 1024 * 1024);
        starter.Start();
        starter.Join();
        await links[^1].Task;
        Console.WriteLine($"    звеньев: {Length - 1:N0}, ушли в пул: {hops.Count}, первые: {string.Join(", ", hops.Take(4))}");

        async Task LinkAsync(int k)
        {
            await links[k].Task;
            if (t_inside != k - 1)
                lock (hops) hops.Add(k);
            t_inside = k;
            links[k + 1].SetResult();               // продолжение звена k+1 выполнится прямо здесь — или уйдёт в пул
            t_inside = -2;
        }
    }
  • Строки 52–53: 99 999 async-методов, каждый ждёт своё звено.
  • Строки 72–74: звено k после пробуждения завершает звено k+1. Если инлайн разрешён, MoveNext звена k+1 выполнится внутри этого SetResult, глубже по стеку.
  • Строки 70–71: признак ухода в пул: звено выполняется не внутри SetResult предыдущего. Поле [ThreadStatic] (строка 42) хранит, внутри какого SetResult сейчас находится этот поток.
  • Строки 56–61: цепочку запускает отдельный поток со стеком ровно 1 МБ.
  -- цепочка: продолжение звена k завершает звено k+1 --
    звеньев: 99,999, ушли в пул: 8, первые: 1500, 15073, 28646, 42219
  -- цепочка: продолжение звена k завершает звено k+1 --
    звеньев: 99,999, ушли в пул: 61, первые: 1042, 2680, 4318, 5956

Факт: ограничитель глубины есть, а числа зависят от ОС

В первой версии курса это стояло с пометкой «проверить». Проверено на обеих ОС: инлайн-цепочка рано или поздно прерывается, и продолжение уходит в пул. Результат одинаков во всех прогонах (Linux — десять, Windows — пять). Меняются сами числа:

Linux (Ubuntu 26.04) Windows 11
первое звено, поток со стеком 1 МБ 1 500 1 042
дальше, на потоках пула каждые 13 573 каждые 1 638
стек потока пула по умолчанию 8 МБ (ulimit -s) 1,5 МБ
стека на одно звено ≈ 600 байт ≈ 880 байт
с DOTNET_TieredCompilation=0 1 901 и 17 192 1 432 и 2 251

Почему так. Проверка TryEnsureSufficientExecutionStack() отрабатывает, когда на стеке остаётся меньше ≈ 128 КБ, поэтому число звеньев до ухода — это (размер стека − 128 КБ) / (стека на звено).

  • Размер стека потока пула. На Linux поток получает стек ulimit -s (здесь 8 МБ): с ulimit -s 2048 интервал сократился до 3 225, а первое звено на потоке с явными 1 МБ осталось 1 500. На Windows стек потока по умолчанию 1,5 МБ. Проверено: если запускающий поток создать с maxStackSize 1,5 МБ (или 0, то есть по умолчанию), первое звено сдвигается к 1 637, как и интервал на пуле; с 8 МБ первое звено уходит к 9 383, а интервал на пуле остаётся 1 638. Выходит, Windows-пул не стал бы жить дольше от того, что у основного потока стек больше.
  • Размер кадров. Из двух замеров на Windows (стеки 1 и 1,5 МБ) выходит ≈ 880 байт на звено, из замеров на Linux (1 и 8 МБ) — ≈ 600 байт: оба раза запас получается ≈ 128–136 КБ. Кадры на Windows крупнее, возможно, из-за соглашения о вызовах x64 Windows (резерв «shadow space» в каждом кадре), но это гипотеза, кадры не сравнивались.

Конкретные числа — свойство стенда, а не рантайма. Многоуровневая компиляция на результат влияет слабо: оптимизированные кадры чуть меньше.

5.7. Итоги

  • Место продолжения выбирается при подписке, в Task.UnsafeSetContinuationForAwait. Три маршрута: Post в SynchronizationContext (если он есть и не базового типа), задача в нестандартном TaskScheduler, иначе бокс прямо в продолжения задачи.
  • Компилятор и машина состояний про контексты ничего не знают. ConfigureAwait — другой тип awaiter'а, а JIT сводит builder к одной ветке.
  • Без контекста продолжение выполняется инлайн, внутри вызова завершителя, если у задачи нет RunContinuationsAsynchronously, стек не глубок и у завершающего потока нет своего контекста. Иначе — в пул. Из нескольких подписчиков инлайн идёт только один.
  • ConfigureAwait(false) действует на один await, на завершённой задаче поток не меняет, ExecutionContext не трогает. В приложениях без контекста он не нужен, в библиотеках — на каждый await.
  • ConfigureAwaitOptions: ForceYielding — гарантированная приостановка (аналог Task.Yield), SuppressThrowing — только для необобщённого Task, для Task<T> — исключение при вызове.
  • TaskCompletionSource в библиотечном коде создавайте с RunContinuationsAsynchronously. «Асинхронно» гарантирует «не внутри SetResult», но не «на другом потоке».

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

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

start/Program.cs
// Глава 5, заготовка. Куда вернётся продолжение?
// PREDICT: для каждой строки с Here.Now запишите, на каком потоке она напечатается: основной, пул или UI.
// TODO 1 (§5.5): сделайте так, чтобы строка "UI, после второго await" печаталась на потоке пула, а не UI.
// TODO 2 (§5.6): сделайте так, чтобы "после SetResult" печаталось сразу, без паузы в секунду.
using System.Diagnostics;

Console.WriteLine("== 1. Маршрут продолжения ==");
Console.WriteLine($"  консоль, до await          : {Here.Now}");     // PREDICT:
await Task.Delay(50);
Console.WriteLine($"  консоль, после await Delay : {Here.Now}");     // PREDICT:

var ui = new UiThread();
await ui.RunAsync(async () =>
{
    Console.WriteLine($"  UI, до await               : {Here.Now}");   // PREDICT:
    await Task.Delay(50);
    Console.WriteLine($"  UI, после await Delay      : {Here.Now}");   // PREDICT:
    await Task.Delay(50);                                               // TODO 1
    Console.WriteLine($"  UI, после второго await    : {Here.Now}");   // PREDICT:
});

Console.WriteLine();
Console.WriteLine("== 2. Синхронное продолжение ==");
var tcs = new TaskCompletionSource<int>();                               // TODO 2
Task consumer = ConsumerAsync(tcs.Task);
var sw = Stopwatch.StartNew();
Console.WriteLine($"  до SetResult        : {Here.Now}");
tcs.SetResult(1);
Console.WriteLine($"  после SetResult     : {Here.Now}, SetResult занял {sw.ElapsedMilliseconds} мс");  // PREDICT: сколько мс?
await consumer;

static async Task ConsumerAsync(Task<int> task)
{
    await task;
    Console.WriteLine($"  продолжение Consumer: {Here.Now}");          // PREDICT:
    Thread.Sleep(1000);                                                 // долгая работа в продолжении
}
start/UiThread.cs
using System.Collections.Concurrent;

// Однопоточный SynchronizationContext — модель UI-потока (WinForms, WPF, MAUI).
// Свой поток "UI" и очередь: Post кладёт делегат в очередь, поток UI выполняет их по одному.
sealed class UiThread : SynchronizationContext
{
    private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
    private readonly Thread _thread;

    public UiThread()
    {
        _thread = new Thread(Loop) { Name = "UI", IsBackground = true };
        _thread.Start();
    }

    public override void Post(SendOrPostCallback d, object? state)
    {
        Console.WriteLine($"    [Post в очередь UI, вызван на: {Here.Now}]");
        _queue.Add((d, state));
    }

    // Запустить async-обработчик на потоке UI и дождаться его завершения.
    public Task RunAsync(Func<Task> handler)
    {
        var started = new TaskCompletionSource<Task>(TaskCreationOptions.RunContinuationsAsynchronously);
        _queue.Add((_ => started.SetResult(handler()), null));
        return started.Task.Unwrap();
    }

    private void Loop()
    {
        SetSynchronizationContext(this);
        foreach (var (callback, state) in _queue.GetConsumingEnumerable())
            callback(state);
    }
}

static class Here
{
    // Где мы сейчас: номер и вид потока, плюс контекст или планировщик, если они не «по умолчанию».
    public static string Now
    {
        get
        {
            var thread = Thread.CurrentThread;
            string kind = thread.IsThreadPoolThread ? "пул" : thread.Name ?? "основной";
            string where = $"поток {Environment.CurrentManagedThreadId,2} ({kind})";
            if (SynchronizationContext.Current is { } ctx)
                where += $", SynchronizationContext={ctx.GetType().Name}";
            if (TaskScheduler.Current != TaskScheduler.Default)
                where += $", TaskScheduler={TaskScheduler.Current.GetType().Name}";
            return where;
        }
    }
}
final/Program.cs
// Глава 5, итог. Куда вернётся продолжение:
//   1) маршрут продолжения: без контекста, на «UI-потоке», с базовым SynchronizationContext, в своём TaskScheduler (§5.2–5.4);
//   2) ConfigureAwait(false) и ConfigureAwaitOptions (§5.5);
//   3) синхронные продолжения: TaskCompletionSource, несколько подписчиков, защита стека (§5.6).

Console.WriteLine("== 1. Маршрут продолжения ==");
Console.WriteLine($"  консоль, до await          : {Here.Now}");
await Task.Delay(50);
Console.WriteLine($"  консоль, после await Delay : {Here.Now}");

var ui = new UiThread();
await ui.RunAsync(async () =>
{
    Console.WriteLine($"  UI, до await               : {Here.Now}");
    await Task.Delay(50);
    Console.WriteLine($"  UI, после await Delay      : {Here.Now}");
});

SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
Console.WriteLine($"  базовый контекст, до await : {Here.Now}");
await Task.Delay(50);
Console.WriteLine($"  базовый контекст, после    : {Here.Now}");

var exclusive = new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler;
await Task.Factory.StartNew(async () =>
{
    Console.WriteLine($"  свой планировщик, до await : {Here.Now}");
    await Task.Delay(50);
    Console.WriteLine($"  свой планировщик, после    : {Here.Now}");
}, CancellationToken.None, TaskCreationOptions.None, exclusive).Unwrap();

Console.WriteLine();
Console.WriteLine("== 2. ConfigureAwait ==");
await ui.RunAsync(async () =>
{
    await Task.Delay(50).ConfigureAwait(false);
    Console.WriteLine($"  Delay.ConfigureAwait(false)         : {Here.Now}");
});
await ui.RunAsync(async () =>
{
    await Task.CompletedTask.ConfigureAwait(false);
    Console.WriteLine($"  CompletedTask.ConfigureAwait(false) : {Here.Now}");
    await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.ForceYielding);
    Console.WriteLine($"  ForceYielding | ContinueOnCaptured  : {Here.Now}");
    await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
    Console.WriteLine($"  ForceYielding                       : {Here.Now}");
});

Task failed = Task.FromException(new InvalidOperationException("boom"));
await failed.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
Console.WriteLine($"  Task + SuppressThrowing      : не бросило, Status={failed.Status}");
try
{
    Task<int> failedInt = Task.FromException<int>(new InvalidOperationException("boom"));
    await failedInt.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
catch (ArgumentOutOfRangeException e)
{
    Console.WriteLine($"  Task<int> + SuppressThrowing : {e.GetType().Name}");
}

Console.WriteLine();
Console.WriteLine("== 3. Синхронные продолжения ==");
await Inline.SetResultAsync(TaskCreationOptions.None);
await Inline.SetResultAsync(TaskCreationOptions.RunContinuationsAsynchronously);
await Inline.ManySubscribersAsync();
await Inline.StackGuardAsync();
final/Inline.cs
using System.Diagnostics;

// Опыты §5.6: продолжение выполняется внутри SetResult того, кто завершил задачу.
static class Inline
{
    public static async Task SetResultAsync(TaskCreationOptions options)
    {
        Console.WriteLine($"  -- TaskCompletionSource({options}) --");
        var tcs = new TaskCompletionSource<int>(options);
        Task consumer = ConsumerAsync(tcs.Task);
        var sw = Stopwatch.StartNew();
        Console.WriteLine($"    до SetResult        : {Here.Now}");
        tcs.SetResult(1);
        Console.WriteLine($"    после SetResult     : {Here.Now}, SetResult занял {sw.ElapsedMilliseconds} мс");
        await consumer;

        static async Task ConsumerAsync(Task<int> task)
        {
            await task;
            Console.WriteLine($"    продолжение Consumer: {Here.Now}");
            Thread.Sleep(1000);                     // долгая работа в продолжении
        }
    }

    public static async Task ManySubscribersAsync()
    {
        Console.WriteLine("  -- три подписчика на одну задачу --");
        var tcs = new TaskCompletionSource();
        Task[] subscribers = [WaitAsync(1), WaitAsync(2), WaitAsync(3)];
        Console.WriteLine($"    SetResult на        : {Here.Now}");
        tcs.SetResult();
        await Task.WhenAll(subscribers);

        async Task WaitAsync(int n)
        {
            await tcs.Task;
            Console.WriteLine($"    подписчик {n}         : {Here.Now}");
        }
    }

    // Номер звена, внутри SetResult которого сейчас находится этот поток (-2 — ни внутри какого).
    [ThreadStatic] private static int t_inside;

    public static async Task StackGuardAsync()
    {
        Console.WriteLine("  -- цепочка: продолжение звена k завершает звено k+1 --");
        const int Length = 100_000;
        var links = new TaskCompletionSource[Length];
        for (int i = 0; i < Length; i++)
            links[i] = new TaskCompletionSource();
        var hops = new List<int>();                 // звенья, которые выполнились не внутри SetResult предыдущего
        for (int k = 0; k < Length - 1; k++)
            _ = LinkAsync(k);

        // Отдельный поток со стеком 1 МБ: так результат меньше зависит от ОС.
        var starter = new Thread(() =>
        {
            t_inside = -1;
            links[0].SetResult();
            t_inside = -2;
        }, maxStackSize: 1024 * 1024);
        starter.Start();
        starter.Join();
        await links[^1].Task;
        Console.WriteLine($"    звеньев: {Length - 1:N0}, ушли в пул: {hops.Count}, первые: {string.Join(", ", hops.Take(4))}");

        async Task LinkAsync(int k)
        {
            await links[k].Task;
            if (t_inside != k - 1)
                lock (hops) hops.Add(k);
            t_inside = k;
            links[k + 1].SetResult();               // продолжение звена k+1 выполнится прямо здесь — или уйдёт в пул
            t_inside = -2;
        }
    }
}
final/UiThread.cs
using System.Collections.Concurrent;

// Однопоточный SynchronizationContext — модель UI-потока (WinForms, WPF, MAUI).
// Свой поток "UI" и очередь: Post кладёт делегат в очередь, поток UI выполняет их по одному.
sealed class UiThread : SynchronizationContext
{
    private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
    private readonly Thread _thread;

    public UiThread()
    {
        _thread = new Thread(Loop) { Name = "UI", IsBackground = true };
        _thread.Start();
    }

    public override void Post(SendOrPostCallback d, object? state)
    {
        Console.WriteLine($"    [Post в очередь UI, вызван на: {Here.Now}]");
        _queue.Add((d, state));
    }

    // Запустить async-обработчик на потоке UI и дождаться его завершения.
    public Task RunAsync(Func<Task> handler)
    {
        var started = new TaskCompletionSource<Task>(TaskCreationOptions.RunContinuationsAsynchronously);
        _queue.Add((_ => started.SetResult(handler()), null));
        return started.Task.Unwrap();
    }

    private void Loop()
    {
        SetSynchronizationContext(this);
        foreach (var (callback, state) in _queue.GetConsumingEnumerable())
            callback(state);
    }
}

static class Here
{
    // Где мы сейчас: номер и вид потока, плюс контекст или планировщик, если они не «по умолчанию».
    public static string Now
    {
        get
        {
            var thread = Thread.CurrentThread;
            string kind = thread.IsThreadPoolThread ? "пул" : thread.Name ?? "основной";
            string where = $"поток {Environment.CurrentManagedThreadId,2} ({kind})";
            if (SynchronizationContext.Current is { } ctx)
                where += $", SynchronizationContext={ctx.GetType().Name}";
            if (TaskScheduler.Current != TaskScheduler.Default)
                where += $", TaskScheduler={TaskScheduler.Current.GetType().Name}";
            return where;
        }
    }
}