[unity]async / await / Task を最低限理解する

「素人は迂闊に近づくな、身を亡ぼすぞ…」
そんなイメージで今まで近づかなかった人に、async / await / Task を説明します。

素人と玄人の境界線は、知らないものに飛び込めるかどうかでほとんど決まる

スレッドとか非同期の説明

スレッドとか非同期ってなんやねん

簡単に言えば「別の平行世界線」。
A という世界線と、B という世界線が同時並行して存在する。そんな厨二心をくすぐる存在です。

厳密には違ったりしますが、そういうややこしいのはヌキで行きましょう。

平行世界線(スレッド)って、必要ある?

例えばこんな時。

ロードしながら、画面も動かす
  • 時間がかかる、膨大な計算を行い、データを生成する
  • 時間がかかる、巨大なファイルを読み込む
  • 時間がかかる、ネットに通信し、その結果を待っている

時間がかかる、というのがポイント
アプリやゲームで数秒画面が固まった場合、人は不安を覚えます。バグったのか? とか UX が悪い、とか言いだすでしょう。

状況によっては1秒未満でも不満を覚えます。
ゲームプレイ中のオートセーブで、セーブする度にゲームが 0.1 秒とか止まってたら「オートセーブうざ」ってなりますよね?
現在の状況をセーブしてほしい。でも、それによってユーザーは待たされたくない。
これが、平行世界線を欲するケースです。

平行世界線の罠

シュタインズゲートでは A の世界線と B の世界線が交わることはありませんでしたが、スレッドは「A と B が交わる必要があります」。

例えば A は B の処理(セーブとしましょう)が終わるまで、こんな表示をさせるとします。

と、いうことはセーブが終了すると、この表示は消す必要があります。
表示を消すのは A です。セーブ終了(がわかるの)は B です。
少なくとも A は B の終了を知る必要があります。

もしかしたら、B はエラーで終わってしまったかもしれません。
その場合 A では、エラーになった事を表示するでしょう。エラー内容を B からもらって。

この同期、伝言ゲームは口で言うほど容易くありません。
あらゆるルートが問題なく動作するよう設計するのは、熟練者といえど厳しいものです。
心しておきましょう。

これを甘く見てると、ほんと痛い目に合う

最初は Task.Run

Task.Run は最も簡単なスレッド生成方法。
test() は A の世界線、longTimeDelay() は B の世界線になります。

    void Awake()
    {
        test();
    }

    void test()
    {
        Task.Run( () => longTimeDelay() );
    }

    void longTimeDelay()
    {
        // 3 秒かかる重い処理
        System.Threading.Thread.Sleep(3000);

        Debug.Log("finished.");
    }

慣れない場合、これから始めるのが1番いいと思います。

UnityEngine にはアクセスしないこと

longTimeDelay() の中で UnityEngine は一切アクセスしない、と覚えておくといいでしょう。

いやいや、それは大げさでしょ!

確かに大げさにいいました。UnityEngine.Debug.Log は B の世界線でも使えます。
が、UnityEngine.Random.Range(1, 10) は A の世界専用です。B で実行したら死にます。

    void Awake()
    {
        test();
    }

    void test()
    {
        Task.Run( () => longTimeDelay() );
    }

    void longTimeDelay()
    {
        // ××× ここで処理が止まり、これ以降実行されない
        UnityEngine.Random.Range(1, 10);

        // 3 秒かかる重い処理
        System.Threading.Thread.Sleep(3000);

        Debug.Log("finished.");
    }

これ以外にも、例えば Application.persistentDataPath にアクセスしただけで死にます。マジかよ。

世の中には A の世界線でしか生きられないものが存在します。Unity 系は大体そうです。なので、「別スレッド(B の世界線)では Unity にアクセスしない」と思っておいた方が安全です。

コルーチンならアクセスできるのに

コルーチンなら Unity にアクセスできるのに!

こんな風に思う人がいるかもいれませんが、コルーチンは A の世界線を分けあってるだけに過ぎない、スレッダー界隈では下に見られている存在です。

なんかメチャクチャな事言い出しましたがノリです。すみません。
こんな事言ってますがコルーチンさんにはいつもお世話になっております。

A の世界線を分け合っているだけなので、その証拠にコルーチンで重い処理を回すと、他が全て止まってしまいます。これではスレッドの利点は得られませんね。

どうしてもスレッドからアクセスしたい

どうしても B から UnityEngine.Random.Range したいんだ! という場合、A の世界線にマーキングしておき、都度呼び出す事はできます。

    System.Threading.SynchronizationContext context;

    void Awake()
    {
        context = System.Threading.SynchronizationContext.Current;
        test();
    }

    void test()
    {
        Task.Run( () => longTimeDelay() );
    }

    void longTimeDelay()
    {
        int val = 0;

        context.Post(
            _ =>
            {
                val = UnityEngine.Random.Range(1, 10);
            }, null );

        // 3 秒待つ
        System.Threading.Thread.Sleep(3000);

        Debug.Log($"finished. val={val}");
    }

5 行目で世界線のマーキング、18-22 が世界の呼び出しです。
ちょっと面倒ですが、この指定が必要な要件は結構あります。

戻り値が欲しければ async~await

B の平行世界線が終わるのを待って、戻り値を得る。

    void Awake()
    {
        var _ = test();
    }

    async Task test()
    {
        int val = await Task.Run( () => longTimeDelay() );
        Debug.Log($"finished. val={val}");
    }

    int longTimeDelay()
    {
        System.Random rand = new System.Random();
        int val = rand.Next(1, 10);

        // 3 秒待つ
        System.Threading.Thread.Sleep(3000);

        return val;
    }

戻り値を得るためには、B の終了を待たなければいけません。これが await です。
なお、await を記述したメソッドには必ず async をつけるというルールがあります。

async void ではなく async Task、非同期で込み入ったプログラムを書いている人ほど「このルールは絶対」という珠玉のルールです。守りましょう。

async void は筆頭問題児

    void Awake()
    {
        test();
    }

    async void test()
    {
        await test1();
        test2();
        await test3();
    }

    async Task test1()
    {
        await Task.Run( () => longTimeDelay("test1") );
    }

    async void test2()
    {
        await Task.Run( () => longTimeDelay("test2") );
    }

    async Task test3()
    {
        await Task.Run( () => longTimeDelay("test3") );
    }

    void longTimeDelay(string name)
    {
        Debug.Log($"start. name={name}");
        System.Threading.Thread.Sleep(3000);
        Debug.Log($"end. name={name}");
    }

test() は test1 -> test2 -> test3 と、非同期メソッドを同期的に(順番に)実行したいと思っています。
ただし、test2 は async void なので await をつけません(つけられません)。

なんとなくイメージ的には「async void の方が await つけなくていい分楽じゃね?」と思ってしまいそうです。

ところがログでは test2 開始直後すぐに test3 が実行されてしまいます。
test2 は async void がついておらず await も使えず、結果として同期実行できないのです。

そもそも test() のような書き方が間違いなのですが、test2 も半端に async がついてるし、ボーッとしてこのように間違えた書き方をしちゃいそうです。
こうなったら致命傷。test3 が test2 実行後のデータを必要としていた場合、普通に落ちます
実際に運用するコードであれば「test2 が間にあえば動く、間に合わない時だけ落ちる」という原因不明の不具合になることが多いでしょう。

この落し穴を防ぐためのセーフティネットとして、なるべく async Task にしておくことです。
async void test() のように、async void は「これ以上は上から呼ばれないメソッド」にしましょう。

エラーハンドリング

B の世界線で起こったエラーを、A に伝える方法を紹介。
他にもあると思いますが、とりあえず使いそうなのを。

    async void Awake()
    {
        // パターン1
        try
        {
            string result = await methodASync("123456");
        }
        catch (Exception ex)
        {
            Debug.LogError(ex.Message);
        }

        // パターン2
        Task<string> task = methodASync("123456");
        await task.ContinueWith(
            (t) =>
            {
                if (t.Exception != null)
                {
                    Debug.LogError(t.Exception.InnerExceptions[0].Message);
                }
            }
        );
    }

    async Task<string> methodASync(string arg)
    {
        // なんかエラーが発生したと仮定
        throw new Exception("error");

        await Task.Delay(3000);
        return arg + "abcdef";
    }

先ほどまでは「同期メソッドを非同期で呼び出す」でしたが、今回は「非同期メソッドを非同期で呼び出す」です。
methodASync が B の世界、Awake が A の世界線です。

await つけてるから結局同期的

パターン1は await を使う方法、パターン2は await を使わない方法です。
とりあえずパターン1だけ覚えておけばいいと思います。

パターン2は、Task.WhenAll とか使いだすと有用?

参考にしたサイト

先人たちに大感謝。


返信を残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA