[C#][WPF][非同期] async/awaitを使ってフリーズしないGUIづくり




自分が勤めてる会社で開発したC#のアプリケーションがありまして、
しょっちゅうアプリケーションがフリーズするという困りごとがありました。

フリーズしないためにどうすれば良いんだろう?という疑問点をバネに、
色々調べて分かった事をメモ書き程度に記事の投稿を行います。

現象

とあるデータを管理・編集するアプリがあります。
そのデータに対して編集コマンドを実行すると、約10秒後にデータ編集画面が表示されます。
その10秒間は特に何もされずに、他のコントロールも反応しなくなります。(= フリーズ状態)
かろうじてマウスカーソルは砂時計に変わる。
けど、プログレスが表示されない。なんてこったい……。

フリーズする原因

単純です。
UIスレッドで重たい処理が実行されているからです。
そのような状態ですと、外からの操作を受け付けられない状態になっているためです。

よくある話なのかな?と思います。
自分もそうなのですが、プログラマ1年生だと何も考えずにガリガリ作ってみて、
一応それっぽいアプリケーションが完成する。

最初は問題ないのですが、扱うデータが増えていくにつれて
処理速度が遅くなってしまい今回のような問題が発生することがありました。

じゃあどうすれば良いのか?の結論を次に記します。

解決策(async/awaitを使う)

フリーズする問題としては、UIスレッドを重たい処理で占有してしまうためなので、
重たい処理は別スレッドにて行う方法が解決策なのかな?と考えます。

「C# 非同期処理」なんかでググると、ThreadクラスやTaskクラスを使った非同期処理の紹介がされていますが、C#5.0で追加された「async/await」を使った非同期処理が、
使いやすい・現在の主流であると認識しました。

実際に調べて、実験用にasync/awaitを使った超簡単なGUIアプリケーションを作ってみました。

ソースコードは以下のような感じです。

MainWindow.xaml.cs
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        /// <summary>
        /// 「重たい処理(同期)」ボタンのクリックイベント
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void button_Click( object sender, RoutedEventArgs e )
        {
            this.HeavyProc();
        }

        /// <summary>
        /// 「重たい処理(非同期)」ボタンのクリックイベント
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private async void button1_Click( object sender, RoutedEventArgs e )
        {
            await HeavyProcAsync();
        }

        /// <summary>
        /// 重い処理
        /// </summary>
        private void HeavyProc()
        {
            // 5秒間スリープするだけ。
            System.Threading.Thread.Sleep( 5000 );
        }

        /// <summary>
        /// 重い処理(async)
        /// </summary>
        /// <returns></returns>
        async private Task HeavyProcAsync()
        {
            // 1.Task.Delayはasyncで定義されている。なので、await識別子を指定する事が可能。
            // awaitすることで、一旦UIスレッドに処理を返す事が出来る。
            //  = > 重たい処理が実行中でも、UI操作が可能。フリーズしない。
            // 処理が終了すると、処理スレッドに戻る。
            await Task.Delay( 5000 );

            // 2.Thread.Sleep処理はasyncで定義されていないので、await識別子を指定することが出来ない。
            //  = > 「HeavyProcAsync」メソッドにasyncの定義があるが、UI操作が不可能。
            // System.Threading.Thread.Sleep( 5000 );

            // 3. 2の処理に対して、非同期処理を行いたい場合は、
            // Task処理として動作させれば良い。
            //await Task.Factory.StartNew( () =>
            //{
            //    System.Threading.Thread.Sleep( 5000 );
            //}
            //);
        }
    }

「重たい処理(同期)」ボタンをクリックすると、
UIスレッドにて5秒間スリープという処理が行われます。
なので、ボタンをクリックしてから5秒間は、UI操作が行えなくなります。

「重たい処理(非同期)」ボタンをクリックすると、
5秒間スリープ(というよりも、5秒後にTaskを返す)処理が行われます。
この5秒間ですが、UI操作が可能です。
(別のボタンが押せたり、ウィンドウサイズの変更が行えたり)

関数の定義に"async"をつけると、その関数内でawaitを指定する事が可能になります。

async private Task HeavyProcAsync()


下記のように"await"を指定すると、その指定された関数を実行中は別スレッドに制御を移すことになります。
そうなると、UIスレッドが空きの状態になるので、外部からの操作が可能となるわけです。

// 1.Task.Delayはasyncで定義されている。なので、await識別子を指定する事が可能。
// awaitすることで、一旦UIスレッドに処理を返す事が出来る。
//  = > 重たい処理が実行中でも、UI操作が可能。フリーズしない。
// 処理が終了すると、処理スレッドに戻る。
await Task.Delay( 5000 );



1の部分をコメントアウトして、2のコメントを解除しました。

// 1.Task.Delayはasyncで定義されている。なので、await識別子を指定する事が可能。
// awaitすることで、一旦UIスレッドに処理を返す事が出来る。
//  = > 重たい処理が実行中でも、UI操作が可能。フリーズしない。
// 処理が終了すると、処理スレッドに戻る。
//await Task.Delay( 5000 );

// 2.Thread.Sleep処理はasyncで定義されていないので、await識別子を指定することが出来ない。
//  = > 「HeavyProcAsync」メソッドにasyncの定義があるが、UI操作が不可能。
System.Threading.Thread.Sleep( 5000 );

// 3. 2の処理に対して、非同期処理を行いたい場合は、
// Task処理として動作させれば良い。
//await Task.Factory.StartNew( () =>
//{
//    System.Threading.Thread.Sleep( 5000 );
//}
//);

2の処理はawaitを定義していないので、UIスレッドにてスリープ処理が実施されます。
ですのでフリーズ状態になります。

下記のように実行中の関数にasyncで定義してもフリーズされます。awaitしないと別スレッドで処理が走らないです。

/// <summary>
/// 重い処理(async)
/// </summary>
/// <returns></returns>
async private Task HeavyProcAsync()

awaitを定義するためには、その呼び出される関数の定義にasync指定する必要があります。
System.Threading.Thread.Sleep(int)のように、async定義されていない、重たい処理を別スレッドで実施したい場合は、以下の3のようにTask処理として動作させればOKです。

// 1.Task.Delayはasyncで定義されている。なので、await識別子を指定する事が可能。
// awaitすることで、一旦UIスレッドに処理を返す事が出来る。
//  = > 重たい処理が実行中でも、UI操作が可能。フリーズしない。
// 処理が終了すると、処理スレッドに戻る。
//await Task.Delay( 5000 );

// 2.Thread.Sleep処理はasyncで定義されていないので、await識別子を指定することが出来ない。
//  = > 「HeavyProcAsync」メソッドにasyncの定義があるが、UI操作が不可能。
// System.Threading.Thread.Sleep( 5000 );

// 3. 2の処理に対して、非同期処理を行いたい場合は、
// Task処理として動作させれば良い。
await Task.Factory.StartNew( () =>
{
    System.Threading.Thread.Sleep( 5000 );
});

とりあえずここまで。
自分が現状理解しているポイントでした。

このサンプルは超簡単なコードなので問題無いですが、
もっと複雑なコードになると、排他処理を行わないといけないケースが発生すると思うので、
なかなか簡単には実装できなさそう……