いろいろ調べた結果をまとめているのですが、私自身、しっかりと分かっていない部分もあるので、間違っている箇所もあるかもしれません。お気づきの点はご指摘頂ければ幸いです。
前提の話
ASP.NETでは、HTTPリクエストを処理する為のスレッドを効率的に再利用する為、コントローラ・アクションメソッドを非同期にすることが推奨されます。
public async Task<string> getTheString()
{
string result = await asyncProc();
Console.WriteLine(result);
return result;
}
こうすることで、getTheStringの呼び出しに使われているHTTPリクエスト・スレッドはasyncProc()の実行が開始するや否やスレッドプールに返され、再利用されます。
asyncProc()の処理が終わると、別のスレッドがスレッドプールから取得され、続きが実行されるわけです。
しかし、Taskの結果を同期的に呼び出せる「Task.Result」や、Taskの終了を同期的に待てる「Task.Wait」などを使って、アクションメソッドからの非同期呼び出しを同期的に書く人もいます。
しかしこれはデッドロックの原因となります。
public String DownloadStringV3(String url)
{
// NOT SAFE, instant deadlock when called from UI thread
// deadlock when called from threadpool, works fine on console
var request = HttpClient.GetAsync(url).Result;
var download = request.Content.ReadAsStringAsync().Result;
return download;
}
この部分の仕組みは、旧ASP.NET(.NET Framework版)とASP.NET Coreでかなり異なっており、良く言われるのは、「ASP.NET Coreではデッドロックしなくなった」という話でした。しかしどうやらそう単純でもなさそうです。
それぞれ見ていきます。
旧ASP.NET(.NET Framework版)でのTask.Resultによるデッドロック
詳しくはMSDNで説明されています。
このデッドロックの根本的な原因は、await がコンテキストを処理する方法にあります。既定では、未完了の Task を待機するときは、現在の "コンテキスト" がキャプチャされ、Task が完了するときのメソッドの再開に使用されます。この "コンテキスト" は現在の SynchronizationContext で、Null の場合は現在の TaskScheduler になります。GUI アプリケーションと ASP.NET アプリケーションには、一度に実行するコードを 1 つのチャンクに限定する SynchronizationContext があります。await が完了するときは、キャプチャしたコンテキスト内で async メソッドの残りを実行しようとします。しかし、このコンテキストは既にその内部にスレッドを持っており、これは asyncメソッドが完了するのを (同期して) 待機します。それらは、それぞれもう一方を待機し、デッドロックを引き起こします。
ちょっと分かりにくいですが、これは恐らくこういう話です。
旧ASP.NETでは、コントローラ・アクションメソッドを実行するスレッドには、AspNetSynchronizationContext という内部クラスの SynchronizationContext が関連づけられています。これを用いて、非同期処理の前後で適切なHttpContext.Currentにアクセスできるようにしています。
とにかく、アクションメソッドを実行するスレッドを「スレッド1」とします。
HttpClient.GetAsyncの時点で、一旦「未完了のTask」が生成され「スレッド2」で実行が開始されます。この時、現在のSynchronizationContextがキャプチャされます。
スレッド1は「未完了のTask」を受け取って処理を続行しようとしますが、その後ろに.Resultが付いている為、スレッド1は、「未完了のTask」が完了するまでブロックされることになります。
一方、スレッド2は処理を完了します。しかし、呼び出し元の処理の続きに戻ろうとしたとき、キャプチャしておいたSynchronizationContextを見て「これは一度に実行するコードを1つのチャンクに限定しないとダメなやつだね。そしてSynchronizationContextは今、スレッド1を実行中だね。スレッド1が終わるまでは続きを処理しちゃダメね」と思って待機してしまいます。
でも、スレッド1は、ご存じの通り、Taskの完了を待っているので、スレッド1とスレッド2がお互いにお互いを待機している状態になり、デッドロックが発生します。
public async Task<String> DownloadStringV3(String url)
{
// SAFE
var request = await HttpClient.GetAsync(url);
var download = await request.Content.ReadAsStringAsync();
return download;
}
上記のように、呼び出し元をasyncにし、Task.Resultを使った同期呼び出しもなくせば、全てはうまくいきます。
スレッド2が処理を完了しようとした時、キャプチャされたSynchronizationContextには何のスレッドも関連づいていない(なぜなら、大元の呼び出し元スレッドは、awaitの時点でどっかに開放されちゃっているから)ので、スレッドプールから新しいスレッドを引っ張ってきて、awaitの続きが処理されるわけです。
ASP.NET Core での Task.Resultによるデッドロック
ASP.NET Coreではかなり状況が異なります。
まず、旧ASP.NET(.NET Framework版)で即デッドロックとなっていたコードは、ASP.NET Coreでは必ずしもデッドロックになりません。
理由は、ASP.NET CoreではSynchronizationContextが使われなくなった為です。
元々、旧ASP.NETでAspNetSynchronizationContextが必要だった理由は、スレッド間で同一の HttpContext.Currentを適切に参照する為でした。
しかし、ASP.NET Coreでは、HttpContextは IHttpContextAccessor を通してDIコンテナから取得するようになった為、HttpContext.Currentを使用する必要がなくなりました。(SynchronizationContext を使わないようにする為にHttpContextはDIから取得するようにした、つまり因果が逆な気もしますが)
SynchronizationContextがないので、非同期処理の呼び出しの続きは、呼び出し元スレッドの終了を待つことなく(つまり同期を取ろうとせず)続行されます。
先ほどのこの部分が、
一方、スレッド2は処理を完了します。しかし、呼び出し元の処理の続きに戻ろうとしたとき、キャプチャしておいたSynchronizationContextを見て「これは一度に実行するコードを1つのチャンクに限定しないとダメなやつだね。そしてSynchronizationContextは今、スレッド1を実行中だね。スレッド1が終わるまでは続きを処理しちゃダメね」と思って待機してしまいます。
「スレッド2の処理終わったのでTask完了にするね」で終わるということです。
Taskが完了になるのでTask.Resultでブロックされていた呼び出し元のスレッドが処理を再開します。
やりたかったのはまさにこれ。全ては問題なし! そもそも旧ASP.NETの挙動がおかしかったんだよ!
…とはいかないようです。
トラブルは、スレッド・プールが枯渇した時に起こります。
public String DownloadStringV3(String url)
{
// NOT SAFE, instant deadlock when called from UI thread
// deadlock when called from threadpool, works fine on console
var request = HttpClient.GetAsync(url).Result;
var download = request.Content.ReadAsStringAsync().Result;
return download;
}
この.GetAsync(url)で、新しいスレッドをスレッド・プールから取得しようとした時、もしスレッドプールが枯渇していると、GetAsyncの処理はスレッドプールの空き待ちキューに入れられます。
しかし、現状の全てのスレッドが同じ状況に陥っていたとしたら?
つまり、このアクションメソッドに対してリクエストが同時に100個来て、100個しかないスレッドプールを使い、次にそそれぞれがGetAsyncを呼び出そうとして「スレッドプールが枯渇している」となり、スレッドプールが空いて処理が行われるまでブロック状態となるとします。しかし、スレッドプールは100個のリクエストによって既に枯渇しており、全てがブロックされている為、永遠に空くことはありません。まさしくデッドロックです。
この現象は、通常の開発中には起こらず、負荷テストをしてようやく発生する為、かなり質が悪いです。
主な原因は、このアクションメソッドが「同期メソッド」だからです。
public async Task<String> DownloadStringV3(String url)
{
// SAFE
var request = await HttpClient.GetAsync(url);
var download = await request.Content.ReadAsStringAsync();
return download;
}
上記のように非同期処理としてアクションが記述されていれば、最初の100個のリクエストのあと、awaitの時点で元のリクエスト・スレッドは解放されます。スレッド・プールの枯渇は起こることなく、無事GetAsyncが新しいスレッドで実行されることになります。
アクションメソッドを同期処理で記述している為、その中の非同期処理の呼び出しの間、スレッドがブロックされてしまう「スレッドの無駄使い」が生じているのです。
「ASP.NET CoreになってSyncronizationContextがなくなったから、同期アクションでTask.Resultで書いてもデッドロックしないしこれでいいや~」とはならない理由がこれです。
内部で非同期メソッドを呼び出すようなアクションは、ちゃんと、非同期アクションにしましょう。
参考