業務のためのC#・C言語・C++学習

主にC#の文法やWPF周りのアウトプットに利用してます。

【C#-WPF】WindowsフォームやWPFといったGUIにおけるTaskを用いた非同期処理の問題点とその解決方法

本記事ではC#における非同期処理、WindowsフォームやWPFといったGUIでTaskを用いた非同期処理、それに直面する問題と解決方法について説明する

非同期処理とは

スレッドとは、プログラムを実行する最小単位の処理のことである。
複数のプログラムを並列処理する場合に意識する技術になる。よくあるのは、GUIアプリでフリーズに見えないように重い処理は別スレッドで処理しアプリ利用者はメインスレッドでUIの操作を行えるようにするがある。

C#では複数のスレッドで並列処理する仕組みをマルチスレッドと呼ぶ。WPFなどのGUIでは、メインスレッド=UIスレッドとワーカスレッドに分類される。メインスレッドでUIコントロールの操作を行い、ワーカスレッドではメインスレッドでの負担を軽くするため重い処理を行う。

マルチスレッドを利用するとき処理の仕方は 「非同期処理」、「同期処理と非同期処理の併用」の2つがある。

非同期処理は並列処理で利用される。処理の終了に責任を持たない仕組みのことで、メインスレッドとワーカースレッドの処理はお互い無関係に動作する。

非同期処理

同期処理は逐次的に処理を一つ一つ実行していくことを単一スレッド内で行う。したがってそれぞれ処理の終了を待機する仕組みと言っても良い。

同期処理

ワーカスレッドに依頼した処理の結果を利用したい場合などに「同期処理と非同期処理の併用」をする。 よく非同期処理とマルチスレッドと並列処理の違いは何ですかとあるが、非同期処理はマルチスレッドで並列処理をするが、処理の終了は認知しないこと。マルチスレッドは並列処理をすることが前提だが、非同期処理だけをする場合や非同期処理と同期処理の併用をする場合がある。並列処理はマルチスレッドを使うこと同義である。

非同期処理と書かれていたら並列処理するんだな、マルチスレッドを使うんだなと考えればよい。

しかし、マルチスレッドと書かれていても非同期処理だけで完結するかは分からない。ほとんどの場合は同期処理を導入しないといけないが、マルチスレッドから見たら非同期なのか同期処理するのかは開発者による。

非同期処理の実現

昔は非同期処理を実現するのに以下の方法を用いていた
・Thread
・ThreadPool
Delegate(InvokeやBegingInvoke)
・Timer

https://atmarkit.itmedia.co.jp/ait/articles/0504/20/news111.html

現在はタスクTaskを用いるのが一般的である。

また、先ほどの図では省略したが、Delegate(InvokeやBegingInvoke)、タスク、await/asyncではスレッドプールの機能を内部的に利用している。スレッドプールとはオーバヘッド削減のため一度作成したスレッドを保管し再利用する仕組みである。

Task

タスクを用いた非同期処理について説明する。

Task task = new Task(デリゲート);
 task.Start();

Start()で、メインスレッドとは別にワーカースレッドでデリゲートが実行される。
またデリゲートの代わりにラムダ式を使う場合が多い。

Task task = new Task(() =>
{
//処理内容を記述
});
task.Start();

Start()の代わりにRun()を使う方法もある

Task task = Task.Run(デリゲート);

ラムダ式を使うと

Task task = Task.Run(()=>
{

});

Start()を使った場合どんな処理順番になるのか。

例1

            Task task = new Task(() =>
            {
                Console.WriteLine("処理A");
                System.Threading.Thread.Sleep(5000);
                Console.WriteLine("処理B");
            });
            task.Start();
            Console.WriteLine("処理C");
            Console.WriteLine("処理D");

処理の結果は処理C→D→A→Bとなる。

例2

            Task task = new Task(() =>
            {
                Console.WriteLine("処理A");
                System.Threading.Thread.Sleep(5000);
                Console.WriteLine("処理B");
            });
            task.Start();
            Console.WriteLine("処理C");
            System.Threading.Thread.Sleep(5000);
            Console.WriteLine("処理D");

処理の結果は処理C→A→D→Bとなる。

例3

            Task task = new Task(() =>
            {
                Console.WriteLine("処理A");
                System.Threading.Thread.Sleep(5000);
                Console.WriteLine("処理B");
            });
            task.Start();
            Console.WriteLine("処理C");
            System.Threading.Thread.Sleep(6000);
            Console.WriteLine("処理D");

処理の結果は処理C→A→B→Dとなる。

例4

            Task task = new Task(() =>
            {
                Console.WriteLine("処理A");
                System.Threading.Thread.Sleep(5000);
                Console.WriteLine("処理B");
            });
            Console.WriteLine("処理C");
            task.Start();
            Console.WriteLine("処理D");

処理の結果は処理C→D→A→Bとなる。

以上のことから非同期処理では、処理時間が分からないと開発者がその順番を簡単に制御できてない。処理時間は普通は分からないで処理を待って処理結果を利用したい場合には困ることが分かる

処理順番の制御をするには?

1つはwaitを使う。

            Task task = new Task(() =>
            {
                Console.WriteLine("処理A");
                System.Threading.Thread.Sleep(5000);
                Console.WriteLine("処理B");
            });
            task.Start();
            Console.WriteLine("処理C");
            task.Wait();
            Console.WriteLine("処理D");

処理の結果は処理C→A→B→Dとなる。処理Dはタスク完了後に実施される。

タスクの戻り値を非同期で利用するには

継続タスクContinueWithを利用する。

        static void Main(string[] args)
        {
            Task<string> taskA = new Task<string>(()=>
             {
                 //処理A
                 var resultA = "処理A";
                 Console.WriteLine(resultA);
                 return resultA;
             });
            //継続タスク
            Task<string> taskB = taskA.ContinueWith<string>(t =>
            {
                //処理B
                var resultB = "処理B:" + t.Result;
                Console.WriteLine(resultB);
                return resultB;
            });
            Task taskC = taskB.ContinueWith(t =>
            {
                //処理C
                Console.WriteLine("処理C:" + t.Result);
            });
            taskA.Start();
            taskC.Wait();
            Console.WriteLine("完了");
        }

出力結果
処理A
処理B:処理A
処理C:処理B:処理A
完了

GUIにおけるTaskによる非同期処理の問題点

今まで述べた通り非同期処理でタスクを使うと処理結果の利用が簡単には出来ない、waitやcountinuewithを使えなくもないが昨今はデットロックの問題もあり使用は推奨されない

GUIアプリケーションでTaskを使う問題点が2つある。 1つは、処理完了後にUI変更をしたいが非同期処理をしているので処理結果が利用できないのである。

        private void button1_Click(object sender, EventArgs e)
        {
            textBox1.Text = "処理中";

            Task.Run(() =>
            {
                System.Threading.Thread.Sleep(5000);
                Console.WriteLine("処理完了");
            });

            textBox1.Text = "処理完了";
        }

本来は処理が終了した後に処理完了の表示をしたいが、処理が完了する前に表示されてしまっている。

もう1つの問題点は、処理結果をUIに反映させようとし、ワーカースレッドからUIにアクセスするがエラーが出力されてしまうことだ。

        private void button1_Click(object sender, EventArgs e)
        {
            textBox1.Text = "処理中";

            Task.Run(() =>
            {
                System.Threading.Thread.Sleep(5000);
                textBox1.Text = "処理完了";
            });
            
        }

以下の通りエラーが出力される。

なぜエラーが出力されるのか?
それはWindowsフォームやWPFにおけるコントロール(UI)は、コントロールが作成されたメインスレッド=UIスレッドからのみ呼び出すことが許されており、ワーカースレッドでコントロールを呼び出すとエラーが起きるからである。
マイクロソフトさんのサイトに以下のように言及されてる。
Most methods on a control can only be called from the thread where the control was created.」
learn.microsoft.com

以上、WindowsフォームやWPFといったGUIにおけるTaskによる非同期処理の問題点をあげた。次にこれら問題点を解決するawait/asyncとInvokeを説明する。

解決方法

await/async

継続タスクを簡潔に記述する機能がawait/asyncである。
await以降の処理をawait内の処理が全て完了したら実行する順になる。
asyncはawaitを利用するために決まり文句である。

        private async void button2_Click(object sender, EventArgs e)
        {
            textBox1.Text = "処理中";

            await Task.Run(() =>
            {
                System.Threading.Thread.Sleep(5000);            
            });

            textBox1.Text = "処理完了";
        }

Invokeを使ったUIコントロールの操作

ワーカースレッドからUI操作をするにはInvokeを用いる。

windowsフォームの場合
          private void button3_Click(object sender, EventArgs e)
        {
            textBox1.Text = "処理中";

            Task.Run(() =>
            {
                System.Threading.Thread.Sleep(5000);
                this.Invoke(new Action(() =>
                {
                    textBox1.Text = "処理完了";
                }));
            });           
        }         
WPFの場合
        private void button_Click(object sender, RoutedEventArgs e)
        {
            textBox1.Text = "処理中";

            Task.Run(() =>
            {
                System.Threading.Thread.Sleep(5000);
                this.Dispatcher.Invoke(new Action(() =>
                {
                    textBox1.Text = "処理完了";
                }));

            });
        }

処理の概略をスレッド毎に示す。

System.WindowsのApplication.Current.Dispatcher

System.Windows.ThreadingのDispatcherクラスでUIコントロールの操作を行ったが、もう一つSystem.WindowsのApplication.Current.Dispatcherを利用する方法がある。これら違いを調べても出てこないのでここではWPFで以下の動作検証をすることにとどめる。

        private void Button_Click_1(object sender, RoutedEventArgs e)
        {
            ListBox.Items.Add("処理1:UIスレッドで変更");
            Task t1 = Task.Run(() =>
            {
                System.Threading.Thread.Sleep(100);//想定する重い処理2
                this.Dispatcher.Invoke(() =>
                {
                    ListBox.Items.Add("処理3:ワーカースレッドからUIスレッドに変更要求");
                });
                Application.Current.Dispatcher.Invoke(() =>
                {
                    ListBox.Items.Add("処理4:ワーカースレッドからUIスレッドに変更要求");
                });
            });

            ListBox.Items.Add("処理2:UIスレッドで変更");
        }

処理順番は処理1→処理2→処理3→処理4となる。特に違いがあるようにはみえない

非同期処理にawait/asyncをつけて継続タスクの機能を追加するとどうなるか

awaitは、awaitをつけた非同期処理内からreturnされる値をawait以降のコードで利用するときに便利であるが、ここでは特にreturnは記述しない。ただし、await以降にある処理2が継続タスクとして処理される。

        private async void Button_Click_2(object sender, RoutedEventArgs e)
        {
            ListBox.Items.Add("処理1:UIスレッドで変更");
            await Task.Run(() =>
            {
                System.Threading.Thread.Sleep(1000);//想定する重い処理
                this.Dispatcher.Invoke(() =>
                {
                    ListBox.Items.Add("処理3:ワーカースレッドからUIスレッドに変更要求");
                });
                Application.Current.Dispatcher.Invoke(() =>
                {
                    ListBox.Items.Add("処理4:ワーカースレッドからUIスレッドに変更要求");
                });
            });

            ListBox.Items.Add("処理2:UIスレッドで変更");
        }

処理順番は処理1→処理3→処理4→処理2となる。await/asyncを利用すると継続タスク扱いになり処理の順番が変更されるので注意してください。