先の記事「非同期プログラミング」の続きで、デッドロックに関して書きます。
Windows Forms や ASP.NET Web アプリで async / await を使った非同期プログラムの中に Task.Wait のような同期メソッドを混ぜるとデッドロックになります。(注: コンソールアプリではデッドロックになることはありません。理由後述)
Windows Forms アプリのコードでそのあたりを分かりやすく書くと以下のような感じです。コメントに「// デッドロック」と書いた方のメソッドを実行するとデッドロックが発生してアプリは固まってしまいます。
using System;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace WindowsFormsAsyncTest
{
public partial class Form3 : Form
{
public Form3()
{
InitializeComponent();
}
// デッドロック
private void button1_Click(object sender, EventArgs e)
{
label1.Text = "";
// Result プロパティは Task.Wait メソッドを呼び出す
string str = TimeCosumingMethod().Result;
label1.Text = str;
}
// 正常動作
private async void button2_Click(object sender, EventArgs e)
{
label1.Text = "";
string str = await TimeCosumingMethod();
label1.Text = str;
}
private async Task<string> TimeCosumingMethod()
{
await Task.Delay(3000);
return "TimeCosumingMethod の戻り値";
}
}
}
ちなみに ASP.NET Web アプリでも、Task.Result を使った同様なコードで、デッドロックが発生してアプリは固まってしまいます。
その理由は Microsoft のドキュメント「非同期プログラミングのベストプラクティス」の「すべて非同期にする」のセクションに書いてあります。
・・・が、自分はその説明を読んでもよく分かりませんでした。自分の独断と偏見による個人的解釈まじりですが、以下のようなことになっているのであろうと想像しています。
Microsoft のドキュメント「Task.Result プロパティ」と「Task.Wait メソッド」に以下の説明があります。
"Task.Result プロパティの get アクセサーにアクセスすると、非同期操作が完了するまで呼び出し元のスレッドがブロックされます。これは、Wait メソッドを呼び出すことと同じです。"
"Wait は、現在のタスクが完了するまで呼び出し元のスレッドを待機させる同期メソッドです。"
また、上に紹介した「非同期プログラミングのベストプラクティス」に以下の説明があります。
"既定では、未完了の Task を待機するときは、現在のコンテキストがキャプチャされ、Task が完了するときのメソッドの再開に使用されます。 ・・・中略・・・ GUI アプリケーションと ASP.NET アプリケーションには、一度に実行するコードを 1 つのチャンクに限定する SynchronizationContext が使われます。await が完了するときは、キャプチャしたコンテキスト内で async メソッドの残りを実行しようとします。しかし、このコンテキストは既にその内部にスレッドを持っており、これは asyncメソッドが完了するのを (同期して) 待機します。それらは、それぞれもう一方を待機し、デッドロックを引き起こします。"
ということで、以下のようになっているのだろうと思いました。
-
まず、上のコードの「デッドロック」とコメントしてある button1_Click メソッドに注目。その中の Task.Result プロパティを使っているところで、呼び出し元のスレッド(UI スレッド)は TimeCosumingMethod メソッドが完了するのを同期的に待機する。上に書いた公式ドキュメントには Task.Result で「非同期操作が完了するまで呼び出し元のスレッドがブロックされます」とありますが、実際にコードを書いて検証してみると、呼び出し先の TimeCosumingMethod メソッドの await の前まで呼び出し元のスレッド(上の例では UI スレッド)で実行され、await の行に制御が移るとそこでデッドロックになります。
-
呼び出された TimeCosumingMethod メソッドに制御が移り、その中の
await Task.Delay(3000);
の await で待機するとき現在のコンテキストがキャプチャされ、await が完了した後はキャプチャしたコンテキストで残りを実行しようとする。 -
Windows Forms アプリでは、先の記事の「(2) Windows Forms アプリの ManagedThreadId」の例のとおり、スレッドの ManagedThreadId の値は変わらない。即ち、await で待機するときキャプチャする「現在のコンテキスト」および await 完了後に使われるキャプチャしたコンテキストでは同じ UI スレッドが使われ続けている。
-
ということは、TimeCosumingMethod メソッドで await 完了後に使われる「キャプチャした現在のコンテキスト」のスレッドは、上の 1 で待機しているスレッドと同じということになる。
-
上の 1 で待機しているスレッドは TimeCosumingMethod メソッドが完了しないと解放されないが、TimeCosumingMethod メソッド内の await が完了した時点では解放されていないので、その場所で開放されるのを永遠に待つことになってデッドロックに陥る。
Windows Forms のような GUI アプリの場合は、実際にコードを書いての検証結果から、上に書いた通り UI スレッドが使われるのを確認しており、上記 1 ~ 5 で説明になっていると思います。
が、ASP.NET のケースが上記 1 ~ 5 では説明できません。ASP.NET で非同期にする目的はスレッドプールのスレッドの有効利用で、await 前までに使っていたスレッドはスレッドプールに戻し、await 完了後の処理はスレッドプールから新たにスレッドを取得して行いますので。(ASP.NET の場合 await 前後でスレッドが異なるということです)
Windows Forms アプリと異なり、ASP.NET アプリでは使うスレッドがどうなっているかより「一度に実行するコードを 1 つのチャンクに限定する」の方が関係しているのかもしれません。つまり、以下のようなことではないかと思っています。
まず、button1_Click メソッドの Task.Result で 1 つの同期ブロックが待機中。 呼び出された TimeCosumingMethod メソッドの await で待機する際にキャプチャされた「現在のコンテキスト」で await 完了後の同期処理を実行しようとする。しかし「現在のコンテキスト」では一度に実行するコードは 1 つのチャンクに限定されている。なので、Task.Result での待機が終わるまで await 完了後の同期処理は実行できない。結果デッドロックに陥る。
結局は、Windows Forms アプリでデッドロックする理由も上記のようなことなのかもしれません。
一方、コンソールアプリでは SynchronizationContext.Current
は null になります。ということは await で待機する際にキャプチャできる「現在のコンテキスト」は存在せず、await 完了後の残りの処理は ASP.NET や Windows Forms アプリとは異なるようです。「非同期プログラミングのベストプラクティス」に以下のように書いてあります。
"コンソールアプリケーションでは、一度に 1 つのチャンクに制限する SynchronizationContext ではなく、スレッドプールを備えた SynchronizationContext を使用するため、await が完了するとき、スレッド プールのスレッドで async メソッドの残り処理のスケジュールが設定されます。"
上の 2 のところで TimeCosumingMethod メソッドの残りを実行するのは「スレッドプールを備えた SynchronizationContext」になるようです。await で待機するときにキャプチャした「現在のコンテキスト」ではないのでデッドロックにならないということのようです。
最後に、上のサンプルコードで「// 正常動作」とコメントしたように Task.Result を使わないで 2 ヶ所で await した場合はデッドロックにはならないのは何故でしょう?
参考にさせていただ記事「ASP.NET の非同期でありがちな Deadlock を克服する」に以下のように書いてありました。
"AspNetSynchronizationContext に Post された 2 つの同期ブロックを、例の医者と患者の方式で順番に処理してくれるから"
・・・だそうです。ただ、正直言ってその説明ではよく分かりませんでした。はっきりした理由が分かったら追記します。
ConfigureAwait メソッドを使うとデッドロックの回避することができます。その話を書くと記事が長くなりすぎるので別の記事に書きます。