【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を入れれば済む話なんですが、そうもいかないパターンもたまにあります。

私がハマったのは

  1. 別のフォームを表示するときに必須の項目をチェックする
  2. ある必須項目がNULLだった場合は非同期処理でその項目を取得しに行く
  3. 非同期処理中にステータスバーのラベルを書き換える
  4. その非同期処理の返り値がTask<bool>で、ResultがFalseなら失敗なので処理を終了する
  5. 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だとどうなるんでしょうね?