Start Debugging

.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.

Все четыре кратко

ПоведениеawaitGetAwaiter().GetResult().Result.Wait()
Блокирует вызывающий потокнетдадада
Возвращает значениеда (T)да (T)да (T)нет (void)
Работает с негенерическим Taskдаданет (только Task<T>)да
Выбрасываемое исключениеисходноеисходноеAggregateExceptionAggregateException
Риск взаимной блокировки (захваченный контекст)нетдадада
Истощение 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. Это и есть настоящая рекомендация, скрытая за матрицей решений: лучший блокирующий вызов — тот, который вы отрефакторили и убрали.

Рекомендация, повторно

Относитесь к каждому блокирующему вызову как к TODO сделать вызывающий код асинхронным. Версия вашего кода, которая никогда не блокирует, быстрее, защищена от взаимных блокировок и бесплатно имеет более чистые исключения.

Связанное

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад