土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

非同期メソッド入門:async/await構文が何故必要とされ、どのように動作するのかを解説する

はじめに

 使う機会がないと永遠に触れることの無い機能オールタイムベスト(?)こと「非同期処理」、そしてC#で非同期処理の代名詞的な扱いである「async/await構文」についてまとめました。「何故非同期処理を実装するのに専用構文が必要なのか?」「async/awaitは具体的にどのような挙動をしているのか?」などにしっくり来ていない方におすすめです。

注意

 説明を優先するために非同期処理において重要な概念のいくつかの説明を省略したり、簡略化したりしています。最後に必要と思われる最低限の補足を書いているので、そちらも参考にしてください。また、参考リンクに載せたサイトで、より詳細な解説を得られます。

想定する状況

 ソシャゲの起動時に表示する「お知らせ」の実装を考えてみます。あの手の表示は、ネットごしにhtmlを取得し、それをwebエンジンに丸投げして表示させるのが一般的かと思います(それが一番楽な実装だから)。
 この「ネット越しにhtmlを取得する」という処理は、非常に時間がかかります。後ほど実際にやってみますが、場合によっては200ミリ秒(約12フレーム)以上かかる場合もあるでしょう。これをそのまま実装すると、12フレームの間ユーザーからの入力を受け付けないことになってしまいます。なのでこれをなんとかしなければならないというのが今回のミッションです。

Main()メソッドの実装

 最初に、今回使うサンプルコードのガワは以下になります。コンソールアプリケーションで、RenderInfomation()メソッドを呼び出すとhtmlの取得が行われます。メソッド呼び出し前後で文字列を出力し、また、メソッドを呼び出してから戻ってくるまでをストップウォッチで計測しています。

using System;
using System.Threading.Tasks;

public class AsyncSample
{
    static void Main()
    {
        var stopWatch = new System.Diagnostics.Stopwatch();
        Console.WriteLine("呼び出し開始");
        stopWatch.Start();

	//このメソッドの実装を更新していく
        RenderInfomation("http://www.yahoo.co.jp"); 

        stopWatch.Stop();
        Console.WriteLine("呼び出し終了");
        Console.WriteLine($"{stopWatch.ElapsedMilliseconds}ミリ秒(約{stopWatch.ElapsedMilliseconds / 16.3:N1}フレーム)");
        Console.ReadLine();

        return;
    }
}

 次に、RenderInfomation()メソッドから呼び出す2つのメソッドを作っておきます。「ネット越しにhtmlを取得するメソッド」と、「取得したhtmlを描画するメソッド」です。
 まずhtmlを取得するメソッドはこんな感じ。指定したurlからhtmlをまるごと取得して返します。

    static string GetHTML(string url)
    {
        System.Net.WebRequest webRequest = System.Net.WebRequest.Create(url);
        using (System.Net.WebResponse webResponse = webRequest.GetResponse())
        using (System.IO.Stream stream = webResponse.GetResponseStream())
        using (System.IO.StreamReader streamReeader = new System.IO.StreamReader(stream, System.Text.Encoding.UTF8))
        {
            string result = streamReeader.ReadToEnd();
            return result;
        }
    }

 受け取ったhtmlを描画するメソッドを実装するのは大変なので、取得したバイト数と中身をコンソールに出力するようにしておきます。

    static void EvalHTML(string html)
    {
        Console.WriteLine($"{html.Length}バイト読み込みました");
        //↓この行を有効にすると取得したhtml本体をコンソールに出力できます
        //Console.WriteLine(html);
    }

 以後、この2つのメソッドを実行する方法を考えていきます。

パターンA:非同期を使わない

 まず、非同期では無い場合からはじめましょう。先程の2つのメソッドを単に実行します。

    static void RenderInfomation(string url)
    {
        string result = GetHTML(url);
        EvalHTML(result);
    }

 実行するとコンソールに以下のように表示されました(注意:数値はあくまで目安です。スレッドの初期化コストやキャッシュの有無など、様々な要件で値は大きく増減します)
f:id:t_tutiya:20201004184931p:plain
 Test1メソッドを呼び出してから抜けるまでに685ミリ秒(60fps換算で約42.0フレーム)かかっています。その間ユーザーからはフリーズして見えるわけです。これをどうにかしていきます。

パターンB:Task.Run()を使ってみる(非同期処理を使っているが結果的には同期)

 早速非同期処理をやってみましょう。ただし、先にダメな例から見てみます。以下はhtmlの取得を別のスレッドで実行するコードです。

    static void RenderInfomation(string url)
    {
    	//①
        Task<string> task  = Task.Run(() => {
            return GetHTML(url);
        });

        //②
        string result = task.Result;

        EvalHTML(result);
    }

①Task.Run()は引数で渡したタスク(非同期で処理するコードの単位のこと。ここではラムダ式)を別スレッドで実行し*1、そのタスクのハンドル*2であるTask<TResult>オブジェクトを返します。ここではTask<string>を宣言し、GetHTML()の戻り値でstring型を受け取れるようにしています。
 タスクは別スレッドで実行されるので、メインスレッドはすぐ②の処理に進みます。

②Task.Resultプロパティは、評価されるとタスクの処理が完了するまで待機し、完了したらそのタスクの戻り値を返します。ここではGetHTML()の戻り値がresultに格納されます。
 結果は以下の通り。htmlの読み込みが終わってから処理がMain()に戻っています。「完了するまで待機」しているので、結果的に同期処理と同じ結果になっているわけです。これでは非同期処理とは言えません。
f:id:t_tutiya:20200830152818p:plain

パターンC:ContinueWithを使った非同期

 ここからが一般的な非同期処理になっていきます。

    static void RenderInfomation(string url)
    {
    	//①
        Task<string> task = Task.Run(() => {
            return GetHTML(url);
        });

    	//②
        task.ContinueWith((task)=> {
            string result = task.Result;
            EvalHTML(result);
        });
    }

①Task.Run()で別スレッドを実行しTaskオブジェクトを受け取る処理はパターンBと同じです。
 タスクは別スレッドで実行されるので、メインスレッドの処理はすぐ②に続きます。
②Task.ContinueWith()は「『タスクが終了したら実行するメソッド』を登録する」というメソッドです(ここではラムダ式を設定しています)。登録しているだけなので、RenderInfomation()は終了してMain()に戻ります。一方別スレッドの方では、GetHTML()が終わった後でtask.Resultからhtmlデータを受け取りEvalHTML()を実行します。
f:id:t_tutiya:20200830152822p:plain
 先程と異なり、"37079バイト読み込みました"が一番最後に来ています。これは、RenderInfomation()がhtmlの読み込みを待たずに処理をMain()に返し、Console.ReadLine()で待っている間に別スレッドの処理が終了してEvalHTML()が値を返した事を示しています。

バリエーション

 ちなみに、上のコードは以下のように書く事もできます(同じ処理になります)。

    static void RenderInfomation(string url)
    {
        Task.Run(() => {
            return GetHTML(url);
        }).ContinueWith((task) => {
            string result = task.Result;
            EvalHTML(result);
        });
    }

【ここが重要!】もうちょっと読みやすくできないものか

 パターンCの実装は正しく非同期処理を実現しています。さて、このコード読みやすいでしょうか? 難しく言えば、可読性やメンテナンス性に優れているでしょうか?
 今回のサンプルは簡単で短いので読みにくいということは無いかもしれません。しかし、非同期処理の実装は容易にコードを複雑にしてしまえることが知られています。
 例えば、GetHTML()で受け取った値から新規にurlを生成し、そのurlを使って更にGetHTML()で別のサイトを読み込むコードを考えてみます。新規にurlを生成するのは大変なのでCreateNewURL()というモックメソッドを使います。

    static void RenderInfomation(string url)
    {
        Task<string> task = Task.Run(() => {
            return GetHTML(url);
        });
        task.ContinueWith(t => {
            string result = t.Result;
            EvalHTML(result);

            url = CreateNewURL(result);
            Task<string> task = Task.Run(() => {
                return GetHTML(url);
            });
            task.ContinueWith(t => {
                string result = t.Result;
                EvalHTML(result);
            });
        });
    }

    static string CreateNewURL(string url)
    {
        return "http://microsoft.co.jp";
    }

 2個目のGetHTML()がurlを受け取るには、1個目のGetHTML()の終了を待たなければなりません。そこで、Task.Run()をネストし、ContinueWith()を2回実行します。このように、非同期処理を連続で実行するにはネストが必要で、コードの可読性/メンテナンス性が落ちます。更に非同期処理を連結したり、前のタスクの結果によって次に実行する非同期処理を分岐で切り替えるようにしたりすれば、より複雑なコードになってしまうことが想像出来るかと思います。
 ちなみに実行結果は以下のようにちゃんと非同期になっています(「受け取った値をベースに~」を出力する処理はサンプルコードにはありません)。
f:id:t_tutiya:20200830152825p:plain
 典型的な非同期処理では、ここまで説明してきたような「非同期で実行したタスクの完了後に、タスクの結果に演算を加える」というコードが頻出します。しかし、多くのプログラミング言語ではこれを簡潔かつ汎用的に記述することが難しく、これまで様々な試行錯誤が行われてきました。
 そんな中、C#*3では、この問題を言語機能レベルで解決することにしました。そのためにC#ver5.0から導入されたのが、await/async構文なのです(やっと出てきたよasync/await!)。

パターンD:async/awaitを使った非同期(非同期メソッド)

 それでは、async/awaitを使って実装してみます(以下は同じ結果になるので出力画面は省略します)。

    //①
    static async void RenderInfomation(string url)
    {
    	//②
        string result = await Task.Run(() => {
            return GetHTML(url);
        });
        EvalHTML(result);
    }

①非同期処理するメソッドの宣言をasync修飾子(async modifier)で修飾します。ちなみに、このasyncは文法仕様上は本来無くても良いのですが、async/await構文導入前のコードでawaitを識別子に使用しているコードで問題が起きないようにしてあるそうです*4
②Task.Run()の手前にawait演算子(await operator)を記述します。この記述によって、コンパイラは、この式より後にあるコードを「タスクの完了後に実行されるコードである」と見なし、パターンCのようにContinueWith()を使った場合とほぼ同じコード*5を生成します。パターンCと比べると、可読性の高いコードになっていることが分かると思います。

バリエーション

 【ここが重要!】で書いた、非同期処理を連続して実行するコードも書き換えてみましょう。

    static async void RenderInfomation(string url)
    {
        string result = await Task.Run(() => {
            return GetHTML(url);
        });
        EvalHTML(result);

        url = CreateNewURL(result);
        string result2 = await Task.Run(() => {
            return GetHTML(url);
        });
        EvalHTML(result2);
    }

 ネストがなくなり、(非同期では無い)通常の処理のように記述できます。

(補足)パターンE:RenderInfomation自体を非同期メソッド化する

 ここまでで入門編は終わりで、最後に補足を。実はこれまで説明したパターンA~Dは非同期処理として不完全でした。というのも、WebRequest.GetResponse()は同期処理でして、実行されるとWebレスポンスを取得し終わるまでそこで待機してしまうのです。
 Webレスポンスを非同期で取得するにはWebRequest.GetResponseAsync()を使います。これを使うことでGetHTML()メソッド自体を非同期メソッドにすることが出来ます。修正したGetHTMLAsync()メソッドと、それを呼び出すRenderInfomation()を以下に示します。

    static async void RenderInfomation(string url)
    {
        string result = await GetHTMLAsync(url); //①
        EvalHTML(result);
    }

    //②
    static async Task<string> GetHTMLAsync(string url)
    {
        System.Net.WebRequest webRequest = System.Net.WebRequest.Create(url);
        
        //③
        using (System.Net.WebResponse webResponse = await webRequest.GetResponseAsync())
        using (System.IO.Stream stream = webResponse.GetResponseStream())
        using (System.IO.StreamReader streamReeader = new System.IO.StreamReader(stream, System.Text.Encoding.UTF8))
        {
            string result = streamReeader.ReadToEnd();
            return result;
        }
    }

①新規に実装したGetHTMLAsync()をawait演算子を付与して実行します。
②GetHTMLAsync()の宣言をasyncで修飾し、非同期メソッドとします。
③webRequest.GetResponse()の代わりにwebRequest.GetResponseAsync()メソッドをawait演算子を付与して実行します。

 非常に少ない修正で、GetHTML()を非同期メソッド化することができました。GetHTMLAsync()の戻り値はTask<string>ですが、RenderInfomation()内ではstringを返すように記述できている点に強い違和感を覚えますが、これこそ、非同期処理の複雑な部分をコンパイラが裏でコードを生成して解決している証左とも言えるでしょう。

終わりに

 以上で説明を終わります。説明を省略している箇所も多数あるし、正確で無い説明になっている箇所もあるかもしれませんがご容赦ください(コメントでご意見頂けると嬉しいです)。Unityでasync/awaitを使うのに最低限必要な解説にはなっていると期待したいです(とはいえ、土屋自身が非同期コード書いたことがほぼ無いのでわからん)。
 Unityでasync/awaitを何に使うのかという点については、基本的にはI/O処理になると思います。ステートマシン管理に使うのはオーバースペックでしょう(使えるのかも知らない)。本論から外れますが、土屋自身はyieldコルーチンを使った状態管理も懐疑的です(プッシュダウンステートマシンの記事を参照)。

補足:この記事で説明していない概念について

 今回説明を省略した物のうち、いくつかについて簡単に書いておきます。

同期コンテキスト(synchronization context)

 複数のスレッドがGUI、つまり描画処理を行おうとした場合、GPUに描画命令を出している最中にスレッド切り替えが起きるとまともに動作しないため、あるスレッドが描画中はGPUを占有する必要があります。このようにマルチスレッド時に処理が確実に動作するように実装することをスレッドセーフと言います*6
 スレッドセーフな実装は非常にコストが高く、GUI描画のように頻出しかつ速度が求められる処理では避けたい所です。そこで、多くのプラットフォームでは、GUIフレームワークは予め決められた1つのスレッド(UIスレッドと言います)のみで動作するように設計されています。これはDirectX/OpenGLなどの低レイヤライブラリでもそうなっているので、Unityを使う場合にも同様です。
 一般に、UIスレッドはメイン処理が動いているスレッドです。そのため、今回のように非同期でhtmlを取得してからそれを描画したい場合、描画する前に元のスレッドに切り替える必要があります。その為には「元のスレッド」の情報が必要になり、この情報のことを「同期コンテスト(synchronization context)」と言います。
 同期コンテキストは以下のように設定します。

task.ContinueWith(()=> {

    //UIスレッド上で動かしたい処理

}, TaskScheduler.FromCurrentSynchronizationContext());

 このように、ContinueWithの第2引数にUIスレッドの同期コンテキストを渡しておく(これを「同期コンテキストを拾う」と表現するようです)と、task完了後の処理はUIスレッドで実行されるようになります(ちなみに、コンソール出力はUIスレッドでなくても実行できます)。
 なお、await/asyncでは、標準で同期コンテキストを拾い、次の行から元スレッドで動作しています(敢えて切り替えないようにするにはオプション設定が必要です)。

 ……と、ここまでが教科書的な説明なのですが、Unity用高性能非同期ライブラリUniTaskではUIスレッドに戻す処理を省略しています。どうやら、Unityではこの処理をコアコード(C++)側で行うため、C#で対応する必要がないようです(未確認)。

スレッドプール

 記事中では何度か「タスクを別スレッドを実行し」と書いていますが、これは「タスクをスレッドプールのキューにスタックし」が正しいです。この「スレッドプール」について説明しておきます。
 必要に応じて新規スレッドを生成/削除するというのは非常にコストの高い処理で、頻繁に実行するとシステム全体の処理能力が低下してしまいます。そこで、一般的な処理系では、予め複数のスレッドを立てておき(これを「スレッドプール」と言います)、空いているスレッドにタスクを振り分けています。
 Task.Run()が実行されると、タスクは一度タスクキューにスタックされ、システム側でスレッドプールの中から空いているスレッドが選択され(空きがない場合には新規に作成し)、そこにタスクが割り当てられるのです。

コンパイラがasync/awaitをどのように展開するか

 記事中では「コンパイラはContinueWith()を使った場合とほぼ同じコードを生成する」と書いていますが、実際には他にもいくつかの処理が生成されています。そのうちの1つは、先程説明した同期コンテキストで元スレッドに戻す処理です。他にも、非同期処理が一瞬で終わった場合にはそもそも待機しないように分岐させたり、awaitを連続で記述した場合にも正しく動くようなステート管理の処理が生成されています。

参考リンク

非同期メソッドの内部実装 - C# によるプログラミング入門(via ++C++; // 未確認飛行 C)
ufcpp.net

async/awaitと同時実行制御(via ++C++; // 未確認飛行 C)
async/awaitと同時実行制御ufcpp.wordpress.com

An other world awaits you 避けては通れない非同期処理(via slideshare)
An other world awaits you

UniTask v2 - Unityのためのゼロアロケーションasync/awaitと非同期LINQ(via Cygames Engineers'Blog)
tech.cygames.co.jp

*1:正確にはスレッドプール上のキューに配置する。スレッドプールについては後述

*2:ある処理に間接的にアクセスする為のエントリポイントのこと。昔は生のアドレスを関数ポインタとして使っていた。

*3:および近年のプログラミング言語

*4:asyncを付けないとコンパイルエラーになるので気にする必要はないです

*5:実際に生成される処理については後述

*6:これは現段階土屋の理解で、スレッドセーフ自体の定義はもっと複雑な物かと思われます