Решение: взаимная блокировка при вызове .Result или .Wait() у async-метода в C#
Блокировка async-Task через .Result или .Wait() приводит к взаимной блокировке при наличии SynchronizationContext. Здесь объясняется, почему происходит зависание и как это исправить в .NET 11 и C# 14.
Если вызов task.Result, task.Wait() или task.GetAwaiter().GetResult() зависает навсегда и никогда не выбрасывает исключение, у вас взаимная блокировка типа sync-над-async. Она возникает, когда вы блокируете поток, владеющий однопоточным SynchronizationContext (поток UI в WPF или WinForms, поток запроса в классическом ASP.NET), в то время как async-метод, который вы блокируете, пытается возобновить своё продолжение обратно в том же самом потоке. Поток застрял в ожидании задачи; задача застряла в ожидании потока. Решение состоит в том, чтобы перестать блокировать: сделать всю цепочку вызовов асинхронной от начала до конца, чтобы вы использовали await вместо .Result. Эта статья объясняет механизм в .NET 11 (Microsoft.NET.Sdk 11.0.0, C# 14) и разбирает каждое решение в порядке предпочтения, включая те, что выглядят правильными, но не работают.
Почему поток ждёт сам себя
await делает две вещи, о которых люди забывают. Прежде чем приостановиться, он захватывает текущий SynchronizationContext (через SynchronizationContext.Current). Когда ожидаемая задача завершается, она не просто возобновляется в любом потоке: по умолчанию она отправляет продолжение, код после await, обратно в захваченный контекст. В обычном рабочем потоке пула потоков контекста нет, поэтому продолжение выполняется в любом свободном потоке пула, и ничего особенного не происходит. Но в потоке UI или в запросе классического ASP.NET контекст однопоточный. В нём ровно один поток, которому разрешено выполнять поставленную в очередь работу.
Теперь поставьте эти два факта рядом с блокирующим вызовом:
- Ваш поток UI вызывает
GetDataAsync().Result. Это блокирует поток UI и удерживает его. - Внутри
GetDataAsyncвызовawait SomeIoAsync()захватилSynchronizationContextUI перед приостановкой. SomeIoAsyncзавершается. Среда выполнения пытается отправить продолжениеGetDataAsyncобратно в контекст UI, чтобы выполнить остаток метода и завершить задачу.- В контексте UI один поток. Этот поток заблокирован на шаге 1 в ожидании завершения задачи. Он никогда не подхватит продолжение.
- Задача не может завершиться, пока не выполнится продолжение. Продолжение не может выполниться, пока не освободится поток. Поток не освободится, пока не завершится задача. Взаимная блокировка.
Стивен Клири назвал этот шаблон много лет назад в Don’t Block on Async Code, и механизм не изменился. Среда выполнения не содержит ошибки. Блокировка на задаче, продолжению которой нужен поток, который вы блокируете, это настоящее циклическое ожидание.
Минимальное воспроизведение, которое зависает
Вам нужны две вещи: однопоточный SynchronizationContext и блокирующий вызов поверх await, который его захватывает. Обработчик кнопки WinForms это классическое воспроизведение, но проект UI вам не нужен. Вы можете установить однопоточный контекст вручную и увидеть, как он зависает.
// .NET 11, C# 14 -- this deadlocks
using System.Threading;
var context = new SingleThreadedSyncContext();
SynchronizationContext.SetSynchronizationContext(context);
// Block on an async method from the context-owning thread:
string result = GetGreetingAsync().Result; // hangs forever
Console.WriteLine(result);
static async Task<string> GetGreetingAsync()
{
// Captures the current (single-threaded) context here:
await Task.Delay(100);
// The runtime tries to post THIS line back to the captured context,
// but that thread is blocked on .Result above.
return "hello";
}
В реальном приложении WPF или WinForms вы не пишете SetSynchronizationContext сами. Фреймворк устанавливает DispatcherSynchronizationContext (WPF) или WindowsFormsSynchronizationContext (WinForms) в потоке UI перед тем, как выполнятся ваши обработчики событий, поэтому любой обработчик, делающий SomethingAsync().Result, воспроизводит это мгновенно. Классический ASP.NET (System.Web, не ASP.NET Core) устанавливает AspNetSynchronizationContext в потоке запроса с тем же однопоточным поведением.
Единственное настоящее решение: асинхронность от начала до конца
Взаимная блокировка существует потому, что вы заблокировали. Уберите блокировку, и она исчезнет. Распространите async/await вверх по цепочке вызовов, пока самый внешний вызывающий не сможет использовать await вместо чтения .Result.
// .NET 11, C# 14 -- no block, no deadlock
private async void OnLoadClick(object sender, EventArgs e)
{
string greeting = await GetGreetingAsync(); // await, not .Result
label.Text = greeting;
}
Здесь await по-прежнему захватывает контекст UI, но ничто не блокирует поток UI. Обработчик приостанавливается, поток UI возвращается в цикл сообщений и остаётся свободным, а когда GetGreetingAsync завершается, его продолжение отправляется обратно и чисто выполняется в теперь простаивающем потоке UI. Именно для этого и нужен SynchronizationContext UI. Продолжение приземляется обратно в потоке UI, поэтому вы можете обращаться к label.Text без маршалинга.
Обработчики событий это единственное санкционированное место для async void именно потому, что они находятся на вершине стека вызовов и не имеют вызывающего, который бы их ожидал. Всё, что находится под ними, должно быть async Task. Если вы не уверены, где async void уместен, а где это ошибка, различие разбирается в статье когда async void корректен, а когда это ловушка.
То же правило применяется на сервере. Action в классическом ASP.NET MVC, обработчик Razor Page, метод хаба SignalR: сделайте их async Task и используйте await для работы вместо блокировки. Здесь нет частичного зачёта. Один-единственный .Result в любой точке синхронного пути может снова привнести взаимную блокировку, даже если каждый другой слой асинхронный.
Библиотечное решение: ConfigureAwait(false)
Иногда вы не можете сделать асинхронной всю цепочку, потому что блокирующий вызов находится в коде, которым вы не владеете. Если вы автор async-библиотеки, на которой блокируются, вы можете обезвредить взаимную блокировку со своей стороны, сказав каждому await не захватывать контекст:
// .NET 11, C# 14 -- library code that stays deadlock-safe under a blocking caller
public async Task<string> GetGreetingAsync()
{
await Task.Delay(100).ConfigureAwait(false);
// No captured context, so this continuation runs on a thread pool
// thread, not the caller's blocked UI/request thread.
return "hello";
}
ConfigureAwait(false) говорит “мне не нужно возобновляться в захваченном контексте.” Продолжение вместо этого выполняется в потоке пула потоков, который не является заблокированным, поэтому циклическое ожидание никогда не образуется и задача может завершиться. Именно поэтому рекомендация для общих библиотек состоит в том, чтобы ставить .ConfigureAwait(false) на каждый await, как Microsoft разъясняет в ConfigureAwait FAQ.
Две оговорки не дают этому быть общим лекарством. Во-первых, это помогает только если применяется к каждому await во всём транзитивном замыкании заблокированного вызова. Пропустите один await в глубине зависимости, и взаимная блокировка вернётся, что и есть причина, по которой это дисциплина библиотеки, а не решение, которое вы разбрасываете в месте вызова. Во-вторых, в собственном коде приложения вам вообще не следует блокировать, поэтому ConfigureAwait(false) в коде приложения лечит симптом. Нюанс того, когда это всё ещё имеет значение и когда анализаторы компилятора подталкивают вас к нему, разобран в статье ConfigureAwait(false) против поведения по умолчанию в .NET 11.
Решения, которые выглядят правильными, но не работают
Замена .Result на .GetAwaiter().GetResult(). Люди прибегают к этому, потому что это разворачивает исключение вместо оборачивания его в AggregateException. Это ничего не меняет во взаимной блокировке. GetAwaiter().GetResult() по-прежнему блокирует вызывающий поток до завершения задачи, а задача по-прежнему не может завершиться, потому что её продолжение стоит в очереди за блокировкой. Лучшие исключения, идентичное зависание.
Добавление тайм-аута через Wait(TimeSpan). task.Wait(5000) вернёт false через пять секунд вместо вечного зависания, но это не решение, это более медленный отказ. Операция всё равно не завершилась, и теперь вы залатали проблему проектирования магическим числом. Нижележащее продолжение по-прежнему застряло.
Оборачивание async-метода в Task.Run и блокировка на нём. Это действительно разрывает взаимную блокировку, и именно поэтому это опасно. Task.Run(() => GetGreetingAsync()).GetAwaiter().GetResult() запускает async-метод в потоке пула потоков, у которого нет однопоточного контекста, поэтому его продолжения больше не нацелены на ваш заблокированный поток UI. Зависание исчезает.
// .NET 11, C# 14 -- avoids the deadlock, but it is a smell, not a solution
string greeting = Task.Run(() => GetGreetingAsync()).GetAwaiter().GetResult();
Это работает, но теперь вы сжигаете поток пула потоков, чтобы заблокировать другой поток, вы потеряли контекст UI для любого продолжения, которому он законно был нужен, и вы скрыли тот факт, что вызов должен был быть асинхронным. Microsoft документирует этот шаблон выгрузки в синхронных обёртках для асинхронных методов с тем же предупреждением: относитесь к этому как к крайней мере для действительно только-синхронной точки входа, а не как к способу продолжать писать блокирующий код.
Почему ASP.NET Core здесь не вызывает взаимной блокировки (и как он кусается иначе)
Если вы перешли с классического ASP.NET на ASP.NET Core и ваши старые взаимные блокировки исчезли, вот причина: в ASP.NET Core нет SynchronizationContext. SynchronizationContext.Current внутри запроса равен null, поэтому await никогда не захватывает однопоточный контекст, продолжения всегда выполняются в потоках пула потоков, и конкретное циклическое ожидание, описанное выше, не может образоваться. Именно поэтому ConfigureAwait(false) также не оказывает эффекта в обработчике запроса ASP.NET Core: нет контекста, от которого можно было бы отказаться.
Это не делает блокировку безопасной в ASP.NET Core. Она меняет детерминированную взаимную блокировку на вероятностную, называемую истощением пула потоков. Каждый запрос, блокирующийся на .Result, паркует поток пула потоков, который не делает ничего, кроме ожидания. Под нагрузкой пул раздаёт потоки быстрее, чем (по умолчанию постепенная) скорость впрыска может заменить запаркованные, поэтому новые запросы встают в очередь без потока для выполнения. Приложение не зависает на первом запросе; оно падает при уровне параллелизма, который вы не можете воспроизвести на своём ноутбуке. Лекарство идентично: не блокируйте, идите асинхронно от начала до конца. Если ваша блокировка была там, чтобы ограничить длительную операцию, сделайте это с отменой, как в статье отмена долго выполняющейся Task без взаимной блокировки, и убедитесь, что токен действительно достигает листового вызова, распространив CancellationToken по цепочке.
Контрольный список для поиска блокировки, которая зависает
Когда что-то зависает и вы подозреваете это, ищите блокировку, а не async-метод:
- Найдите в синхронном пути
.Result,.Wait(и.GetAwaiter().GetResult(). Один из них находится в потоке, владеющем контекстом. Это ваш виновник, а не невинныйawait, который он блокирует. - Подтвердите, что в игре однопоточный контекст. Поток UI, запрос классического ASP.NET или пользовательский контекст. Если вы на ASP.NET Core или в простом консольном приложении без установленного контекста, симптом это истощение или медленный ответ, а не жёсткое зависание.
- Замените блокировку на
awaitи сделайте охватывающий методasync Task. Повторяйте вверх по стеку, пока не достигнете точки входа, которая может быть асинхронной (обработчик событий,Main, action контроллера). - Если слой действительно не может быть async и вы владеете async-библиотекой, добавьте
ConfigureAwait(false)во всю эту библиотеку. Если вы ей не владеете, выгрузка черезTask.Runэто крайняя мера, с указанными выше издержками. - Никогда не “исправляйте” это тайм-аутом.
Wait(timeout), возвращающий false, это взаимная блокировка, которая сдаётся, а не работающий дизайн.
Сквозная мысль проста: async-код хочет оставаться async. В тот момент, когда вы блокируете его из потока, который нужен его продолжению, вы вручную построили циклическое ожидание. Перестаньте блокировать, и взаимная блокировка не сможет существовать. Всё остальное на этой странице это устранение последствий для случаев, когда вы ещё не можете перестать блокировать.
Related
- Когда async void корректен, а когда это ловушка в C#
- ConfigureAwait(false) против поведения по умолчанию в .NET 11: имеет ли это ещё значение?
- Как отменить долго выполняющуюся Task в C# без взаимной блокировки
- Как распространить CancellationToken через async-методы в .NET 11
Sources
- Don’t Block on Async Code — Stephen Cleary
- ConfigureAwait FAQ — .NET Blog
- ASP.NET Core SynchronizationContext — Stephen Cleary
- Synchronous wrappers for asynchronous methods — Microsoft Learn
- CA2007: Do not directly await a Task — Microsoft Learn
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.