Deadlock и ConfigureAwait

Вопрос-крючок, на который ловят Middle: «Этот код намертво вешает приложение. Объясните почему — и почему в консольном приложении он работает».

Deadlock (взаимоблокировка) здесь возникает не между двумя lock, а между потоком и его же продолжением: поток блокируется в ожидании задачи, а задача не может завершиться, потому что её продолжение ждёт освобождения этого самого потока.

Код, который вешает приложение

// WPF / WinForms — обработчик кнопки. Или контроллер классического ASP.NET (не Core).
private void OnLoadClick(object sender, EventArgs e)
{
    // «Мне же нужно значение прямо сейчас, я просто заберу .Result»
    string html = GetPageAsync().Result;   // ЗДЕСЬ ВСЁ ЗАМИРАЕТ НАВСЕГДА
    textBox.Text = html;
}

private async Task<string> GetPageAsync()
{
    using var http = new HttpClient();
    string html = await http.GetStringAsync("https://example.com");  // (1)
    return html.Trim();                                              // (2) — сюда мы не попадём
}

Разбор по шагам

  1. Обработчик выполняется в UI-потоке. В нём SynchronizationContext.Current — это UI-контекст, и он однопоточный: продолжения он умеет исполнять только на этом единственном потоке, через очередь сообщений.
  2. Вызывается GetPageAsync(). Она доходит до await в строке (1), сетевой запрос ещё не завершён — метод приостанавливается. Перед этим awaiter захватывает текущий контекст и запоминает: «когда HTTP-ответ придёт, продолжение (строку 2) нужно выполнить в UI-потоке».
  3. Управление возвращается в обработчик. Тот вызывает .Result — и блокирует UI-поток, ожидая завершения задачи.
  4. Ответ от сервера приходит. Рантайм пытается поставить продолжение в очередь UI-потока. Но UI-поток стоит намертво в .Result и очередь не разгребает.
  5. Продолжение никогда не выполнится → задача никогда не завершится → .Result никогда не разблокируется. Классический клинч: каждый ждёт другого.

Почему в консоли не воспроизводится? Потому что в консольном приложении и в ASP.NET Core SynchronizationContext.Current == null. Захватывать нечего, продолжение просто уходит в пул потоков — там всегда есть свободный, задача завершается, .Result разблокируется. Проверьте:

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Console.WriteLine($"SynchronizationContext.Current = {SynchronizationContext.Current?.ToString() ?? "null"}");

        // В консоли контекста нет — продолжение уходит в пул, и .Result не «клинчит».
        // В WPF/WinForms/ASP.NET Framework ровно эта строка повесила бы приложение.
        string data = GetDataAsync().Result;
        Console.WriteLine(data);
    }

    static async Task<string> GetDataAsync()
    {
        await Task.Delay(100);
        return "Задача завершилась: продолжение выполнил поток из пула";
    }
}

Результат:

SynchronizationContext.Current = null
Задача завершилась: продолжение выполнил поток из пула

Отсюда самое коварное свойство этого бага: он не воспроизводится в консольном тесте и в unit-тестах. Разработчик проверяет — работает. А в проде на WPF или в старом ASP.NET приложение виснет.

Где контекст есть, а где нет

СредаSynchronizationContextЧто будет от .Result
WPF / WinForms / MAUIОднопоточный UI-контекстDeadlock
ASP.NET (Framework, 4.x)Контекст запроса, один поток за разDeadlock
ASP.NET CorenullDeadlock'а нет, но поток пула заблокирован → thread pool starvation
Консоль / фоновая службаnullРаботает, но блокирует поток впустую
xUnit / NUnitОбычно nullТест зелёный — баг доедет до прода

Обратите внимание на строку ASP.NET Core: отсутствие deadlock'а не означает «можно блокировать». Заблокированный поток пула под нагрузкой — это тот самый голод пула из прошлого урока.

ConfigureAwait(false): что он на самом деле делает

ConfigureAwait(false) — это указание awaiter'у: «не захватывай контекст, продолжение можно выполнить где угодно». Продолжение уйдёт в пул потоков — и цепочка сможет завершиться, даже если исходный поток заблокирован.

private async Task<string> GetPageAsync()
{
    using var http = new HttpClient();
    // не возвращаемся в UI-поток — нам он тут не нужен, мы не трогаем элементы формы
    string html = await http.GetStringAsync("https://example.com").ConfigureAwait(false);
    return html.Trim();   // выполнится на потоке пула — и задача спокойно завершится
}

Если поставить ConfigureAwait(false) на все await'ы внутри библиотечного метода, вызов с .Result из UI перестанет виснуть. Но это лечение симптома, а не болезни: поток по-прежнему блокируется впустую, и достаточно одного забытого ConfigureAwait глубоко в цепочке, чтобы deadlock вернулся.

Правильная позиция, которую хотят услышать:

  • В библиотечном коде (общие сервисы, NuGet-пакеты, слой доступа к данным) — ConfigureAwait(false) на каждом await. Библиотека не знает, из какой среды её вызовут, и ей не нужен ничей UI-поток. Заодно это чуть быстрее: нет лишнего прыжка через очередь сообщений.
  • В коде приложения (обработчики UI, ViewModel) — ConfigureAwait(false) не ставим: после await нам как раз нужно вернуться в UI-поток, чтобы обновить элементы. Иначе получим InvalidOperationException про «доступ из другого потока».
  • В ASP.NET CoreConfigureAwait(false) технически не нужен (контекста нет), поэтому в коде самого приложения его обычно не пишут; в разделяемых библиотеках — всё равно ставят.

Настоящее лекарство: async всю дорогу

Правило звучит так: async all the way — асинхронность не должна обрываться синхронным вызовом. Если метод вызывает асинхронный код, он сам обязан стать асинхронным, и так до самого верха — до обработчика события, контроллера или Main.

// БЫЛО: цепочка обрывается блокировкой
private void OnLoadClick(object sender, EventArgs e)
{
    textBox.Text = GetPageAsync().Result;              // deadlock: поток ждёт сам себя
}

// СТАЛО: цепочка асинхронна до самого верха
private async void OnLoadClick(object sender, EventArgs e)   // async void допустим ТОЛЬКО здесь
{
    try
    {
        textBox.Text = await GetPageAsync();           // UI-поток свободен, продолжение вернётся в него
    }
    catch (HttpRequestException ex)
    {
        MessageBox.Show(ex.Message);
    }
}

Бонусом вы избавитесь от второй неприятности .Result и .Wait() — они заворачивают исключение в AggregateException, ломая привычные catch-блоки. Сравните:

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        try { BoomAsync().Wait(); }
        catch (Exception ex) { Console.WriteLine($".Wait()             -> {ex.GetType().Name}"); }

        try { BoomAsync().GetAwaiter().GetResult(); }
        catch (Exception ex) { Console.WriteLine($".GetAwaiter().GetResult() -> {ex.GetType().Name}"); }
    }

    static async Task BoomAsync()
    {
        await Task.Delay(10);
        throw new InvalidOperationException("Всё сломалось");
    }
}

Результат:

.Wait()             -> AggregateException
.GetAwaiter().GetResult() -> InvalidOperationException

Написали catch (InvalidOperationException) — и он не сработал, потому что .Wait() подсунул AggregateException. Отсюда практический совет: если блокировать совсем неизбежно (например, в Main старого приложения или в конструкторе, который не может быть async), используйте GetAwaiter().GetResult() — он хотя бы пробрасывает исходное исключение. Deadlock, впрочем, он не лечит.

Частые ошибки на собеседовании

  • «ConfigureAwait(false) делает код быстрее / выполняет его в фоне». Он не про скорость и не про фон. Он только про то, захватывать ли контекст для продолжения.
  • «Достаточно поставить ConfigureAwait(false) в вызывающем методе». Нет: важен каждый await внутри всей цепочки, ведь клинч создаёт тот await, который захватил UI-контекст.
  • «В ASP.NET Core deadlock невозможен, значит .Result безопасен». Deadlock'а нет, но блокировка потока пула под нагрузкой убивает пропускную способность.
  • Спрятать .Result внутрь конструктора или свойства. Инициализацию, требующую I/O, выносят в асинхронный фабричный метод: public static async Task<Service> CreateAsync().
  • Путать этот deadlock с обычной взаимоблокировкой lock. Здесь нет двух ресурсов и двух потоков — есть один поток, который сам себя ждёт.

Итоги-шпаргалка

  • Deadlock: await захватил однопоточный контекст (UI / ASP.NET Framework), а вызывающий заблокировал этот поток через .Result/.Wait() — продолжению негде выполниться.
  • В консоли и ASP.NET Core контекста нет — баг не воспроизводится, поэтому он и доезжает до прода.
  • ConfigureAwait(false) = «не возвращайся в захваченный контекст». В библиотеках — ставим везде, в UI-коде приложения — нет.
  • Настоящее решение — async всю дорогу: await до самого верха, без синхронных «мостиков».
  • .Wait()/.Result заворачивают исключение в AggregateException; вынужденная блокировка — только GetAwaiter().GetResult().
  • Асинхронный конструктор невозможен — используйте асинхронный фабричный метод.
Проверьте себя
1. Почему код с .Result вешает WPF-приложение, но спокойно отрабатывает в консольном тесте?
AВ консоли Task выполняется синхронно, поэтому ждать нечего
BВ WPF есть однопоточный SynchronizationContext, в который await возвращает продолжение; в консоли контекста нет и продолжение уходит в пул потоков
CВ консоли пул потоков больше, поэтому свободный поток находится всегда
DВ WPF метод .Result вообще не поддерживается и выбрасывает исключение
2. Что делает ConfigureAwait(false)?
AОтменяет задачу, если она не завершилась вовремя
BЗапускает await-выражение в отдельном потоке
CГоворит awaiter'у не захватывать текущий контекст синхронизации — продолжение можно выполнить на любом потоке пула
DОтключает проверку исключений внутри задачи