.Result vs .Wait() vs GetAwaiter().GetResult() vs await в C#: что использовать?
await -- правильный ответ почти всегда. Когда вам действительно нужно блокировать, GetAwaiter().GetResult() превосходит .Result и .Wait(), потому что выбрасывает исходное исключение. Матрица решений для .NET 11 и C# 14.
Если у вас есть Task<T> и вы хотите получить из него T, у вас есть четыре варианта: task.Result, task.Wait(), task.GetAwaiter().GetResult() и await task. Используйте await. Это единственный вариант, который не блокирует поток, и он выбрасывает ровно то исключение, которое выбросил ваш код, а не обёртку. Остальные три блокируют вызывающий поток и рискуют взаимной блокировкой; среди них GetAwaiter().GetResult() наименее плох, потому что разворачивает исключения так же, как await. Прибегайте к нему только тогда, когда застряли в синхронном методе, который не можете сделать async. Это справедливо для .NET 11 (Microsoft.NET.Sdk 11.0.0) с C# 14, и семантика стабильна со времён .NET Framework 4.5.
Все четыре кратко
| Поведение | await | GetAwaiter().GetResult() | .Result | .Wait() |
|---|---|---|---|---|
| Блокирует вызывающий поток | нет | да | да | да |
| Возвращает значение | да (T) | да (T) | да (T) | нет (void) |
Работает с негенерическим Task | да | да | нет (только Task<T>) | да |
| Выбрасываемое исключение | исходное | исходное | AggregateException | AggregateException |
| Риск взаимной блокировки (захваченный контекст) | нет | да | да | да |
| Истощение thread pool под нагрузкой | нет | да | да | да |
Безопасно для ValueTask<T> | да (один раз) | нет | только если завершена | n/a |
Прочтите эту таблицу сверху вниз для await, и вы получите чистый столбец: без блокировки, реальное значение, исходное исключение, без взаимной блокировки. В любом другом столбце есть хотя бы одно «да» в строке, которую вы не хотите. В этом весь аргумент. Остальная часть статьи объясняет, почему каждая строка верна и когда компромисс действительно вынуждает вас.
Почему await выигрывает по умолчанию
await — это не более изящный способ вызвать .Result. Это другая операция. Когда вы делаете await задачи, которая ещё не завершилась, метод приостанавливается и возвращает управление своему вызывающему коду. Ни один поток не сидит и не ждёт. Среда выполнения планирует остаток вашего метода как продолжение, которое выполняется, когда задача завершается. Блокирующий член делает противоположное: он паркует текущий поток и удерживает его, пока задача не будет готова.
Именно это единственное различие объясняет, почему await масштабируется, а блокировка — нет. На сервере заблокированный поток — это поток из thread pool, который не делает ничего, кроме ожидания, и под нагрузкой они у вас заканчиваются. В потоке интерфейса заблокированный поток — это замороженное окно. await освобождает поток для другой работы (обслужить другой запрос, прокрутить цикл сообщений) и возобновляет ваш метод позже.
// .NET 11, C# 14 -- the default: no thread is blocked while the I/O runs
public async Task<string> GetGreetingAsync(HttpClient http)
{
string body = await http.GetStringAsync("https://example.com/greeting");
return body.Trim();
}
await также даёт вам то исключение, которое вы действительно выбросили. Если GetStringAsync выбрасывает HttpRequestException, await повторно выбрасывает этот HttpRequestException с его исходной трассировкой стека ровно там, где вы сделали await. Без разворачивания, без гимнастики catch (AggregateException). Если у вас нет конкретной причины блокировать, на этом решение заканчивается.
Когда GetAwaiter().GetResult() — правильный блокирующий вызов
Иногда вы не можете быть асинхронными. Конструктор класса не может быть async. Main до C# 7.1, Dispose (не DisposeAsync), метод интерфейса, сигнатуру которого вы не контролируете, точка входа стороннего плагина, которая передаёт вам синхронный делегат: это по-настоящему синхронные швы. Если вам нужно вызвать асинхронный код изнутри одного из них и вы не можете реструктурировать, вам нужно на чём-то блокировать. Блокируйте на GetAwaiter().GetResult().
Причина, по которой он превосходит .Result и .Wait(), — точность исключений. Task.Result и Task.Wait() появились раньше async/await; они происходят из Task Parallel Library в .NET 4.0, где один Task (вспомните Task.WhenAll) мог завершиться сразу несколькими исключениями. Чтобы это представить, они оборачивают то, что пошло не так, в AggregateException, даже когда внутреннее исключение ровно одно. GetAwaiter().GetResult() был добавлен вместе с async/await в .NET 4.5 и следует соглашению await: он выбрасывает первое исключение напрямую, без обёртки.
// .NET 11, C# 14 -- same failing task, three different exceptions surfaced
static async Task<int> FailAsync()
{
await Task.Yield();
throw new InvalidOperationException("boom");
}
// .Result -> throws AggregateException wrapping InvalidOperationException
try { _ = FailAsync().Result; }
catch (Exception ex) { Console.WriteLine(ex.GetType().Name); } // AggregateException
// GetAwaiter().GetResult() -> throws InvalidOperationException directly
try { _ = FailAsync().GetAwaiter().GetResult(); }
catch (Exception ex) { Console.WriteLine(ex.GetType().Name); } // InvalidOperationException
Если ваши блоки catch написаны для InvalidOperationException (как и должно быть), .Result молча их обходит, потому что исключение приходит обёрнутым. В итоге вы ловите AggregateException и вызываете .InnerException, или, что хуже, исключение остаётся необработанным, потому что никто не ожидал обёртки. GetAwaiter().GetResult() избегает всего этого. Именно поэтому стандартная рекомендация, восходящая к серии «A Tour of Task» Стивена Клири, такова: если у вас нет иного выбора, кроме блокировки, блокируйте с помощью GetAwaiter().GetResult().
Он также работает с негенерическим Task, поэтому это единственный блокирующий вызов, который покрывает и «выполни это и подожди», и «выполни это и дай мне значение»:
// .NET 11, C# 14 -- blocks and unwraps, whether or not there is a return value
SaveAsync().GetAwaiter().GetResult(); // Task, no value
int count = CountAsync().GetAwaiter().GetResult(); // Task<int>, value
Почему .Result и .Wait() строго хуже
.Result и .Wait() делают всё то же, что и GetAwaiter().GetResult() (блокируют поток, та же подверженность взаимной блокировке), и добавляют сверху обёртку AggregateException. Нет сценария, в котором обёртка помогает вам, когда задача — одна логическая операция. Единственное место, где .Result читается приемлемо, — на задаче, о которой вы уже знаете, что она завершилась, где он не будет блокировать:
// .NET 11, C# 14 -- .Result on a known-completed task does not block
if (task.IsCompletedSuccessfully)
{
var value = task.Result; // safe: completed, so no wait, no deadlock
}
Даже там GetAwaiter().GetResult() — прекрасная замена, которая сохраняет вашу обработку исключений единообразной, если предположение о завершении когда-нибудь окажется ложным. У .Wait() самое узкое законное применение: ожидание задачи «запустил и забыл», где вы намеренно не хотите возвращаемого значения и обрабатываете AggregateException явно. На практике это редкость, и обычно это признак того, что работу следовало оформить как отдельную фоновую задачу. Если вы выполняете работу вне потока запроса, делайте это с помощью паттернов из безопасного выполнения работы «запустил и забыл» с BackgroundService, а не блокируя на свободной задаче.
Есть реальная ловушка с .Wait(timeout) и .Wait(cancellationToken). Они заставляют ожидание сдаться раньше, что выглядит как устойчивость, но ею не является. Wait(5000), возвращающий false, не отменил базовую операцию; задача всё ещё выполняется, её продолжение всё ещё в очереди, и вы просто перестали её ждать. Вы прикрыли зависание магическим числом. Если вам нужно ограничить операцию, отмените её как положено, как описано в ограничении времени асинхронной операции с CancellationTokenSource.CancelAfter.
Деталь, которая решает за вас: взаимные блокировки и ValueTask
Две вещи могут полностью лишить вас выбора.
Захваченный SynchronizationContext. Если поток, на котором вы блокируете, владеет однопоточным контекстом (поток интерфейса WPF или WinForms, поток запроса классического ASP.NET), каждый блокирующий вариант из этого сравнения может привести к взаимной блокировке, и переключение между ними не помогает. GetAwaiter().GetResult() вызывает взаимную блокировку ровно в той же точке, что и .Result; лучшее поведение исключений — слабое утешение, когда приложение зависает. Механизм и каждое исправление в порядке предпочтения описаны в почему блокировка на асинхронном методе приводит к взаимной блокировке и как это исправить. Коротко: в потоке интерфейса или классического ASP.NET не блокируйте вовсе. В ASP.NET Core нет SynchronizationContext, поэтому этой конкретной взаимной блокировки вы не получите, но блокировка всё равно вызывает истощение thread pool под нагрузкой, которое сложнее диагностировать, потому что оно проявляется только при параллелизме.
ValueTask<T>. Если метод возвращает ValueTask<T> вместо Task<T>, ни один из блокирующих членов небезопасно использовать напрямую. ValueTask может опираться на IValueTaskSource, который можно переиспользовать после потребления значения, и его можно потребить только один раз. Вызов .Result или .GetAwaiter().GetResult() на ValueTask, который не завершился, — это неопределённое поведение, а двойной await — ошибка. Если вам передали ValueTask<T> и вы действительно не можете сделать await, сначала преобразуйте его в Task<T> с помощью .AsTask() и блокируйте на нём:
// .NET 11, C# 14 -- never block a ValueTask directly; materialize a Task first
ValueTask<int> vt = ReadValueAsync();
int value = vt.AsTask().GetAwaiter().GetResult(); // safe
// int bad = vt.Result; // undefined if not completed
Более чистое правило: делайте await для ValueTask ровно один раз и никогда не сохраняйте его. Блокировка на нём — запах дизайна поверх запаха дизайна. Полный набор ограничений см. в заметке когда ValueTask того стоит.
Как сделать блокировку ненужной
Чаще всего честное исправление — удалить блокирующий вызов, а не выбрать наименее вредный. Блокировка почти всегда существует потому, что кто-то перестал распространять async на уровне, который мог бы продолжить. Синхронное действие контроллера, вызывающее асинхронный репозиторий, обработчик событий void, которому «просто нужно значение прямо сейчас»: оба обычно можно сделать async Task (или async void для обработчика, единственного места, где это законно). Граница между корректным async void и ошибкой изложена в когда async void корректен, а когда это ловушка.
Когда вы делаете цепочку асинхронной сверху донизу, всё сравнение из этой статьи испаряется. Вы никогда не трогаете .Result, .Wait() или GetAwaiter().GetResult(), потому что у вас всегда есть доступный await. Это и есть настоящая рекомендация, скрытая за матрицей решений: лучший блокирующий вызов — тот, который вы отрефакторили и убрали.
Рекомендация, повторно
- По умолчанию используйте
await. Он не блокирует, масштабируется и выбрасывает исходное исключение. Если охватывающий метод может бытьasync, это ответ, точка. - Если вы действительно не можете быть асинхронными, блокируйте с помощью
GetAwaiter().GetResult(). Он блокирует, как и остальные, но выбрасывает реальное исключение вместоAggregateException, и работает и сTask, и сTask<T>. - Избегайте
.Resultи.Wait()кроме как на задаче, о которой вы уже знаете, что она завершилась. Они добавляют обёрткуAggregateExceptionбез пользы для одиночных операций. - Никогда не блокируйте в потоке интерфейса или классического ASP.NET и никогда не блокируйте
ValueTaskнапрямую. Первое приводит к взаимной блокировке; второе — неопределённое поведение. ПреобразуйтеValueTaskвTaskс помощью.AsTask(), если у вас нет альтернативы.
Относитесь к каждому блокирующему вызову как к TODO сделать вызывающий код асинхронным. Версия вашего кода, которая никогда не блокирует, быстрее, защищена от взаимных блокировок и бесплатно имеет более чистые исключения.
Связанное
- Fix: взаимная блокировка при вызове .Result или .Wait() на асинхронном методе в C#
- Когда async void корректен, а когда это ловушка в C#
- ConfigureAwait(false) против значения по умолчанию в .NET 11: имеет ли это ещё значение?
- Что такое ValueTask и когда он того стоит?
- Как ограничить время асинхронной операции с CancellationTokenSource.CancelAfter в C#
Источники
- A Tour of Task, Part 6: Results — Stephen Cleary
- Don’t Block on Async Code — Stephen Cleary
- TaskAwaiter.GetResult Method — Microsoft Learn
- Task exception handling in .NET — Microsoft Learn
- ValueTask Restrictions — Stephen Cleary
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.