【C#】async/awaitと仲良くなろう

こんにちは!KENTEM第2開発部のK.H.です。
10年以上C#er(C#を扱う技術者)をやっています。

C#erのみなさん、async/awaitは好きですか!?
私は正直嫌いです。ややこしい上に、別に求めてなくても最近のライブラリ使っていると勝手に顔出してくるウザいやつです。あと読み方も嫌いです。
でも嫌いな人とも仲良くしなければならないのが社会人です。
嫌いな人と付き合っていくコツは嫌な部分をちゃんと理解しておくことです。
事前に理解していれば「なんだコイツ!」ってなることも減るでしょう。
その結果、好きになることもあるかもしれません。
ということで、今回はasync/awaitの使い方と、使ってきた中での嫌だったところを共有させていただきます。

async/awaitの使い方

重たい処理があったとします。

        void HeavyMethod()
        {
            //重い処理
            Thread.Sleep(1000);
        }

これをどうにか非同期にさせたい。
Taskにしてコールします。

        Task HeavyTask()
        {
            return Task.Run(() => HeavyMethod());
        }
        void HeavyTaskCall()
        {
            HeavyTask();
            //HeavyTask終了後の処理
        }

でもこのままだと、HeavyTaskが終わる前にHeavyTask終了後の処理が走ってしまう。
そこでasync/awaitの登場

        async Task HeavyTaskCall()
        {
            await HeavyTask();
            //HeavyTask終了後の処理
        }

awaitでHeavyTaskを待ってくれるのでHeavyTask終了後の処理との順序が守られます。
awaitを使いたいときは必ずasyncを付ける必要があります。 めでたしめでたし。

async/awaitとの嫌な思い出

ここからは、開発作業を進めていく中で、実際に問題にぶつかった嫌な思い出です。

デッドロック

シングルスレッドで生きてきたのに、新しいライブラリに更新したら急に〇〇Asyncとかってメソッドに代わっていたりします。
できる限りソースコードを改変したくない(弱気)。
そんな時にWaitメソッドという非同期メソッドを同期させてくれる神メソッドを見つけます。

        private void button1_Click(object sender, EventArgs e)
        {
            HeavyTaskCall().Wait();
        }

デッドロックが発生します。(フリーズ)
以下の二つの条件が当てはまると発生するようです。
①UIスレッドでTaskのWaitを呼んでいる
②そのTask内でawaitしている
②はざらにあるので防げませんが、①はちゃんと意識する必要があります。waitしててもある場所では発生するけど別の場所では発生しないという状況になって混乱します。
そもそも、できるかぎりWaitは使わない方が良いようです。

どこまで直したらいいの?

よし、Waitは使わないようにしよう。でもちゃんと処理の順序は守りたい。
async/awaitを使えば楽勝だぜ。
あれ、このメソッドを呼んでいるところもasync/awaitを使わないと順序が守られない。
あれ、さらに呼んでるメソッド・・・・
気づいたら大量のコード変更に・・・。
イベントハンドラーまで辿り着いてようやく解放されます。
私はこれをasync/await地獄と呼んでいます。
Waitの誘惑が消えません。

非同期じゃないの?

Taskを使えば簡単に非同期処理を作れる。
よし、並列処理で高速化だ!ってことで、以下のように書いてみます。

        async Task HeavyTask()
        {
            HeavyMethod();
            await OtherTask();
        }
        Task OtherTask()
        {
            return Task.Run(() => 処理);
        }

HeavyMethodは全然高速化されません。
なんとなく、Taskを戻り値にしているメソッド内は非同期って勘違いしてしまいますが(私だけ?)、別スレッドに移行するのはTask.Run部分からです。
その外側で重い処理を書いてしまうと全く意味がありません。

動いてるの?動いてないの?

タスクの定義方法には二つあります。

        Task HeavyTask1()
        {
            return Task.Run(() => HeavyMethod());
        }
        Task HeavyTask2()
        {
            return new Task(() => HeavyMethod());
        }

HeavyTask1はコールされればHeavyMethodが実行されますが、HeavyTask2はコールされてもまだHeavyMethodは実行されていません。
戻り値のTaskをStartさせることで実行されます。
メソッドを作っている人には当たり前のことで特に問題ないと思いますが、使う側は気を付けなければ行けません。
私のプロジェクトでは、重たい処理の場合に下記のような形でウェイトアニメーションを表示しています。

        Task WaitForTask(Task task)
        {
            _animationView.Show();
            task.Start()
            await task;
            _animationView.Close();
        }

Taskは二重でStartさせるとエラーになるので、引数のTaskがHeavyTask1だった場合にはエラーになります。
Task.Statusで実行されているかどうかわかるので、判定させればOKです。

エラーを隠蔽

        async Task HogeMethod()
        {
            var result = await HeavyTask();
            if (!result)
                throw new HogeExceptoion();
        }
        void HogeMethodCall()
        {
            try
            {
                HeavyTaskCall().Wait();
            }
            catch (HogeException ex)
            {
                //例外処理
            }
        }

上記のように例外ケースが発生しうる際に、メソッド内で例外をスローさせて、使う側でキャッチするケースがあると思います。
悲しいことに上記のコードではHogeExceptionをキャッチできません。
Waitされた場合、Task内の例外はAggregateException内に格納されるため、抜き出して判定する必要があります。
async/awaitすれば回避できます。
Waitはやっぱり使わない方がいいのでしょう。

まとめ

とりあえず思い出せる範囲はこのくらいです。
みなさんいかがでしたでしょうか?
async/awaitが嫌いになりましたか?好きになりましたか?
こうやって整理してみて、嫌いなのは私の理解が浅いのが原因な気がしてきました。
これから仲良くなれるよう理解を深めていきたいと思います。
※そもそもasync/awaitというよりもTaskの話の方が多かったですが、セットで考えてしまっている部分もあるので、そのあたりはご容赦ください。

KENTEMでは、様々な拠点でエンジニアを大募集しています!
建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。
recruit.kentem.jp career.kentem.jp