tanaka's Programming Memo

プログラミングについてのメモ。

Unity6ではじめてのAwaitable

本記事は、Qiita Advent Calendar 2024. Unity Advent Calendar 2024のシリーズ2の12日目の記事です。

です。

最近まで、諸々の事情でUnity2021を使っていて、がっつりタスクを使う場面もなかったので、コルーチンにはIEnumeratorを使い続けていました。ようやくUnity6に更新したので、新たに導入されたAwaitableを使ってみようということで、リアル「はじめてのAwaitable」です。

目次

基本的なこと

これまでずっとIEnumeratorでやってたので、基本的なことを調べるところからはじめました。

そもそも非同期処理とは

流石に非同期処理は知ってましたが、念の為。

非ではない同期処理から。同期処理とは、ある処理が完了するまで、次に処理を進めない処理方法です。たとえば次のようなコードがあったとします。

Debug.Log($"こんにちは!");
Debug.Log($"同期処理で、");
Debug.Log($"表示します。");

Unityでこれを実行すると、コンソールに次のような感じで表示されます。

こんにちは!
同期処理で、
表示します。

当たり前のようですが、このように順序よく表示されるのは、UnityのC#が基本的に同期的に実行されるからです。

非同期処理は、同期しない、つまり、ある処理の完了を待たずに、すぐに次の処理をはじめる処理方法のことです。先のコードを非同期に実行すると、次のように表示されるかも知れません。

こ同表ん期示に処しち理てはでい!
、ま

す。

3つのDebug.Logが次々に開始して、それぞれが並行して処理を進めていきます。実行順は、その時々の状況に応じて変わるため、実行するたびに結果は変わります。

同期処理は、手順通りに処理を進めるプログラムの実行に向いています。しかし、時間がかかる大量のデータを処理するプログラムや、サーバーからの返信を待つような処理では、システムの応答が止まってしまいます。複数の処理をスレッドなどに分割して、非同期に実行することで、システムの停止を回避できます。非同期処理は、複数の処理や重い処理を実行したい場合に活躍します。

Unityでは、IEnumeratorを使ったコルーチンや、AsyncOperationを返すLoadSceneAsyncやUnloadSceneAsyncなどの非同期処理の機能を提供してきました。

Awaitableとは

日本語にすると、「待つことができる」。Unity6(Unity2023.1)で追加されたAwaitableクラスは、Unityのライフサイクルを、awaitで待機できるようにするクラスです。裏スレッドに実行を切り替える機能もあります。

Awaitableは、awaitと一緒に使うことを想定したクラスです。

awaitとは

awaitは、非同期操作を受け取る演算子です。かなりややこしい動きをします。

  • awaitが出てくるまでは、普通のメソッドと同じように、同期して処理を進める
  • awaitが非同期操作を受け取ったら、その場で処理を中断して、呼び出し元に制御を返す
  • 非同期操作が完了したら、awaitで中断したところから処理を再開する

処理を途中で中断して、呼び出し元に制御を戻すことで、システムのフリーズを防ぎます。非同期操作が完了したら、中断した場所から再開されます。これにより、UIや他のゲームオブジェクトの動作を停止させることなく、いつ終わるかわからないような処理の完了を待ってから、続きの処理を実行できます。手順通りに進めたい処理で、途中で時間がかかったり、操作待ちがあるような場合でも、手軽に実装できます。

awaitのうしろに、非同期操作を返すラムダ式や、非同期メソッドを書くことができます。その場合は、awaitで中断するより先に、ラムダ式や非同期メソッドを呼び出して、同期的に処理が進みます。どこかでawaitに非同期操作が渡されて、待機状態になったら、awaitをさかのぼって、呼び出し元まで制御が戻ります。そこからは、通常のawaitと同様の流れで実行されます。戻り値を受け取るで、実際の進行を例示します。

asyncとは

asyncは、awaitを使いたいメソッドに付けて、非同期メソッドとして定義するものです。

awaitを含まないメソッドの宣言に、asyncを付ける意味はありません。awaitを含まないメソッドにasyncをつけると、以下のような警告が出ます。

awaitを持たないasyncメソッドは警告が出る

asyncとawaitは、同一スレッド上の制御が滞らないようにする機能です。これら自体には、別スレッドで処理を実行するような機能はありません。

TaskとAwaitable

TaskとAwaitableは、どちらもawaitに渡せる非同期操作を返す機能を持ちます。

Taskは、別のスレッドで処理を実行する機能を提供します。メインスレッドを止めずに、時間がかかる計算を実行するのに適しています。名前が示す通り、何らかの処理を実行することを目的としています。.NETで提供されるクラスなので、Unityのライフサイクルにあわせて実行することはできません。

Awaitableは、Unityから提供されるクラスです。Awaitableという名前が示す通り、awaitで待機することを主な目的としています。Unityの特定のライフサイクルや、指定の秒数が経過するのをawaitで待機する機能を提供します。これにより、IEnumeratorと同じようなUnityのライフサイクル内で実行できるコルーチンを、async/awaitを使って実装できるようになりました。また、実行スレッドをメインとバックグラウンドで切り替える機能も提供されています。Taskと同様に、重い処理をバックグラウンドスレッドで実行したいときにも使えます。

Awaitableの使い方

基本的なことがわかったので、Awaitableを使ってみます。Awaitableが提供する機能は、次のとおりです。

  • IsCompleted
    • Awaitableが完了していたら、trueを返す
  • Cancel
    • Awaitableが待機中なら、System.OperationCanceledExceptionをスローして、処理をキャンセルする
  • BackgroundThreadAsync
    • awaitで処理を中断して、次のフレームまで待機する。次のフレームになったら、バックグラウンドスレッドに切り替えて、処理を再開する。すでにバックグラウンドスレッドだったら、何もしない
  • EndOfFrameAsync
    • 現在のフレームの更新処理が完了するまで待機してから、処理を再開する
  • FixedUpdateAsync
    • 次の物理更新まで待機してから、処理を再開する
  • FromAsyncOperation
    • LoadSceneAsyncなどで返されるAsyncOperationのインスタンスを、Awaitableに変換する
  • MainThreadAsync
    • awaitで処理を中断して、次のフレームまで待機する。次のフレームになったら、メインスレッドに切り替えて、処理を再開する。すでにメインスレッドだったら、何もしない
  • NextFrameAsync
    • 次のフレームまで待機してから、処理を再開する
  • WaitForSecondsAsync
    • 指定の秒数待機してから、処理を再開する

Awaitable - Unity スクリプトリファレンスより

Unityのライフサイクルを考慮したコルーチン

Awaitableのうち、Unityのライフサイクルや、秒数を待つコルーチンとしての使い方です。

using UnityEngine;

public class CoroutineSample : MonoBehaviour
{
    private void Start()
    {
        DoCoroutine();
        Debug.Log("Start終わり");
   }

    /// <summary>
    /// コルーチン的な使い方
    /// </summary>
    async void DoCoroutine()
    {
        Debug.Log("処理開始");
        await Awaitable.NextFrameAsync();
        Debug.Log("1フレーム経過");

        await Awaitable.EndOfFrameAsync();
        Debug.Log("フレーム処理が完了。カメラの移動など、他のオブジェクトの更新後の処理を書くとよい");

        await Awaitable.FixedUpdateAsync();
        Debug.Log("物理更新のタイミング。物理更新関連の処理を書くとよい");

        await Awaitable.WaitForSecondsAsync(1f);
        Debug.Log("1秒経過");
    }
}

ざっくりと処理の流れです。

  • 5行目:シーンが開始したら、Startが呼ばれる
  • 7行目:非同期メソッドDoCoroutineを呼び出す。従来のコルーチンのようなStartCoroutine()は不要
  • 14-16行目:「処理開始」とログ表示。ここまでは、普通のメソッドと同じ動作
  • 17行目:awaitのオペランドのAwaitable.NextFrameAsync()を呼び出して、次のフレームまで待機する非同期操作を受け取ったら、呼び出し元に制御を戻す
  • 8行目:awaitによって制御が戻るので、「Start終わり」と表示して、Startメソッドを終える
  • 次のフレームになるまで、処理なし
  • 18行目:次のフレームになったら、18行目から処理を再開。「1フレーム経過」と表示
  • 20行目:awaitが出てきたので、メソッドから抜ける。呼び出し元のStartは終わっているので、他のオブジェクトの処理などへ
  • フレームの更新処理が終わるまで、処理なし
  • 21行目:フレームの更新処理が一通り終わったら、21行目から処理を再開。「フレーム処理が完了・・・」と表示
  • 23行目:awaitが出てきたので、メソッドから抜ける。20行目と同様
  • 物理更新がはじまるまで、処理なし
  • 24行目:物理更新がはじまったら、24行目から処理を再開。「物理更新のタイミング・・・」と表示
  • 26行目:20, 23行目と同様
  • 1秒経過するまで、処理なし
  • 27行目:1秒経過したら、27行目から処理を再開。「1秒経過」と表示
  • 28行目:非同期メソッド終わり

コンソールは、以下のように表示されます。

コルーチン的使い方のサンプルの表示例

処理は、メインスレッド上で実行されます。「Start終わり」の表示が出力されるタイミングが腑に落ちれば、async/awaitが大体理解できたものと思います。フレーム更新や物理更新するまで、awaitで待機する、文字通り「Awaitable」な使い方をしています。

スレッドの切り替え

Awaitableには、BackgroundThreadAsyncと、MainThreadAsyncという、スレッドを切り替える機能も提供されています。これらを使うと、Taskと似たような使い方ができます。以下、サンプルコードです。

using UnityEngine;

public class ThreadChangeSample : MonoBehaviour
{
    private void Start()
    {
        BackThread();
    }

    async void BackThread()
    {
        Debug.Log($"処理開始");
        await Awaitable.BackgroundThreadAsync();

        Debug.Log($"バックグラウンド切り替え。重い処理開始");
        HeavyTask();
        Debug.Log($"重い処理完了");

        // transform.position = Vector3.zero; // エラー

        await Awaitable.MainThreadAsync();
        Debug.Log($"メインスレッドに帰還");

        transform.position = Vector3.zero; // 実行可能
    }

    void HeavyTask()
    {
        double x = 1;
        for (int i = 0; i < 100000000; i++)
        {
            x += Mathf.Sqrt(i);
        }
    }
}

HeavyTaskに書いた重い処理を、バックグラウンドスレッドで実行するサンプルです。このスクリプトを、任意のゲームオブジェクトにアタッチして実行すると、コンソールに経過が表示されます。

バックグラウンドスレッドで実行

バックグラウンドスレッドに切り替えてから、HeavyTaskを呼び出しているので、その前に表示しているログが2つ表示されます。そして、HeavyTaskの終了が終わったら、残りの「重い処理完了」と「メインスレッドに帰還」が表示されます。システムが停止していないので、Debug.Logを実行したタイミングで、ログが表示されています。

13行目のawait Awaitable.BackgroundThreadAsync();コメントアウトして実行すると、処理が終了するまで、ログが表示されなくなります。

デフォルトのまま実行

HeavyTaskが、メインスレッドで実行されるので、すべての処理が終わるまでコンソールへの表示ができないからです。

最後にメインスレッドに戻していますが、メインスレッドに戻してから実行する処理がなければ、これは不要です。バックグラウンドスレッドで実行されるのは、asyncメソッド内のみです。

バックグラウンドスレッドは使えない機能が多い

便利そうなバックグラウンドスレッドなのですが、Unityの多くの機能が使えません。19行目のコメントアウトを外すと、以下のような例外が出力されます。

バックグラウンドスレッドではいろいろとエラーになる

同じAwaitableクラスのAwaitable.NextFrameAsync()やAwaitable.WaitForSecondsAsync()といった、Unityライフサイクルに関する機能も使えません。そのような機能を使いたい場合は、Awaitable.MainThreadAsync()で、メインスレッドに戻してください。

スレッドの切り替えには1フレームかかる

スレッドの切り替えは、一度awaitで呼び出しもとに処理を返してから、次のフレームで処理を再開させるときに実行されます。瞬時に切り替わらないことを前提に、利用してください。

戻り値を受け取る

Awaitableは、Taskと同様に戻り値を受け取ることができます。戻り値は、非同期メソッドの結果として返します。awaitで待機する必要があるので、非同期メソッドで利用することになります。

ユーザーがYかNのどちらかを押すまで待機して、押されたキーをログに表示する例です。

using UnityEngine;

public class ReturnValue : MonoBehaviour
{
    void Start()
    {
        YorNAsync();
        Debug.Log("Start終了");
    }

    async void YorNAsync()
    {
        Debug.Log("YかNを押してください。");
        string res = await InputYorNAsync();
        Debug.Log($"{res}が押されました。");
    }

    async Awaitable<string> InputYorNAsync()
    {
        Debug.Log($"キー入力待機");
        while (true)
        {
            if (Input.GetKeyDown(KeyCode.Y))
            {
                return "Y";
            }
            else if (Input.GetKeyDown(KeyCode.N))
            {
                return "N";
            }

            await Awaitable.NextFrameAsync();
        }
    }
}
  • 5-7行目:開始したら、YorNAsyncメソッドを呼び出す
  • 11-13行目:「YかNを押してください。」と表示する
  • 14行目:awaitのうしろがメソッドなので、InputYorNAsyncメソッドを呼び出す
  • 18-20行目:「キー入力待機」と表示する
  • キー入力がなければ、32行目まで進む
  • 32行目:awaitで、次のフレームまで待機するので、呼び出し元に制御を返す
  • 14行目:awaitに非同期操作が渡されるので、さらに呼び出し元に制御を戻す
  • 8行目:「Start終了」と表示して、Startメソッドを終える
  • 次のフレームになるまで、処理なし
  • 32行目:次のフレームになったら、32行目から処理再開
  • 21-33行目:while文で、YかNが押されるまで、フレーム更新ごとにループ
  • Yが押されたら
  • 23-25行目:Yを戻り値として返す
  • 14行目:非同期操作が終了して、戻り値としてYが返るので、resに代入
  • 15行目:「Yが押されました。」と表示
  • 16行目:YorNAsyncを終了

以上です。Nが押された時は、23-25行目と同様に27-29行目が実行されて、「Nが押されました。」と表示されます。

「キー入力待機」が、「Start終了」よりも先に表示されています。これにより、awaitに非同期操作が渡されるまでは、通常の同期処理と同様に処理が進むことが確認できます。

待機をキャンセルする

先の例では、YかNを押すまで処理を停止できませんでした。InputYorNAsyncに、処理を抜ける機能を追加することもできますが、Awaitableのインスタンスを使えば、外部から中断させることもできます。

using System;
using UnityEngine;

public class ReturnValue : MonoBehaviour
{
    Awaitable<string> inputYorNAsync;

    void Start()
    {
        YorNAsync();
        Debug.Log("Start終了");
    }

    private void OnDestroy()
    {
        CancelInvoke();
    }

    async void YorNAsync()
    {
        Debug.Log("YかNを押してください。");
        
        inputYorNAsync = InputYorNAsync();
        Invoke(nameof(AbortAwait), 3);

        try
        {
            string res = await inputYorNAsync;
            CancelInvoke();
            Debug.Log($"{res}が押されました。");
        }
        catch (OperationCanceledException e)
        {
            Debug.Log(e);
        }
    }

    void AbortAwait()
    {
        inputYorNAsync?.Cancel();
    }

    async Awaitable<string> InputYorNAsync()
    {
        Debug.Log($"キー入力待機");
        while (true)
        {
            if (Input.GetKeyDown(KeyCode.Y))
            {
                return "Y";
            }
            else if (Input.GetKeyDown(KeyCode.N))
            {
                return "N";
            }

            await Awaitable.NextFrameAsync();
        }
    }
}
  • 6行目:Awaitableインスタンスを保存しておくinputYorNAsyncを定義
  • 23行目:InputYorNAsyncメソッドから返されるAwaitableの非同期操作インスタンスを直にawaitせずに、inputYorNAsyncに代入しておく
  • 43-57行目:非同期メソッドでも、awaitが出てくるまでは通常のメソッドと同様に処理されるので、57行目のawaitまでそのまま処理が進む
  • 57行目:awaitで、次のフレームの待機をはじめたら、呼び出し元に制御を返す
  • 24行目:3秒後に、待機をキャンセルするメソッド呼び出しを設定
  • 28行目:InputYorNAsyncメソッドがreturnするまで、待機を開始して、呼び出しもとのStartメソッドに制御を返す
  • 11行目:「Start終了」と表示して、Startメソッドを終える
  • YかNが押されたら
    • 先の例と同様に、戻り値で文字を返す
    • 29行目:時間切れ処理が呼ばれないように、CancelInvokeメソッドを呼び出して、Invokeをキャンセル
    • 30行目:押された文字を表示する
  • 3秒経過したら
    • 38行目:Invokeで設定した、AbortAwaitメソッドが呼ばれる
    • 40行目:inputYorNAsyncに保存していたAwaitableのインスタンスのCancelメソッドを呼び出して、処理をキャンセルする
    • 32行目:非同期処理をキャンセルすると、OperationCanceledExecptionが発生するので、catchして、エラーになることを防止
    • 34行目:例外の内容を表示。予定の動作なら、表示はしなくてよい

OperationCanceledExeptionをログに表示

Awaitableのインスタンスをキャッシュしておいて、Cancelメソッドを呼べば、待機をキャンセルできます。待機しているawaitでOperationCanceledExceptionが発生するので、try-catchで囲んでおくとよいでしょう。

キャッシュしたAwaitableは再利用できない

Taskのインスタンスは、キャッシュしておいて再利用できます。一方、Awaitableのインスタンスは、パフォーマンス上の理由から、再利用できません。次のようなコードは、エラーになります。

using UnityEngine;

public class UseCache : MonoBehaviour
{
    async void Start()
    {
        Awaitable cache = Awaitable.NextFrameAsync();

        Debug.Log($"実行前");
        await cache;
        Debug.Log($"1フレーム経過");
        await cache;  // ここでエラー
        Debug.Log($"2フレーム経過");
    }
}

2回目の利用時にエラー

シーンの切り替えシーケンス

シーンの読み込みや解放をするSceneManager.LoadSceneAsyncやUnloadSceneAsyncが返すAsyncOperationも、awaitに渡せます。

    async void ChangeScene()
    {
        // シーン読み込み
        await FadeOut();
        await SceneManager.LoadSceneAsync("SceneA", LoadSceneMode.Additive);
        await FadeIn();

        // シーン解放
        await FadeOut();
        await SceneManager.UnloadSceneAsync("SceneA");
        await FadeIn();
    }

AsyncOperationをキャンセル可能にする

AwaitableのFromAsyncOperationに、AsyncOperationのインスタンスとCancellationTokenを渡すと、キャンセル可能なawaitができます。Webアクセスのキャンセルなどに使えそうです。

タスクのキャンセル - .NET | Microsoft Learn

以下、意味はありませんが、シーンの読み込みのキャンセルを試みるサンプルです。

using System;
using System.Threading;
using UnityEngine;
using UnityEngine.SceneManagement;

public class LoadSceneCancel : MonoBehaviour
{
    private void Start()
    {
        var token_source = new CancellationTokenSource();
        CancelableLoadScene(token_source.Token);
        token_source.Cancel();
    }

    async void CancelableLoadScene(CancellationToken token)
    {
        var asyncOperation = SceneManager.LoadSceneAsync("SceneA", LoadSceneMode.Additive);
        var awaitable = Awaitable.FromAsyncOperation(asyncOperation, token);
        try
        {
            await awaitable;
        }
        catch (OperationCanceledException e)
        {
            Debug.LogException(e);
        }
    }
}
  • 10-11行目:CancellationTokenSourceのインスタンスを生成して、Tokenをメソッドに渡して呼び出し
  • 17-18行目:シーンの非同期読み込みを実行して、AsyncOperationと、引数で受け取ったトークンから、awaitableを用意
  • 21行目:シーンの読み込みが完了するのと待機

シーンが読み込まれるより先に、12行目が実行されれば、シーンの読み込み待機がキャンセルされます。処理がキャンセルされたら、OperationCanceledExceptionが発生するので、19-26行目はtry-catchで囲んでいます。

Awaitableのパフォーマンス

Awaitableは、従来のIEnumeratorを使ったコルーチンよりも、パフォーマンスは向上するとのことです。Unity6以降を使うのであれば、IEnumeratorから乗り換えた方がよさそうです。

UpdateやFixedUpdateの代わりにAwaitableのループを使うことは推奨されていません。以下、Unity公式マニュアルから引用します。

Although Unity’s Awaitable class is optimized for performance, you should avoid running hundreds of thousands of concurrent coroutines. Similar to coroutine-based iterators, a behavior with a loop similar to the following example attached to all your game objects is very likely to cause performance problems:

Await support - Unity マニュアルより

Awaitableを使うと、小さいループで処理が書けるため、パフォーマンスがよさそうに見えます。しかし、処理を再開するために状態を保存したり、待機処理から再開できるようにコードを調整したりと、内部的には結構な処理をしています。通常のゲームオブジェクトの更新処理は、UpdateやFixedUpdateに実装しましょう。

Awaitableは、多少のパフォーマンスを犠牲にしても、ややこしい手順をシンプルに書きたい場合や、シナリオの再生処理などの限定された場面で利用します。パフォーマンスを向上させたい場合は、DOTSなどの利用を検討してください。

まとめ

ようやく、asyncとawaitに足を踏み入れました。非同期処理のややこしさが、awaitに集約されているように感じました。疑問に感じたところを自分自身であれこれ試して、ようやく腑に落ちました。

Awaitableクラスは、Unityのライフサイクルにあわせて、async/awaitを利用する機能を提供してくれます。これにより、従来のIEnumeratorを使ったコルーチンは、より効率が良いasync/awaitを使ったものに書き換えられます。

awaitのオペランドとして、処理を再開するための非同期操作を渡すことで、呼び出し元に制御を戻します。これにより、メインスレッドの処理を進められるので、システムが停止しなくなります。awaitした非同期操作が完了したら、awaitの続きの処理を呼び出して、処理を再開します。これにより、メインスレッドだけでも、非同期的な処理が実現できます。

また、スレッドの切り替え機能も持っています。awaitした非同期操作から再開する時に、スレッドを切り替えます。切り替えたスレッドは、非同期メソッドのチェーン内で有効です。バックグラウンドスレッドでは、Unityの主要な機能が使えません。PureなC#や、スレッドセーフな機能だけで処理をします。Unityの機能を使う場合は、メインスレッドに戻します。次のフレームの更新タイミングで、awaitから戻るタイミングでスレッドを切り替えるため、スレッドを切り替えるごとに1フレーム進むことに注意が必要です。

AsyncOperationも、awaitできます。待機をキャンセルするときは、Cancelメソッドを使います。

自分の利用用途としては、IEnumeratorの置き換えがほとんどです。シナリオの制御や、シーンの切り替え、エンドロールやUIのシーケンス処理といったところです。今のところ、特に不足点はなさそうなので、これからビシバシ使っていきたいと考えています。

本記事は、Qiita Advent Calendar 2024. Unity Advent Calendar 2024のシリーズ2の12日目の記事でした。

です。

参考・関連URL