多分わかりやすいC#の非同期処理その1 〜 ThreadとTask 〜

こんにちは、サイオステクノロジー技術部 武井です。今回は、C#を学び始めた人たちが一番始めに当たる関門であろう非同期処理ついて解説したいと思います。

非同期処理

非同期処理とは何であるかを説明する前に、まず同期処理のことをお話したいと思います。

同期処理とは、結果が返ってくるまで待つ処理のことです。例えば、とあるメソッドmethodAがあったとします。このmethodAは文字列hogeを返すとします。このmethodAを実行して、hogeが返ってくるまで次の処理に進まず、待っているのが同期処理です。

かたや非同期処理とは、methodAを実行後、hogeが返ってくるのを待たずに次の処理に進んでしまいます。

非同期処理を活用するシーンの代表格は、やはりUI処理でしょう。例えば、何らかのボタンをクリックして処理が終わるまでにUIが固まって、他の処理ができなくなってしまうようでは、ユーザービリティはガタ落ちですよね。例えばWebブラウザで、ファイルをダウンロードしたときに、Webブラウザ自体が固まってしまうようなものだった場合、そんなWebブラウザ使いたいと思う奇特な人いないでしょう。

そこで、非同期処理の出番なのです。ボタンをクリックしたときにUIスレッドとは別のスレッドを起動して、UIスレッドを専有しないことで、ファイルダウンロード中でもUIが固まらないようにします。

でも、ところがどっこい、非同期処理って難しいです。ちょっと間違うと、スレッドセーフでないプログラム作ってデータ破壊したり、デッドロック起こしたり、例外が補足出来なかったり、結果を受け取るのがめんどくさかったり、、、。

そんな非同期処理を誰でも簡単にできるようにしようというのが、Taskなのです。これらを使えば、まるで同期処理を書いているような感覚で、非同期処理を実現出来ます。ただ、これらの概念って理解が難しいんですよね。そんな壁にぶつかっている人たちに本記事がお役に立てれば幸いに存じます。

そしてタイトルからもわかりますように、その1とあるので、続きを書く予定です。その2では、もっとわかりにくいasync、awaitについて、多分わかりやすく書こうかなと思っています。

レガシーな非同期処理Thread

Task、async、awaitを説明する前に、まず、レガシーな非同期処理の代表格Threadについてご説明して、その上でThreadとTask、async、awaitの違いをご説明します。

今のこのご時世、これから新規に非同期処理を作るときにThreadを使うことはあまりないかと思いますが、まず新しい概念を説明するときは、今までのレガシーなやり方と比較するとわかりやすいと思うので、ちょっと説明させて頂ければと思います。

以下のコードは、メインスレッドとは別のスレッドで起動したHeavyMethod1で重い処理をしながら、メインスレッドで別の重い処理HeavyMethod2を実行します。処理はThread.Sleepで擬似的に実現しております。非同期処理の説明あるあるですね。

 Program
{
    static void Main(string[] args)
    {
        Thread thread = new Thread(new ThreadStart(() =>
        {
            HeavyMethod1();
        }));

        thread.Start();

        HeavyMethod2();

        Console.ReadLine();
    }

    static void HeavyMethod1()
    {
        Console.WriteLine("すごく重い処理その1(´・ω・`)はじまり");
        Thread.Sleep(5000);
        Console.WriteLine("すごく重い処理その1(´・ω・`)おわり");
    }

    static void HeavyMethod2()
    {
        Console.WriteLine("すごく重い処理その2(´・ω・`)はじまり");
        Thread.Sleep(3000);
        Console.WriteLine("すごく重い処理その2(´・ω・`)おわり");
    }
}

結果は以下のようになります。

すごく重い処理その2(´・ω・`)はじまり
すごく重い処理その1(´・ω・`)はじまり
すごく重い処理その2(´・ω・`)おわり
すごく重い処理その1(´・ω・`)おわり

Threadでの処理をタスクに置き換える

先程のThreadで実施した処理をTaskで置き換えてみます。何で、わざわざTaskで置き換えなければ行けないかと言うと、TaskではThreadで実現出来ない以下のことが実現できるためです。

  • 非同期で実施した処理の状態(実行中、完了、キャンセル、エラー)を知ることができる
  • 例外を補足することができる
  • 非同期処理の実行順序を制御できる

つまり、Taskは非同期処理を実現するための、Threadに変わる新しい方法と思って頂ければよいと思います。

では、先程のThreadで実施した処理をTaskで置き換えたコードは以下のとおりです。

 Program
{
    static void Main(string[] args)
    {
        // Taskを生成しています。Task.RunメソッドはTaskを生成するFactoryクラスで、
        // 戻り値はTask型のオブジェクトです。
        // 引数には、delegateを渡します。単純にHeavyMethod1を実行したい場合は、
        // 引数なしのdelegateを渡します。
        Task task = Task.Run(() => {
            HeavyMethod1();
        }); 

        HeavyMethod2();

        Console.ReadLine();
    }   

    static void HeavyMethod1()
    {   
        Console.WriteLine("すごく重い処理その1(´・ω・`)はじまり"); 
        Thread.Sleep(5000);
        Console.WriteLine("すごく重い処理その1(´・ω・`)おわり"); 
    }   

    static void HeavyMethod2()
    {   
        Console.WriteLine("すごく重い処理その2(´・ω・`)はじまり"); 
        Thread.Sleep(3000);
        Console.WriteLine("すごく重い処理その2(´・ω・`)おわり"); 
    }   
}

単純にThreadの部分をTaskに置き換えただけですので、当然Taskによる効果はほとんど感じられないと思います。

もちろん結果は先程と同じです。

すごく重い処理その2(´・ω・`)はじまり
すごく重い処理その1(´・ω・`)はじまり
すごく重い処理その2(´・ω・`)おわり
すごく重い処理その1(´・ω・`)おわり

次からはユースケース別に、Threadで実装した場合とTaskで実装した場合を比べて見たいと思います。

結果を受け取りたい

非同期処理で結果を受け取りたい場合を考えてみます。hogeという文字列を返すHeavyMethodを実行し、その戻り値であるhogeを受け取ってみたいと思います。

Threadの場合

Threadの場合は以下のコードになります。

 Program
{
    static void Main(string[] args)
    {
        // メインスレッドからアクセスできる変数を定義します。これに結果を格納します。
        string result = ""; 

        Thread thread = new Thread(new ThreadStart(() =>
        {   
            // メインスレッドとは異なるスレットでHeavyMethodを実行して、
            // 結果(hoge)をresultに格納します。
            result = HeavyMethod();
        }));

        // スレッドを開始します。
        thread.Start();

        // Joinメソッドを使うとスレッドが終了するまで、これより先のコードに
        // 進まないようになります。
        thread.Join();

        // 結果をコンソールに表示します。
        Console.WriteLine(result);

        Console.ReadLine();
    }   

    static string HeavyMethod()
    {   
        Console.WriteLine("すごく重い処理(´・ω・`)はじまり"); 
        Thread.Sleep(5000);
        Console.WriteLine("すごく重い処理(´・ω・`)おわり"); 

        return "hoge";
    }   
}

結果は以下のようになります。

すごく重い処理(´・ω・`)はじまり
すごく重い処理(´・ω・`)おわり
hoge

Taskの場合

Taskを使った場合、以下のようなコードになります。

 Program
{
    static void Main(string[] args)
    {
        // 結果を受け取る場合は、TaskをGeneric型として定義してそのGenericに戻り値のクラスを指定します。
        Task<string> task = Task.Run(() => {
            return HeavyMethod();
        }); 

        // 結果を受け取ります。Resultのプロパティを使用すると、Taskで指定したHeavyMethodが
        // 終了するまで待機して、結果をresult変数に入れてくれます。
        string result = task.Result;

        Console.WriteLine(result);

        Console.ReadLine();
    }   

    static string HeavyMethod()
    {   
        Console.WriteLine("すごく重い処理(´・ω・`)はじまり"); 
        Thread.Sleep(5000);
        Console.WriteLine("すごく重い処理(´・ω・`)おわり"); 

        return "hoge";
    }   
}

Threadに比べてTaskを使った方式だと、より直感的に戻り値を取得できるようになった気がしませんでしょうか?Threadを使った場合は、わざわざメインスレッドでresultという変数を定義して、スレッドの中でそれを使っています。しかもJoinメソッドでスレッドの終了を待機しています。いいかにもスレッドであることを意識したコードです。

それに比べてTaskの場合は、TaskのResultプロパティからサクッと取得することが出来ます。Resultプロパティを使うだけで、Threadを使うことによって実施していた以下の2つの処理をしなくてすむようになります。

  • メインスレッドでresult変数をあらかじめ定義する。
  • スレッドが終了するまでJoinメソッドで待機する。

これらの処理は、Taskの場合は、Resultプロパティを使えば、全部やってくれます。

つまり、Taskは、使い人があまりスレッドを意識することなく、同期的なコードに近いコードで非同期を実現できるものです。

例外を補足したい

ThreadとTaskの違いのもう一つの例として、例外の補足の容易性が挙げられます。スレッドの中で発生した例外を補足する場合は考えてみます。

Threadの場合

スレッドの中で例外を発生させたコードです。

 Program
{
    static void Main(string[] args)
    {   
        try {
            Thread thread = new Thread(new ThreadStart(() =>
            {   
                throw new Exception("受け止めて(><)");
                HeavyMethod();
            }));

            thread.Start();

        } catch (Exception e) {
            Console.WriteLine("受け止めたよ⌒ >+○ヽ(・o・ヽ) キャッチ!!");
        }   

        Console.WriteLine("おわり");

        Console.ReadLine();
    }   

    static void HeavyMethod()
    {   
        Console.WriteLine("すごく重い処理(´・ω・`)はじまり"); 
        Thread.Sleep(5000);
        Console.WriteLine("すごく重い処理(´・ω・`)おわり"); 
    }   

}

結果は以下のようになります。

おわり

スレッドの中の例外を握りつぶしてしまっていて、メインスレッドは何事もなかったかのように終わってしまいました。これでは、困りますね。

Taskの場合

Taskの場合では、どうでしょうか?

 Program
{
    static void Main(string[] args)
    {   
        Task task = Task.Factory.StartNew(() => {
            throw new Exception("受け止めて(><)");
            HeavyMethod();
        }); 

        try {
            // 例外をキャッチする場合には、Waitメソッドを実施している部分をtry...catchでくくります。
            // 例外が発生すると、AggregateExceptionにラップされてスローされます。
            task.Wait();
        } catch (AggregateException e) {
            Console.WriteLine("受け止めたよ⌒ >+○ヽ(・o・ヽ) キャッチ!!");
        }   

        Console.WriteLine("おわり"); 

        Console.ReadLine();
    }   

    static void HeavyMethod()
    {   
        Console.WriteLine("すごく重い処理(´・ω・`)はじまり"); 
        Thread.Sleep(5000);
        Console.WriteLine("すごく重い処理(´・ω・`)おわり"); 
    }   

}
<

結果は以下のようになります。

受け止めたよ⌒ >+○ヽ(・o・ヽ) キャッチ!!
おわり

きちんとスレッドの中で発生した例外をメインスレッドがキャッチできました。

ここにおいても、Taskを使った場合は、スレッドを意識することなく、同期的なフローと同じような処理で例外をキャッチできました。

複数のスレッドの終了を待ちたい

複数のスレッドを並行で実行させて、それらのスレッドの結果を処理したいということありませんでしょうか?

Taskを使うとこういうこともラクチンに出来ます。

ここでは、1の整数値を返すHeavyMethod1と、2の整数値を返すHeavyMethod2があり、それらを並行で実行させて、それらの結果をすべて足して、コンソールに3を出力するケースを考えてみます。

Threadの場合

横着してすみません(´・ω・`)ちょっとThreadでこの処理を書く方法をぱっと思いつかなったのですが、相当めんどくさい処理になりそうなことは想像に難くないのではないでのでしょうか?

気を取り直して、Taskで処理する場合のコードに移りましょう。

Taskの場合

Taskの場合は、WhenAllメソッドというものを使うと、複数のスレッドの終了を待つことができます。

 Program
{
    static void Main(string[] args)
    {   
        // 1の整数を返すHeavyMethod1のTaskを生成します。
        Task<int> task1 = Task.Run(() => {
            return HeavyMethod1();
        }); 

        // 2の整数を返すHeavyMethod2のTaskを生成します。
        Task<int> task2 = Task.Run(() => {
            return HeavyMethod2();
        }); 

        // WhenAllの引数に、先程生成したTaskを入れると、HeavyMethod1、HeavyMethod2が終了するまで
        // 次のコードに進みません。つまり待機します。
        Task.WhenAll(task1,task2);

        Console.WriteLine(task1.Result + task2.Result);

        Console.ReadLine();

    }   

    static int HeavyMethod1()
    {   
        Console.WriteLine("すごく重い処理その1(´・ω・`)はじまり"); 
        Thread.Sleep(5000);
        Console.WriteLine("すごく重い処理その1(´・ω・`)おわり"); 

        return 1;
    }   

    static int HeavyMethod2()
    {   
        Console.WriteLine("すごく重い処理その2(´・ω・`)はじまり"); 
        Thread.Sleep(3000);
        Console.WriteLine("すごく重い処理その2(´・ω・`)おわり"); 

        return 2;
    }   
}

結果は以下のようになります。想定どおりですね。

すごく重い処理その2(´・ω・`)はじまり
すごく重い処理その1(´・ω・`)はじまり
すごく重い処理その2(´・ω・`)おわり
すごく重い処理その1(´・ω・`)おわり
3

こんな複雑な処理もTaskを使うと簡単に処理できます。

TaskはThreadの進化系です。複雑だった同期処理をすごい簡単にしてくれます。ありがたいですね。

まとめ

Taskを使うと、今までThreadでやっていたことがすごくとてもシンプルにできることがご理解いただけたかと思います。

次はasync、awaitについて書こうと思います。(いつ?)

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。

コメント投稿

メールアドレスは表示されません。


*