Start Debugging

C# の .Result vs .Wait() vs GetAwaiter().GetResult() vs await: どれを使うべきか

await はほぼ常に正しい答えです。本当にブロックしなければならないときは、GetAwaiter().GetResult() が元の例外をスローするため .Result や .Wait() より優れています。.NET 11 と C# 14 のための判断マトリクスです。

Task<T> があってそこから T を取り出したいとき、選択肢は 4 つあります。task.Resulttask.Wait()task.GetAwaiter().GetResult()、そして await task です。await を使ってください。スレッドをブロックしない唯一の選択肢であり、ラッパーではなくコードが実際にスローした例外をそのままスローします。他の 3 つはいずれも呼び出し元のスレッドをブロックし、デッドロックのリスクを伴います。その中では GetAwaiter().GetResult() が最もましです。await と同じように例外をアンラップするからです。これに頼るのは、async にできない同期メソッドの中で行き詰まったときだけにしてください。これは .NET 11 (Microsoft.NET.Sdk 11.0.0) と C# 14 で当てはまり、セマンティクスは .NET Framework 4.5 以降安定しています。

4 つを一覧で

挙動awaitGetAwaiter().GetResult().Result.Wait()
呼び出し元スレッドをブロックするいいえはいはいはい
値を返すはい (T)はい (T)はい (T)いいえ (void)
非ジェネリックの Task で動作するはいはいいいえ (Task<T> のみ)はい
スローされる例外元の例外元の例外AggregateExceptionAggregateException
デッドロックのリスク (キャプチャされたコンテキスト)いいえはいはいはい
負荷時の thread pool 枯渇いいえはいはいはい
ValueTask<T> で安全はい (1 回のみ)いいえ完了済みの場合のみ該当なし

この表を await の列で上から下まで読むと、きれいな 1 列になります。ブロックなし、実際の値、元の例外、デッドロックなし。他のどの列にも、望まない行に少なくとも 1 つの「はい」があります。それが議論のすべてです。この記事の残りは、各行がなぜ真なのか、そしてそのトレードオフが実際にいつあなたの手を縛るのかを説明します。

なぜ await がデフォルトで勝つのか

await.Result を呼ぶより洗練された方法、というわけではありません。別の操作です。まだ完了していないタスクを await すると、メソッドは中断し、制御を呼び出し元に返します。どのスレッドも座って待つことはありません。ランタイムはメソッドの残りを継続 (continuation) としてスケジュールし、タスクが完了したときに実行します。ブロッキングメンバーはその逆をします。現在のスレッドを停め、タスクが完了するまで保持します。

その 1 つの違いが、await がスケールしブロックがスケールしない理由です。サーバーでは、ブロックされたスレッドは待つ以外に何もしない thread pool のスレッドであり、負荷時には枯渇します。UI スレッドでは、ブロックされたスレッドは固まったウィンドウです。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 は、あなたが実際にスローした例外も返します。GetStringAsyncHttpRequestException をスローすると、await はその HttpRequestException を、元のスタックトレースとともに、await したまさにその場所で再スローします。アンラップも catch (AggregateException) の体操も不要です。ブロックする具体的な理由がない限り、判断はここで終わりです。

GetAwaiter().GetResult() が正しいブロッキング呼び出しになるとき

非同期にできないこともあります。クラスのコンストラクターは async にできません。C# 7.1 より前の MainDispose (DisposeAsync ではない)、シグネチャを制御できないインターフェイスメソッド、同期デリゲートを渡してくるサードパーティプラグインのエントリーポイント。これらは本当に同期的な継ぎ目です。その中から非同期コードを呼ぶ必要があり、再構成もできないなら、何かをブロックするしかありません。GetAwaiter().GetResult() でブロックしてください。

これが .Result.Wait() に勝る理由は、例外の忠実さです。Task.ResultTask.Wait()async/await より前からあり、.NET 4.0 の Task Parallel Library に由来します。そこでは 1 つの Task (Task.WhenAll を思い浮かべてください) が一度に複数の例外で失敗することがありました。それを表現するため、内部例外がちょうど 1 つのときでも、失敗した内容を 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() はそのすべてを避けます。だからこそ、Stephen Cleary の「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 ラッパーを付け加えます。タスクが 1 つの論理的な操作であるとき、ラッパーが役に立つシナリオはありません。.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 を明示的に処理する、撃ちっぱなし (fire-and-forget) の Task を待つ場合です。実際にはこれは稀で、たいていはその作業を独立したバックグラウンドジョブとして構成すべきだった兆候です。リクエストスレッドの外で作業を実行しているなら、ゆるいタスクをブロックするのではなく、BackgroundService で撃ちっぱなしの作業を安全に実行するのパターンで行ってください。

.Wait(timeout).Wait(cancellationToken) には本当の罠があります。これらは待機を早めに諦めさせ、回復力があるように見えますが、そうではありません。false を返す Wait(5000) は基盤の操作をキャンセルしていません。タスクはまだ実行中で、その継続はまだキューにあり、あなたは単に待つのをやめただけです。ハングをマジックナンバーで覆い隠したにすぎません。操作に上限を設ける必要があるなら、CancellationTokenSource.CancelAfter で非同期操作をタイムアウトさせるで説明しているように、正しくキャンセルしてください。

あなたの代わりに決めてしまう要因: デッドロックと ValueTask

2 つの要因が、選択の余地を完全に奪うことがあります。

キャプチャされた SynchronizationContext ブロックするスレッドがシングルスレッドのコンテキスト (WPF や WinForms の UI スレッド、クラシック ASP.NET のリクエストスレッド) を所有している場合、この比較のどのブロッキング選択肢もデッドロックし得ますし、それらを切り替えても助けになりません。GetAwaiter().GetResult().Result とまったく同じ地点でデッドロックします。アプリケーションがハングしているとき、より良い例外の挙動は乏しい慰めです。そのメカニズムと、優先順位順のすべての修正は、非同期メソッドでブロックするとなぜデッドロックするのか、どう直すのかにあります。短く言えば、UI スレッドやクラシック ASP.NET スレッドでは、そもそもブロックしないことです。ASP.NET Core には SynchronizationContext がないため、この特定のデッドロックは起きませんが、ブロックは依然として負荷時に thread pool 枯渇を引き起こし、並行性でしか現れないため診断が難しくなります。

ValueTask<T> メソッドが Task<T> ではなく ValueTask<T> を返す場合、どのブロッキングメンバーも直接使うのは安全ではありません。ValueTask は、値が消費された後に再利用され得る IValueTaskSource に裏打ちされている場合があり、消費できるのは 1 回だけです。完了していない ValueTask に対して .Result.GetAwaiter().GetResult() を呼ぶのは未定義動作であり、2 回 await するのはバグです。ValueTask<T> を渡されて本当に await できないなら、まず .AsTask()Task<T> に変換し、それをブロックしてください。

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

より明快なルールはこうです。ValueTask はちょうど 1 回だけ await し、決して保存しないこと。それをブロックするのは、設計のにおいの上にさらに設計のにおいを重ねることです。制約の全体像については、ValueTask に価値があるのはいつかの注記を参照してください。

ブロックを不要にする

たいていの場合、正直な修正は最も害の少ないものを選ぶことではなく、ブロッキング呼び出しを削除することです。ブロックがほぼ常に存在するのは、続けられたはずの層で誰かが async の伝播をやめたからです。非同期リポジトリを呼ぶ同期コントローラーアクション、「今すぐ値が要るだけ」の void イベントハンドラー。どちらも通常 async Task (ハンドラーなら、それが正当な唯一の場所である async void) にできます。正しい async void とバグの境界は、async void が正しいときと罠になるときで示しています。

チェーンを上から下まで非同期にすると、この記事の比較全体が蒸発します。常に await が使えるので、.Result.Wait()GetAwaiter().GetResult() に触れることはありません。それが判断マトリクスの裏に隠れた本当の推奨です。最良のブロッキング呼び出しは、リファクタリングで消し去ったものです。

推奨、あらためて

すべてのブロッキング呼び出しを、呼び出し元を非同期にするための TODO として扱ってください。決してブロックしないバージョンのコードは、より速く、デッドロックに強く、そしておまけにより整った例外を持ちます。

関連記事

出典

Comments

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

< 戻る