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

6. Лаборатория дедлоков

О главе

Цель: воспроизвести классический дедлок sync-over-async («синхронно ждём асинхронное»), увидеть изнутри, где застревает продолжение, и разобрать способы его избежать, включая две ловушки с ConfigureAwait(false).

Лабораторная: start/ — модель UI-потока и один дедлок. Нужно предсказать вывод и найти три способа его устранить. final/ — дедлок со «снимком» потока UI, ConfigureAwait(false) и две ловушки с ним, Task.Run, await без блокировки. Код — в конце главы.

Визуализация: Дедлок по шагам — Три варианта кода: .Result, .Result с ConfigureAwait(false) и await.

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

Дедлок с .Result / .Wait() — самый частый вопрос про async на собеседованиях и одна из самых частых ошибок в UI-приложениях и старом ASP.NET. В консоли его не воспроизвести: там нет SynchronizationContext. Поэтому сделаем собственный однопоточный контекст, как у UI.

Что нужно помнить из главы 5:

  • await на незавершённой задаче смотрит на SynchronizationContext.Current в момент подписки. Если контекст есть (и не базового типа), продолжение будет передано в него через Post.
  • ConfigureAwait(false) отключает это для одного await. На уже завершённой задаче он ничего не меняет: подписки нет, поток тот же.

6.1. Стенд

UiThread — модель UI-потока: свой поток с именем UI, очередь делегатов и SynchronizationContext, у которого Post кладёт делегат в эту очередь.

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

    // Что сейчас с потоком UI: заблокирован ли он и сколько делегатов ждёт в очереди.
    public string Snapshot =>
        $"поток UI {((_thread.ThreadState & ThreadState.WaitSleepJoin) != 0 ? "заблокирован (WaitSleepJoin)" : "работает")}, " +
        $"в очереди UI: {_queue.Count}";

    // Запустить 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, когда до него дойдёт.
  • Строки 35–40: цикл потока UI. Он ставит себя контекстом потока (строка 37) и выполняет делегаты из очереди по одному. Если поток UI занят или заблокирован, очередь стоит. Цикл сообщений WinForms и диспетчер WPF работают так же.
  • Строки 23–25: «снимок» для наблюдения за дедлоком: заблокирован ли поток UI и сколько делегатов ждёт в очереди.
  • Строки 28–33: запустить обработчик на потоке UI, как будто пользователь нажал кнопку.

«Библиотечные» методы, которые будем вызывать:

final/Work.cs
// «Библиотечные» async-методы, которые вызывает обработчик на потоке UI.
static class Work
{
    // Обычный await: продолжение захочет вернуться в контекст вызывающего.
    public static async Task<int> WithContextAsync()
    {
        await Task.Delay(100);
        return 1;
    }

    // ConfigureAwait(false): продолжению не нужен поток контекста.
    public static async Task<int> NoContextAsync()
    {
        await Task.Delay(100).ConfigureAwait(false);
        return 2;
    }

    // ConfigureAwait(false) стоит только снаружи, а внутренний метод его не ставит.
    public static async Task<int> OuterOnlyAsync()
    {
        return await InnerAsync().ConfigureAwait(false);
    }

    private static async Task<int> InnerAsync()
    {
        await Task.Delay(100);
        return 3;
    }

    // ConfigureAwait(false) стоит, но на уже завершённой задаче.
    public static async Task<int> CompletedFirstAsync()
    {
        await Task.CompletedTask.ConfigureAwait(false);
        await Task.Delay(100);
        return 4;
    }
}

И блокирующее ожидание с таймаутом 1 с, чтобы программа не зависла навсегда. Если через 500 мс задача ещё не готова, таймер пула печатает снимок потока UI:

final/Program.cs
// Блокирующее ожидание с таймаутом 1 с. Если через 500 мс задача ещё не готова — снимок потока UI.
static void Block(string title, Task<int> task, UiThread? ui)
{
    using var probe = new Timer(_ => Console.WriteLine($"    через 500 мс: {ui?.Snapshot ?? "контекста нет"}"),
                                null, dueTime: 500, period: Timeout.Infinite);
    bool done = task.Wait(TimeSpan.FromSeconds(1));
    Console.WriteLine($"  {title,-36}: завершилась={done}");
}

Предскажите

В start/Program.cs один и тот же вызов Work.WithContextAsync() с .Wait делается дважды: без контекста (строка 7) и на потоке UI (строка 13). Что напечатает каждый Block? Что покажет снимок через 500 мс? Запуск:

cd chapters
dotnet run -c Release --project 06-deadlocks\start

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

Три варианта кода: .Result, .Result с ConfigureAwait(false) и await. Открыть на весь экран.

6.2. Дедлок по шагам

final/Program.cs
Console.WriteLine("== 0. Без контекста ==");
Block("0) WithContextAsync().Wait", Work.WithContextAsync(), ui: null);

var ui = new UiThread();
Task<int>? stuck = null;
await ui.RunAsync(async () =>
{
    Console.WriteLine();
    Console.WriteLine($"== На потоке UI: {Here.Now} ==");
    stuck = Work.WithContextAsync();
    Block("1) WithContextAsync().Wait", stuck, ui);
1
2
3
4
5
6
7
== 0. Без контекста ==
  0) WithContextAsync().Wait          : завершилась=True

== На потоке UI: поток  8 (UI), SynchronizationContext=UiThread ==
    [Post в очередь UI, вызван на: поток  5 (пул)]
    через 500 мс: поток UI заблокирован (WaitSleepJoin), в очереди UI: 1
  1) WithContextAsync().Wait          : завершилась=False

Строка 2 вывода (строка 8 кода): в консоли контекста нет. Продолжение WithContextAsync выполнил поток пула, обработавший таймер Delay, задача завершилась через 100 мс, Wait вернул true. Блокировать поток плохо (он простаивает), но дедлока нет.

Строки 4–7 (строки 16–17): тот же вызов на потоке UI. По шагам:

  1. Обработчик выполняется на потоке UI (строка 4 вывода). SynchronizationContext.Current — UiThread.
  2. WithContextAsync() (строка 16) выполняется синхронно до await Task.Delay(100) (Work.cs, строка 7). Delay не завершён, await захватывает контекст UI: в продолжения Delay кладётся обёртка «при завершении сделай Post в UiThread» (маршрут 1 из главы 5).
  3. WithContextAsync возвращает незавершённую задачу. Обработчик вызывает Block → task.Wait(1 с) (строка 34): поток UI блокируется.
  4. Через 100 мс таймер на потоке пула 5 завершает Delay, обёртка делает Post (строка 5 вывода). Продолжение WithContextAsync встало в очередь UI.
  5. Обработать очередь некому: единственный поток UI ждёт в Wait. Снимок через 500 мс (строка 6) это и показывает: поток UI в WaitSleepJoin, в очереди 1 делегат — то самое продолжение.
  6. Круг замкнулся: поток UI ждёт задачу → задача ждёт своё продолжение → продолжение ждёт поток UI. Без таймаута ожидание длилось бы вечно. С таймаутом через 1 с Wait возвращает false (строка 7).
sequenceDiagram
    participant UI as Поток UI
    participant Q as Очередь UI
    participant W as WithContextAsync
    participant P as Поток пула (таймер)

    UI->>W: WithContextAsync()
    W->>W: await Task.Delay(100): захват контекста UI
    W-->>UI: незавершённая задача
    UI->>UI: task.Wait() — поток заблокирован
    Note over P: через 100 мс
    P->>Q: Post(продолжение WithContextAsync)
    Note over Q: продолжение ждёт поток UI
    Note over UI,Q: поток UI ждёт задачу, задача ждёт продолжение,<br/>продолжение ждёт поток UI — дедлок

Под капотом: почему Wait не обрабатывает очередь

Wait, .Result и .GetAwaiter().GetResult() на незавершённой задаче приходят в один и тот же InternalWait. Wait(timeout) передаёт таймаут, .Result и GetResult() — -1, то есть «бесконечно».

CoreLib 10.0.12: Task.InternalWaitCore и SpinThenBlockingWait (сокращено)
private bool InternalWaitCore(int millisecondsTimeout, CancellationToken cancellationToken)
{
    if (IsCompleted)
    {
        return true;
    }
    ...
    bool result = (millisecondsTimeout == -1 && !cancellationToken.CanBeCanceled && WrappedTryRunInline() && IsCompleted) || SpinThenBlockingWait(millisecondsTimeout, cancellationToken);
    ...
    return result;
}

private bool SpinThenBlockingWait(int millisecondsTimeout, CancellationToken cancellationToken)
{
    bool flag = millisecondsTimeout == -1;
    uint num = ((!flag) ? ((uint)Environment.TickCount) : 0u);
    bool flag2 = SpinWait(millisecondsTimeout);
    if (!flag2)
    {
        ThreadPoolWorkQueue.TransferAllLocalWorkItemsToHighPriorityGlobalQueue();
        SetOnInvokeMres setOnInvokeMres = new SetOnInvokeMres();
        try
        {
            AddCompletionAction(setOnInvokeMres, addBeforeOthers: true);
            ...                                        // ветка flag (бесконечно) — то же с Wait(-1)
            uint num2 = (uint)Environment.TickCount - num;
            if (num2 < millisecondsTimeout)
            {
                bool flag4 = ThreadPool.NotifyThreadBlocked();
                try
                {
                    flag2 = setOnInvokeMres.Wait((int)(millisecondsTimeout - num2), cancellationToken);
                }
                finally
                {
                    if (flag4)
                    {
                        ThreadPool.NotifyThreadUnblocked();
                    }
                }
            }
        }
        finally
        {
            if (!IsCompleted)
            {
                RemoveContinuation(setOnInvokeMres);
            }
        }
    }
    return flag2;
}
  • Строка 8: при бесконечном ожидании задачу, которая ещё не начала выполняться (например, из Task.Run), Wait пытается выполнить сам, на текущем потоке (WrappedTryRunInline). У задачи async-метода планировщика нет (m_taskScheduler == null), этот путь не срабатывает.
  • Строка 17: немного покрутиться (SpinWait) в надежде, что задача вот-вот завершится.
  • Строка 20: если ждёт поток пула, работа из его локальной очереди переносится в глобальную, чтобы её могли забрать другие потоки, пока этот заблокирован (помните preferLocal: true из главы 5).
  • Строки 21, 24: событие SetOnInvokeMres (это ManualResetEventSlim, у которого «вызвать» = Set()) добавляется первым в продолжения задачи. Завершение задачи взведёт событие.
  • Строка 32: поток засыпает на событии. Это ожидание ядра (WaitSleepJoin в снимке), а не цикл обработки сообщений: делегаты из очереди UI в это время никто не выполняет.
  • Строки 29, 38: пулу сообщают, что поток заблокирован. Это помогает при голодании пула (глава 13), но очередь UI-потока не спасает.
  • Строки 45–48: таймаут истёк, а задача не завершилась: событие убирается из продолжений. Сама задача и её продолжение в очереди UI никуда не деваются.

Разница между тремя способами блокироваться — только в исключениях: .Wait() и .Result заворачивают исключение задачи в AggregateException, а .GetAwaiter().GetResult() бросает исходное (глава 8). От дедлока не спасает ни один.

Факт: застрявшее продолжение не теряется, оно выполнится, как только поток UI освободится

В конце обработчика (строки 23–26 final/Program.cs) печатается статус задачи из опыта 1:

1
2
3
4
  задача из опыта 1 до await: WaitingForActivation
    [Post в очередь UI, вызван на: поток  9 (пул)]
  6) await WithContextAsync() = 1, продолжение на: поток  8 (UI), SynchronizationContext=UiThread
  задача из опыта 1 после await: RanToCompletion

Wait давно вернул false, но продолжение WithContextAsync всё это время лежало в очереди UI (снимки опытов 3 и 4 показывают, как очередь растёт: 1 → 2 → 3). Обработчик дошёл до await в строке 24 и освободил поток UI. Цикл потока UI выполнил всю очередь по порядку, и задача из опыта 1 завершилась. Таймаут на Wait не «отменяет» операцию, он только перестаёт её ждать.

6.3. Способы избежать

final/Program.cs
    Block("2) NoContextAsync().Wait", Work.NoContextAsync(), ui);
    Block("3) OuterOnlyAsync().Wait", Work.OuterOnlyAsync(), ui);
    Block("4) CompletedFirstAsync().Wait", Work.CompletedFirstAsync(), ui);
    Block("5) Task.Run(WithContextAsync).Wait", Task.Run(Work.WithContextAsync), ui);

    Console.WriteLine($"  задача из опыта 1 до await: {stuck.Status}");
    int result = await Work.WithContextAsync();
    Console.WriteLine($"  6) await WithContextAsync() = {result}, продолжение на: {Here.Now}");
    Console.WriteLine($"  задача из опыта 1 после await: {stuck.Status}");
  2) NoContextAsync().Wait            : завершилась=True
    [Post в очередь UI, вызван на: поток  5 (пул)]
    через 500 мс: поток UI заблокирован (WaitSleepJoin), в очереди UI: 2
  3) OuterOnlyAsync().Wait            : завершилась=False
    [Post в очередь UI, вызван на: поток  5 (пул)]
    через 500 мс: поток UI заблокирован (WaitSleepJoin), в очереди UI: 3
  4) CompletedFirstAsync().Wait       : завершилась=False
  5) Task.Run(WithContextAsync).Wait  : завершилась=True
  задача из опыта 1 до await: WaitingForActivation
    [Post в очередь UI, вызван на: поток  9 (пул)]
  6) await WithContextAsync() = 1, продолжение на: поток  8 (UI), SynchronizationContext=UiThread
  задача из опыта 1 после await: RanToCompletion

Вывод одинаков во всех прогонах (кроме номеров потоков пула).

1. Асинхронность до конца — правильный способ

Опыт 6 (строки 24–25): не блокировать. await освобождает поток UI, тот обрабатывает очередь, продолжение WithContextAsync выполняется на нём же, а затем и продолжение обработчика (строка 11 вывода — снова поток UI). Это async all the way: async/await пробрасывается до самой точки входа (обработчик события, action контроллера, Main). Синхронный вызов асинхронного кода — источник и дедлоков, и голодания пула.

2. ConfigureAwait(false) в библиотеке — на каждом await

Опыт 2 (строка 18, Work.cs строка 14): продолжению NoContextAsync поток UI не нужен. Оно выполняется на потоке пула, завершает задачу, и Wait возвращает true. Post не было.

Это защищает вызывающих вашу библиотеку от дедлока, если они всё же блокируются. Но только если ConfigureAwait(false) стоит на каждом await по всей цепочке, причём на await, который действительно ждёт. Две ловушки:

Ловушка 1: ConfigureAwait(false) только снаружи (опыт 3, Work.cs строки 19–28). OuterOnlyAsync ставит ConfigureAwait(false), а вызванный им InnerAsync — нет. Дедлок (строки 2–4 вывода). Почему — видно в машине состояний OuterOnlyAsync (.\tools\disasm.ps1 chapters\06-deadlocks\final -Type Work -Mode cs):

Во что превращается return await InnerAsync().ConfigureAwait(false) (из MoveNext)
1
2
3
4
5
6
7
8
awaiter = InnerAsync().ConfigureAwait(continueOnCapturedContext: false).GetAwaiter();
if (!awaiter.IsCompleted)
{
    num = (<>1__state = 0);
    <>u__1 = awaiter;
    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
    return;
}

Строка 1: сначала вызывается InnerAsync(), и только потом к его задаче применяется ConfigureAwait. InnerAsync выполняется синхронно на потоке UI до своего await Task.Delay(100) и там сам захватывает контекст UI. ConfigureAwait(false) снаружи влияет только на продолжение OuterOnlyAsync, но оно не наступит: задача InnerAsync ждёт своё продолжение в очереди UI.

Ловушка 2: ConfigureAwait(false) на завершённой задаче (опыт 4, Work.cs строки 31–36). Первый await Task.CompletedTask.ConfigureAwait(false) не приостанавливает метод (задача уже завершена), поэтому поток UI мы не покидаем. Следующий await Task.Delay(100) без ConfigureAwait захватывает контекст UI. Дедлок (строки 5–7 вывода). Так бывает и «в жизни»: первый await попал в кеш и завершился синхронно, а второй пошёл в сеть.

Отсюда правило анализатора CA2007 и практика библиотек: ConfigureAwait(false) на всех await во всех методах. Пропуск одного места возвращает дедлок.

3. Task.Run — обходной путь

Опыт 5 (строка 21): Task.Run(Work.WithContextAsync) запускает метод на потоке пула, где контекста нет. Его await не захватывает ничего, продолжение выполняется в пуле, Wait возвращает true (строка 8 вывода).

Это обходной путь, а не решение:

  • поток UI всё равно заблокирован на время операции: интерфейс «замирает»;
  • метод выполняется без контекста: если он трогает элементы UI или полагается на контекст, это ошибка;
  • в серверном коде это занимает два потока вместо нуля и ведёт к голоданию пула.

Применять — только если синхронный вызов неизбежен (например, синхронный интерфейс, который нельзя поменять) и известно, что метод не требует контекста.

Способ Дедлок Поток UI блокируется Где применять
await до конца нет нет везде, где можно
ConfigureAwait(false) на каждом await в библиотеке нет да, если вызывающий блокирует библиотеки
Task.Run(…).GetAwaiter().GetResult() нет да вынужденный sync-over-async
.Result / .Wait() / .GetAwaiter().GetResult() напрямую да, при контексте да нигде

Задание

В start/ найдите три способа получить на потоке UI результат без дедлока (TODO в start/Program.cs и start/Work.cs):

  1. изменить только Work.WithContextAsync;
  2. изменить только вызов в строке 13 start/Program.cs, оставив блокирующий Block;
  3. не блокировать вовсе.

6.4. Без контекста дедлока нет, а проблема есть

В ASP.NET Core, консоли, сервисах и на потоках пула SynchronizationContext нет. Продолжение выполняется на любом потоке пула, и классического дедлока, как в опыте 0, не бывает. Но блокировка .Result / .Wait() занимает поток пула на всё время ожидания. Под нагрузкой заблокированных потоков становится много, пул добавляет новые медленно, и сервис «встаёт» — голодание пула потоков (глава 13). Внешне похоже на дедлок, причина другая.

Старый ASP.NET (.NET Framework) — с контекстом AspNetSynchronizationContext. Он не привязан к одному потоку, но выполняет продолжения запроса по одному. Поэтому .Result в контроллере там даёт тот же дедлок, что и в UI.

6.5. Другие источники дедлоков с async

  • lock и синхронное ожидание. Поток держит lock и блокируется на задаче, а продолжению этой задачи (на другом потоке) нужен тот же lock. await внутри lock компилятор запрещает, а .Wait() — нет.
  • Синхронные продолжения TaskCompletionSource (глава 5, §5.6). Вы держите блокировку и вызываете SetResult, а чужое продолжение выполняется инлайн внутри этого вызова и ждёт ресурс, который держите вы. Лечение — RunContinuationsAsynchronously.
  • SemaphoreSlim.Wait() вместо WaitAsync() в async-коде: блокирует поток, а при контексте может замкнуть такой же круг, как в опыте 1.
  • Блокирующая инициализация: .Result в конструкторе, статическом конструкторе, Lazy<T>, фабрике DI. Конструкторы не бывают async, и туда «просто подставляют» .Result. Решение — асинхронная фабрика или ленивая асинхронная инициализация (глава 10).
  • async void-обработчик, который пытаются ждать. Его задачу не получить, и приходится изобретать синхронное ожидание через события. Обработчики событий оставляйте async void, но не ждите их из другого кода.

6.6. Итоги

  • Дедлок sync-over-async: поток контекста блокируется на задаче, а продолжение этой задачи стоит в очереди того же контекста. Нужны оба условия: однопоточный (или «по одному») контекст и блокирующее ожидание на нём.
  • .Wait(), .Result, .GetAwaiter().GetResult() одинаково спят на событии и не обрабатывают очередь контекста. Различаются только видом исключения.
  • Таймаут на Wait не отменяет операцию: продолжение выполнится, как только поток освободится.
  • Правильно — await до конца. В библиотеках — ConfigureAwait(false) на каждом await. Он не помогает, если его нет во внутреннем методе или если он стоит на уже завершённой задаче. Task.Run — обходной путь, поток всё равно блокируется.
  • Без контекста (ASP.NET Core) дедлока нет, но блокировки ведут к голоданию пула.

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

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

start/Program.cs
// Глава 6, заготовка. Дедлок sync-over-async на однопоточном контексте (как UI-поток).
// PREDICT: что напечатает Block без контекста и на потоке UI? Что покажет снимок через 500 мс?
// TODO: найдите три способа получить на потоке UI "завершилась=True" или результат без блокировки (§6.3):
//   1) изменить Work.WithContextAsync; 2) изменить только вызов в строке с TODO; 3) не блокировать вовсе.

Console.WriteLine("== Без контекста ==");
Block("WithContextAsync().Wait", Work.WithContextAsync(), ui: null);       // PREDICT:

var ui = new UiThread();
await ui.RunAsync(async () =>
{
    Console.WriteLine($"== На потоке UI: {Here.Now} ==");
    Block("WithContextAsync().Wait", Work.WithContextAsync(), ui);          // PREDICT:   TODO (способы 2 и 3)
    await Task.CompletedTask;
});

// Блокирующее ожидание с таймаутом 1 с. Если через 500 мс задача ещё не готова — снимок потока UI.
static void Block(string title, Task<int> task, UiThread? ui)
{
    using var probe = new Timer(_ => Console.WriteLine($"    через 500 мс: {ui?.Snapshot ?? "контекста нет"}"),
                                null, dueTime: 500, period: Timeout.Infinite);
    bool done = task.Wait(TimeSpan.FromSeconds(1));
    Console.WriteLine($"  {title,-28}: завершилась={done}");
}
start/Work.cs
1
2
3
4
5
6
7
8
9
// «Библиотечный» async-метод, который вызывает обработчик на потоке UI.
static class Work
{
    public static async Task<int> WithContextAsync()
    {
        await Task.Delay(100);                  // TODO (способ 1): что поменять здесь?
        return 1;
    }
}
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));
    }

    // Что сейчас с потоком UI: заблокирован ли он и сколько делегатов ждёт в очереди.
    public string Snapshot =>
        $"поток UI {((_thread.ThreadState & ThreadState.WaitSleepJoin) != 0 ? "заблокирован (WaitSleepJoin)" : "работает")}, " +
        $"в очереди UI: {_queue.Count}";

    // Запустить 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}";
            return where;
        }
    }
}
final/Program.cs
// Глава 6, итог. Дедлок sync-over-async на однопоточном контексте и способы его избежать:
//   0) без контекста блокирующее ожидание дедлока не даёт (§6.1);
//   1) на потоке UI — дедлок, видно, где застряло продолжение (§6.2);
//   2–5) ConfigureAwait(false) на каждом await, две ловушки с ним, Task.Run (§6.3);
//   6) правильно: await без блокировки (§6.3).

Console.WriteLine("== 0. Без контекста ==");
Block("0) WithContextAsync().Wait", Work.WithContextAsync(), ui: null);

var ui = new UiThread();
Task<int>? stuck = null;
await ui.RunAsync(async () =>
{
    Console.WriteLine();
    Console.WriteLine($"== На потоке UI: {Here.Now} ==");
    stuck = Work.WithContextAsync();
    Block("1) WithContextAsync().Wait", stuck, ui);
    Block("2) NoContextAsync().Wait", Work.NoContextAsync(), ui);
    Block("3) OuterOnlyAsync().Wait", Work.OuterOnlyAsync(), ui);
    Block("4) CompletedFirstAsync().Wait", Work.CompletedFirstAsync(), ui);
    Block("5) Task.Run(WithContextAsync).Wait", Task.Run(Work.WithContextAsync), ui);

    Console.WriteLine($"  задача из опыта 1 до await: {stuck.Status}");
    int result = await Work.WithContextAsync();
    Console.WriteLine($"  6) await WithContextAsync() = {result}, продолжение на: {Here.Now}");
    Console.WriteLine($"  задача из опыта 1 после await: {stuck.Status}");
});

// Блокирующее ожидание с таймаутом 1 с. Если через 500 мс задача ещё не готова — снимок потока UI.
static void Block(string title, Task<int> task, UiThread? ui)
{
    using var probe = new Timer(_ => Console.WriteLine($"    через 500 мс: {ui?.Snapshot ?? "контекста нет"}"),
                                null, dueTime: 500, period: Timeout.Infinite);
    bool done = task.Wait(TimeSpan.FromSeconds(1));
    Console.WriteLine($"  {title,-36}: завершилась={done}");
}
final/Work.cs
// «Библиотечные» async-методы, которые вызывает обработчик на потоке UI.
static class Work
{
    // Обычный await: продолжение захочет вернуться в контекст вызывающего.
    public static async Task<int> WithContextAsync()
    {
        await Task.Delay(100);
        return 1;
    }

    // ConfigureAwait(false): продолжению не нужен поток контекста.
    public static async Task<int> NoContextAsync()
    {
        await Task.Delay(100).ConfigureAwait(false);
        return 2;
    }

    // ConfigureAwait(false) стоит только снаружи, а внутренний метод его не ставит.
    public static async Task<int> OuterOnlyAsync()
    {
        return await InnerAsync().ConfigureAwait(false);
    }

    private static async Task<int> InnerAsync()
    {
        await Task.Delay(100);
        return 3;
    }

    // ConfigureAwait(false) стоит, но на уже завершённой задаче.
    public static async Task<int> CompletedFirstAsync()
    {
        await Task.CompletedTask.ConfigureAwait(false);
        await Task.Delay(100);
        return 4;
    }
}
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));
    }

    // Что сейчас с потоком UI: заблокирован ли он и сколько делегатов ждёт в очереди.
    public string Snapshot =>
        $"поток UI {((_thread.ThreadState & ThreadState.WaitSleepJoin) != 0 ? "заблокирован (WaitSleepJoin)" : "работает")}, " +
        $"в очереди UI: {_queue.Count}";

    // Запустить 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}";
            return where;
        }
    }
}