[Unity]Task/async/awaitを理解する(したい)

もう何千番煎じか分かりませんが、Taskまわりについて自分のメモがわりに書いておきます。
この話題についてはたくさんのいい情報があるのでそちらを参照してもらえれば問題ありません。

しかし自分の理解力が乏しいため、今だにデッドロックを引き起こしたり書き方に迷ったりしているので、ここにまとめておきたいと思います。(今後も追記予定)

デッドロックについて
    void Start()
    {
        button.onClick.AddListener(()=>{
            var ret = GetHtmlAsync().Result;
            Debug.Log(ret);
        }
    }
 
    public async Task<string> GetHtmlAsync()
    {
        HttpClient client = new HttpClient();
        var result = await client.GetStringAsync("https://torikasyu.com/");
 
        return result;
    }

HttpClientを使ったGetHtmlAsyncというメソッドを作って、ボタンのクリックで実行するというシンプルなものですが、これを実行するとデッドロックが発生してUnityを落とさないといけなくなります。

[理由]
呼び出し時のResultでメインスレッドをブロックして結果を待ち続ける
=> await GetStringAsyncは別のスレッドで実行されるが、returnで結果を返そうとしたときに内部的にメインスレッドを呼び出そうとする
=> メインスレッドがブロックされているのでどうにもならない

はじめにUnity 2018から.NET 4.6がstableになり、async/awaitやTask使いこなせるようになっておく必要がでてきました。今回はそれらの裏に隠れている、Synchro…

回避方法については、上記にも記載されているように、2種類あります。

1.awaitをつける
    void Start()
    {
        button.onClick.AddListener(async ()=>{
            var ret = await GetHtmlAsync().Result;
            Debug.Log(ret);
        }
    }
 
    public async Task<string> GetHtmlAsync()
    {
        HttpClient client = new HttpClient();
        var result = await client.GetStringAsync("https://torikasyu.com/");
 
        return result;
    }

AddListenerの後にasyncがついて、GetHtmlAsyncの前にawaitがつきました。
今回の例の場合はボタンのイベントハンドラなので、簡単にメソッド自体にasyncをつけることができました。しかし、別の関数から呼び出されたりしている場合は、その関数にasyncを付けなければならず、更にその関数を呼び出している関数にもasyncを付けて、と、最上位までひたすらasyncを付けなければいけなくなり影響範囲が非常に大きいです。なので設計大事ですね・・(非同期関数を同期的に待つ方法については別で書きます)

2.ConfigureAwait(false)を使う
    void Start()
    {
        button.onClick.AddListener(()=>{
            var ret = GetHtmlAsync().Result;
            Debug.Log(ret);
        }
    }
 
    public async Task<string> GetHtmlAsync()
    {
        HttpClient client = new HttpClient();
        var result = await client.GetStringAsync("https://torikasyu.com/").ConfigureAwait(false);
 
        return result;
    }

GetStringAsyncの後に、ConfigureAwait(false)をつけています。これによって、GetHtmlAsync()がreturnするときに、メインスレッドに戻らなくなります。なのでデッドロックが回避されることになります。

もしGetHtmlAsyncをラップしている関数がある場合は、その関数の中での呼び出しにもConfigureAwait(false)をつける必要があります。

    public async Task<string> GetHtmlAsync()
    {
        HttpClient client = new HttpClient();
        var result = await client.GetStringAsync("https://torikasyu.com/").ConfigureAwait(false);
 
        return result;
    }
 
    public async Task<string> GetHtmlAsyncWrapper()
    {
        var result = await GetHtmlAsync().ConfigureAwait(false);
        return result;
    }

これはこれでまた影響範囲が広いですね。更に、この方法では別の問題があります。

    public async Task<string> GetHtmlAsyncWrapper()
    {
        var result = await GetHtmlAsync().ConfigureAwait(false);
        textMesh.text = "GetHtmlAsyncWrapper";
        return result;
    }

ConfigureAwaitの後にメインスレッドに戻ってこない状態で、シーンに置いてあるTextMeshを変更しようとしています。すると、下記のエラーが発生します。

UnityException: set_text can only be called from the main thread.
Constructors and field initializers will be executed from the loading thread when loading a scene.
Don't use this function in the constructor or field initializers, instead move initialization code to the Awake or Start function.

画面の更新はメインスレッドでしか行えない、というエラーになります。これを解消するためにはメインスレッドを指定する、という方法もありますが、別の項目で記載します。

どちらにしろ影響範囲が広いので、やはり設計が大事、ということになりますね。例えばViewクラスでしか画面の変更は行わず、その下のViewModelクラスに非同期メソッドを定義しておく、などです。

メインスレッドを指定する方法

スレッドの情報はSystem.Threading.SynchronizationContextで扱うことができます。メインスレッド上でCurrentを取得して、引数として非同期関数に渡し、非同期関数内では渡されたメインスレッドで画面を更新する処理を実行します。

    void Start()
    {
        button.onClick.AddListener(()=>{
            var context = SynchronizationContext.Current;
            var ret = GetHtmlAsyncWrapper(context).Result;
            Debug.Log(ret);
        }
    }
 
    public async Task<string> GetHtmlAsyncWrapper(SynchronizationContext _context)
    {
        var result = await GetHtmlAsync().ConfigureAwait(false);
        _context.Post(_ =>{
            textMesh.text = "GetHtmlAsyncWrapper";
        },null);
 
        return result;
    }
 
    public async Task<string> GetHtmlAsync()
    {
        HttpClient client = new HttpClient();
        var result = await client.GetStringAsync("https://torikasyu.com/").ConfigureAwait(false);
 
        return result;
    }
非同期メソッドを同期メソッドとして待つ方法

上記で、デッドロックを回避するためにひらすら最上位までawaitをつけて行く方法を書きました。
構造上これはツライさんな場合は、asyncの付いた非同期メソッドを同期メソッドとして実行することができます。

    public async Task<string> GetHtmlAsync()
    {
        HttpClient client = new HttpClient();
        var result = await client.GetStringAsync("https://torikasyu.com/");
 
        return result;
    }
 
    public string GetHtmlAsyncWrapper2()
    {
        var result = Task.Run(() => GetHtmlAsync());
        return result.Result;
    }

Task.Run()というメソッドが出てきました。この書き方をすると、非同期メソッドを同期メソッドとして実行することになり、GetHtmlAsyncWrapper2()にasyncをつける必要がなく、asyncの連鎖をストップすることができます。ただし、同期メソッドのため、UIはその間フリーズしてしまいます。なので最後の手段として緊急回避的な方法になります。

スポンサーリンク

シェアする

  • このエントリーをはてなブックマークに追加

フォローする

スポンサーリンク

コメント

  1. […] Task / async / await を理解する | 独立型戦闘支援ブログ […]