【C#】TaskとUIスレッド問題
私は諸事情により.NET4.5を使うことが出来ず、.NET4.0しか使えないので、この事象が.NET4.5でも再現するかどうかはわかりません。念のため。
事象
例えばこんなコードがあったとします。Form上にあるボタンを押すと非同期処理が走り、Labelが書き換えられます。
private void button1_Click(object sender, EventArgs e) { Task.Factory.StartNew(() => { //重い処理 Thread.Sleep(5000); }).ContinueWith((t) => { if (!t.IsFaulted) { label1.Text = "Success"; } }, TaskScheduler.FromCurrentSynchronizationContext()); }
先ほどの記事の通りです。FromCurrentSynchronizationContextのおかげでInvokeなしでもUIを変更できます。
じゃあこんなパターンだとどうなるでしょう。
private void button1_Click(object sender, EventArgs e) { var t1 = Task.Factory.StartNew(() => { //重い処理 Thread.Sleep(5000); }).ContinueWith<bool>((t2) => { if (!t2.IsFaulted) { label1.Text = "Success"; } return true; }, TaskScheduler.FromCurrentSynchronizationContext()); //ContinueWithのResultがTrueならMessageBoxを表示する if (t1.Result) { MessageBox.Show("Success"); } }
実行してみると、いつまでたってもMessageBoxが表示されません。っていうか、フリーズします。
じゃあ仕方ない。Invokeにしてみましょう。
private void button1_Click(object sender, EventArgs e) { var t1 = Task.Factory.StartNew(() => { //重い処理 Thread.Sleep(5000); }).ContinueWith<bool>((t2) => { if (!t2.IsFaulted) { Invoke((MethodInvoker)(() => label1.Text = "Success")); } return true; }); //ContinueWithのResultがTrueならMessageBoxを実行する if (t1.Result) { MessageBox.Show("Success"); } }
やっぱりフリーズします。
原因(ほとんど推測)
いくつかブレークポイントを置いてデバッグしてみると、FromCurrentSynchronizationContextを指定した場合だとStartNewで作成したタスクが完了してもContinueWithが一向に実行されません。
Invokeを使用した場合は、ラベルの書き換え時に固まってしまいます。
確証はないんですが、ContinueWithをUIスレッド(メインループ)で動かそうとするんだけど、t1.Resultを受け取るために待機しているのもまたUIスレッドなせいで、デッドロックが発生しているのだと思われます。
FromCurrentSynchronizationContextはUIスレッドを取得しに行こうとする。でも待機してるから無理。無理だから待つ。一方待機している方はContinueWithが完了しないと解放できない。典型的なやつですね。
解決策
要は「タスク内でUIの処理を行う場合はUIスレッドでタスクの完了を待たないようにする」としか。
まぁこのぐらい単純な例だとContinueWithにMessageBoxを入れれば済む話なんですが、そうもいかないパターンもたまにあります。
私がハマったのは
- 別のフォームを表示するときに必須の項目をチェックする
- ある必須項目がNULLだった場合は非同期処理でその項目を取得しに行く
- 非同期処理中にステータスバーのラベルを書き換える
- その非同期処理の返り値がTask<bool>で、ResultがFalseなら失敗なので処理を終了する
- ResultがTrueなら取得できてるので別フォームを表示する
みたいな流れです。処理を簡略化してコードにするとこんな感じですかね。
private string _hoge; private void button1_Click(object sender, EventArgs e) { if (string.IsNullOrEmpty(_hoge)) { if (!GetHoge().Result) { return; } } label1.Text = _hoge; } private Task<bool> GetHoge() { return Task<string>.Factory.StartNew(() => { //重い処理 Thread.Sleep(5000); return "hoge"; }).ContinueWith<bool>((t) => { _hoge = t.Result; label2.Text = "Success:GetHoge"; return true; }, TaskScheduler.FromCurrentSynchronizationContext()); }
まぁ設計が悪いって言われたらそれまでなんですが。元々同期処理だったのを無理矢理非同期にした弊害ですね。
言い訳はともかく対処法。
上記の例であれば、「ContinueWith内でlabel2を書き換える」と「_hogeがあったらlabel1を書き換える」という二つがUIスレッドで行いたい内容です。
逆に言えば、それ以外は全部別スレッドでもいいわけです。
private void button1_Click(object sender, EventArgs e) { Task.Factory.StartNew(() => { if (string.IsNullOrEmpty(_hoge)) { if (!GetHoge().Result) { return; } } Invoke((MethodInvoker)(() => label1.Text = _hoge)); }); } private Task<bool> GetHoge() { return Task<string>.Factory.StartNew(() => { //重い処理 Thread.Sleep(5000); return "hoge"; }, TaskCreationOptions.AttachedToParent) .ContinueWith<bool>((t) => { _hoge = t.Result; label2.Text = "Success:GetHoge"; return true; }, TaskScheduler.FromCurrentSynchronizationContext()); }
このように新しいTaskを作って包んでしまえばOK…と言いたいところなんですが、これはTaskScheduler.FromCurrentSynchronizationContextメソッドでInvalidOperationExceptionが出ます。
現在の SynchronizationContext を TaskScheduler として使用することはできません。
別スレッドからTaskScheduler.FromCurrentSynchronizationContextを呼んではいけないみたいです。なので、ContinueWith内でラベルを書き換える処理をInvokeで行うか、UIスレッドでの処理中にFromCurrentSynchronizationContextを取得し、引数で渡してやればOKです。
private void button1_Click(object sender, EventArgs e) { var ts = TaskScheduler.FromCurrentSynchronizationContext(); Task.Factory.StartNew(() => { if (string.IsNullOrEmpty(_hoge)) { if (!GetHoge(ts).Result) { return; } } Invoke((MethodInvoker)(() => label1.Text = _hoge)); }); } private Task<bool> GetHoge(TaskScheduler ts) { return Task<string>.Factory.StartNew(() => { //重い処理 Thread.Sleep(5000); return "hoge"; }, TaskCreationOptions.AttachedToParent) .ContinueWith<bool>((t) => { _hoge = t.Result; label2.Text = "Success:GetHoge"; return true; }, ts); }
これでデッドロックを回避することが出来ました。
まとめ
これ、WPFだとどうなるんでしょうね?