LoginSignup
41
28

【Unity】コルーチンからUniTaskに乗り換えるときに気をつけるべきところ

Last updated at Posted at 2023-02-26

はじめに

今回はUnityにおけるコルーチンをどうUniTaskに置き換える上で気をつけるべき点を紹介します。

以前、【C#】async/awaitのキャンセル処理まとめ
という記事を書いたのですが、この記事は「一般的なC#の話」となっています。
今回はもうちょっと話を「Unity寄り」かつ「初学者向け」にしています。

対象

対象者

  • Unityのコルーチンは使えるが、async/await,UniTaskはわからないという人

なお、次の話はしません。

  • async/awaitの文法について(触れません)
  • UniTaskが提供するとコルーチン互換の機能について(触れません)

コルーチンからUniTaskへ

コルーチンからUniTaskに乗り換える上での注意点

コルーチンからUniTaskに乗り換える上で何を一番気をつけるべきか。それは「処理の寿命」です。コルーチンはMonoBehaviourに紐づいて動作します。これは寿命管理をサボってもGameObjectの破棄と同時に処理が勝手に停止するということを意味します。そのためコルーチンを使う上で「寿命」を意識したことが無い人も多いかもしれません。

しかし、UniTaskにおいてはGameObjectを破棄しても勝手に処理は止まりません。UniTaskを用いたすべての処理をの寿命を常に意識し、必要に応じて処理を止められるように書く必要があります。

コルーチンをそのままUniTaskに置き換えるとどうなるのか

たとえば次のようなコードがあったとします。

using System.Collections;
using UnityEngine;

namespace Sample.Coroutines
{
    public class CubeMover1 : MonoBehaviour
    {
        private void Start()
        {
            // コルーチンの起動
            StartCoroutine(MoveLoopCoroutine());
        }

        /// <summary>
        /// 指定したポイントを周回し続ける
        /// </summary>
        private IEnumerator MoveLoopCoroutine()
        {
            // 無限にループし続ける
            while (true)
            {
                // (0,0,5)に移動するのを待つ
                yield return MoveToCoroutine(new Vector3(0, 0, 5), 3f);
                // (5,0,5)に移動するのを待つ
                yield return MoveToCoroutine(new Vector3(5, 0, 5), 3f);
                // (5,0,0)に移動するのを待つ
                yield return MoveToCoroutine(new Vector3(5, 0, 0), 3f);
                // (0,0,0)に移動するのを待つ
                yield return MoveToCoroutine(new Vector3(0, 0, 0), 3f);
            }
        }

        /// <summary>
        /// 目的地に向かって一定速度で移動する
        /// </summary>
        private IEnumerator MoveToCoroutine(Vector3 target, float speed)
        {
            // 目的地値に到達するまで無限ループする
            while (true)
            {
                var currentPosition = transform.position;
                var delta = target - currentPosition;
                var distance = delta.magnitude;

                if (distance < speed * Time.deltaTime)
                {
                    // ゴールに十分近いならゴールに移動して終了
                    transform.position = target;
                    // コルーチン終了
                    yield break;
                }
                else
                {
                    // ゴールまで距離があるなら一定速度で移動
                    var direction = delta.normalized;
                    transform.position += direction * (speed * Time.deltaTime);
                    // 1フレーム待機する
                    yield return null;
                }
            }
        }

        private void OnGUI()
        {
            if (GUI.Button(new Rect(0, 0, 150, 50), "Destroy"))
            {
                Destroy(gameObject);
            }
        }
    }
}

Coroutine1.gif

このコードは「基本的にコルーチンは無限ループで動作する(中断を考えない)」「GameObjectが破棄されると同時にコルーチンも停止する」という想定で書かれています。
実際にこの実装は問題なく動作し、Destroyが実行されたとしてもエラーなく処理が止まります。

CoroutineStop.gif


では、これとほぼ同等の処理をUniTask(+ async/await)でほぼそのまま書き直してみます。

using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Sample.Coroutines
{
    public class CubeMover2Bad : MonoBehaviour
    {
        private void Start()
        {
            MoveLoopAsync().Forget();
        }

        /// <summary>
        /// 指定したポイントを周回し続ける
        /// </summary>
        private async UniTask MoveLoopAsync()
        {
            // 無限にループし続ける
            while (true)
            {
                // (0,0,5)に移動するのを待つ
                await MoveToAsync(new Vector3(0, 0, 5), 3f);
                // (5,0,5)に移動するのを待つ
                await MoveToAsync(new Vector3(5, 0, 5), 3f);
                // (5,0,0)に移動するのを待つ
                await MoveToAsync(new Vector3(5, 0, 0), 3f);
                // (0,0,0)に移動するのを待つ
                await MoveToAsync(new Vector3(0, 0, 0), 3f);
            }
        }

        /// <summary>
        /// 目的地に向かって一定速度で移動する
        /// </summary>
        private async UniTask MoveToAsync(Vector3 target, float speed)
        {
            // 目的地値に到達するまで無限ループする
            while (true)
            {
                var currentPosition = transform.position;
                var delta = target - currentPosition;
                var distance = delta.magnitude;

                if (distance < speed * Time.deltaTime)
                {
                    // ゴールに十分近いならゴールに移動して終了
                    transform.position = target;
                    // 終了
                    return;
                }
                else
                {
                    // ゴールまで距離があるなら一定速度で移動
                    var direction = delta.normalized;
                    transform.position += direction * (speed * Time.deltaTime);
                    // 1フレーム待機する
                    await UniTask.Yield();
                }
            }
        }

        private void OnGUI()
        {
            if (GUI.Button(new Rect(0, 0, 150, 50), "Destroy"))
            {
                Destroy(gameObject);
            }
        }
    }
}

この実装はコルーチンをただ機械的にUniTaskに置換しただけになります。
そしてこの実装でも一見問題なく動作します。

UniTask1.gif

しかし、この実装ではGameObjectが破棄されたときに問題が発生します。

UniTaskError.gif

StackTrace.png

このように、GameObjectが破棄されたタイミングで例外が発生してしまいました。
内容としては「MissingReferenceException」、つまりは「対象としているオブジェクトが無くなった」です。

エラーはMoveToAsyncの「transform.position」にアクセスした瞬間に発生しました。

private async UniTask MoveToAsync(Vector3 target, float speed)
{
    // 目的地値に到達するまで無限ループする
    while (true)
    {
        // ここの「transform」が破棄されたことが原因
        var currentPosition = transform.position;
        var delta = target - currentPosition;
        var distance = delta.magnitude;

コルーチンはMonoBehaviourが破棄された(GameObjectがDestroyされた)タイミングで実行自体が停止します。

しかしUniTaskは処理そのものはGameObjectと独立して動作しています。GameObjectがDestroyされたとしても、UniTaskの処理は関係なく継続してしまいます。そのため「破棄されたオブジェクトにアクセスしてしまい、エラーが起きた」という状態になってしまいました。

対策(寿命をどう制御すればいいのか)

では、コルーチンで書かれたコードをUniTaskに置き換えるときにどうしたらいいのか。(またはこれからUniTaskを使ってコードを書こうとしている人が、何に気をつければよいのか)

それは「処理の中断」をできるようにすることです。実行中のUniTaskの処理に対して「今GameObjectが破棄されたからすぐ処理を止めて!!!!」ということを伝えられる仕組みにしておく必要があります。

そのために用いると便利なオブジェクトがCancellationTokenです。

CancellationToken

CancellationTokenはC#標準で用意されているオブジェクトで、名前のとおり「処理を中止(キャンセル)したいときに使うトークン」です。

CancellationTokenを作る方法はいくつかありますが、一旦ここでは「コルーチンと同等の機能を持たせる」ことに的を絞ります。
コルーチンと同様に「GameObjectが破棄されたら処理が止まってほしい」としたい場合、this.GetCancellationTokenOnDestroy()を使うのがオススメです。

private void Start()
{
    // CancellationTokenの取得
    CancellationToken ct = this.GetCancellationTokenOnDestroy();

    // この↓に処理が続くとして
    // ...
}

this.GetCancellationTokenOnDestroy()はUniTaskが提供する拡張メソッドで、「このGameObjectの破棄時にキャンセル扱いになるCancellationToken」を作ってくれます。

そのためコルーチンと同等の挙動を持たせたいなら、ひとまずこのthis.GetCancellationTokenOnDestroy()を使いましょう。

備考: destroyCancellationToken

Unity 2022.2からdestroyCancellationTokenMonoBehaviourに追加されたのでこっちを使ってもいいです。

private void Start()
{
    // CancellationTokenの取得
    CancellationToken ct = destroyCancellationToken;

    // この↓に処理が続くとして
    // ...
}

ちなみにthis.GetCancellationTokenOnDestroy()を呼び出した場合destroyCancellationTokenが使えるなら勝手にそっちを使うようになっています。

CancellationTokenを使った処理の中断

CancellationTokenを使った処理の中断方法は2パターンあります。

A. CancellationTokenをawaitする相手に渡す

一番王道かつ、間違い無いのがこの手法です。
awaitするときにCancellationTokenを相手に渡すことを徹底するというものです。
async/await本来のキャンセルに則った手法のため間違いはありません。

using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Sample.Coroutines
{
    public class CubeMover2A : MonoBehaviour
    {
        private void Start()
        {
            // CancellationTokenの取得
            var ct = this.GetCancellationTokenOnDestroy();

            MoveLoopAsync(ct).Forget();
        }

        /// <summary>
        /// 指定したポイントを周回し続ける
        /// </summary>
        private async UniTask MoveLoopAsync(CancellationToken ct)
        {
            // 無限にループし続ける
            while (true)
            {
                // (0,0,5)に移動するのを待つ
                await MoveToAsync(new Vector3(0, 0, 5), 3f, ct);
                // (5,0,5)に移動するのを待つ
                await MoveToAsync(new Vector3(5, 0, 5), 3f, ct);
                // (5,0,0)に移動するのを待つ
                await MoveToAsync(new Vector3(5, 0, 0), 3f, ct);
                // (0,0,0)に移動するのを待つ
                await MoveToAsync(new Vector3(0, 0, 0), 3f, ct);
            }
        }

        /// <summary>
        /// 目的地に向かって一定速度で移動する
        /// </summary>
        private async UniTask MoveToAsync(Vector3 target, float speed, CancellationToken ct)
        {
            // 目的地値に到達するまで無限ループする
            while (true)
            {
                var currentPosition = transform.position;
                var delta = target - currentPosition;
                var distance = delta.magnitude;

                if (distance < speed * Time.deltaTime)
                {
                    // ゴールに十分近いならゴールに移動して終了
                    transform.position = target;
                    // 終了
                    return;
                }
                else
                {
                    // ゴールまで距離があるなら一定速度で移動
                    var direction = delta.normalized;
                    transform.position += direction * (speed * Time.deltaTime);

                    // !!ここ!!
                    await UniTask.Yield(ct);
                }
            }
        }

        private void OnGUI()
        {
            if (GUI.Button(new Rect(0, 0, 150, 50), "Destroy"))
            {
                Destroy(gameObject);
            }
        }
    }
}

わかりにくいかもしれませんが、一番重要なのはUniTask.YieldCancellationTokenを渡しているところです。

await UniTask.Yield(ct);

こうすることで、GameObjectが破棄されたときにキャンセル例外が発行されて自動的にUniTaskの処理が停止します。
(なおこのときに発行される例外OperationCancelledExceptionはUniTaskが自動的にもみ消してくれるため、コンソールには表示されないようになっています)

この辺の仕組みはかなりややこしいので、詳しく知りたい人はこの記事をご覧ください。

B. CancellationTokenをみて手動で止める

Aの手法がasync/await的に正しい止め方ではあるんですが、こちらの手法でも処理は停止できます。
どうするかというと、すごく単純で「CancellationTokenをみて処理を止める」というだけです。

using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Sample.Coroutines
{
    public class CubeMover2B : MonoBehaviour
    {
        private void Start()
        {
            var ct = this.GetCancellationTokenOnDestroy();

            MoveLoopAsync(ct).Forget();
        }

        /// <summary>
        /// 指定したポイントを周回し続ける
        /// </summary>
        private async UniTask MoveLoopAsync(CancellationToken ct)
        {
            // キャンセルされるまで実行し続ける
            while (!ct.IsCancellationRequested)
            {
                // (0,0,5)に移動するのを待つ
                await MoveToAsync(new Vector3(0, 0, 5), 3f, ct);
                
                // キャンセルされていたら終わる
                if(ct.IsCancellationRequested) return;
                
                // (5,0,5)に移動するのを待つ
                await MoveToAsync(new Vector3(5, 0, 5), 3f, ct);
                
                // キャンセルされていたら終わる
                if(ct.IsCancellationRequested) return;

                // (5,0,0)に移動するのを待つ
                await MoveToAsync(new Vector3(5, 0, 0), 3f, ct);
                
                // キャンセルされていたら終わる
                if(ct.IsCancellationRequested) return;

                // (0,0,0)に移動するのを待つ
                await MoveToAsync(new Vector3(0, 0, 0), 3f, ct);
            }
        }

        /// <summary>
        /// 目的地に向かって一定速度で移動する
        /// </summary>
        private async UniTask MoveToAsync(Vector3 target, float speed, CancellationToken ct)
        {
            // 目的地値に到達する or キャンセルされるまで実行し続ける
            while (!ct.IsCancellationRequested)
            {
                var currentPosition = transform.position;
                var delta = target - currentPosition;
                var distance = delta.magnitude;

                if (distance < speed * Time.deltaTime)
                {
                    // ゴールに十分近いならゴールに移動して終了
                    transform.position = target;
                    // 終了
                    return;
                }
                else
                {
                    // ゴールまで距離があるなら一定速度で移動
                    var direction = delta.normalized;
                    transform.position += direction * (speed * Time.deltaTime);
                    // 1フレーム待機する
                    await UniTask.Yield();
                }
            }
        }

        private void OnGUI()
        {
            if (GUI.Button(new Rect(0, 0, 150, 50), "Destroy"))
            {
                Destroy(gameObject);
            }
        }
    }
}

注目するべきは、while文の部分です。

// キャンセルされるまで実行し続ける
while (!ct.IsCancellationRequested)
{
    // (0,0,5)に移動するのを待つ
    await MoveToAsync(new Vector3(0, 0, 5), 3f, ct);
    
    // キャンセルされていたら終わる
    if(ct.IsCancellationRequested) return;
    
    // (5,0,5)に移動するのを待つ
    await MoveToAsync(new Vector3(5, 0, 5), 3f, ct);
    
    // キャンセルされていたら終わる
    if(ct.IsCancellationRequested) return;

    // (5,0,0)に移動するのを待つ
    await MoveToAsync(new Vector3(5, 0, 0), 3f, ct);
    
    // キャンセルされていたら終わる
    if(ct.IsCancellationRequested) return;

    // (0,0,0)に移動するのを待つ
    await MoveToAsync(new Vector3(0, 0, 0), 3f, ct);
}
}
// 目的地値に到達する or キャンセルされるまで実行し続ける
while (!ct.IsCancellationRequested)
{
    var currentPosition = transform.position;
    var delta = target - currentPosition;
    var distance = delta.magnitude;

    if (distance < speed * Time.deltaTime)
    {
        // ゴールに十分近いならゴールに移動して終了
        transform.position = target;
        // 終了
        return;
    }
    else
    {
        // ゴールまで距離があるなら一定速度で移動
        var direction = delta.normalized;
        transform.position += direction * (speed * Time.deltaTime);
        // 1フレーム待機する
        await UniTask.Yield();
    }
}

CancellationToken.IsCancellationRequested(bool値)をみることで、処理のキャンセルが要求されているかを調べることができます。これをみて処理を早期returnしたり、ループを止めることで処理を中断するという手法です。

Aの方法と比べると単純でわかりやすいし、キャンセル時に例外が飛ばないので若干低負荷です。

AとBどっちを使えばいいのか

  • 基本的にはAの手法(await対象にCancellationTokenを渡す)の方が安全
  • Bの手法(CancellationTokenをみて手動で止める)はパフォーマンス的に若干軽いが、即座に止まってくれるわけではない

Aの手法は処理の中断時に例外(OperationCanceledException)が飛びます。そのため勝手に大域脱出して処理が止まります。そのためAはキャンセル要求をすると基本的にほぼ即座に処理が止まってくれます(実装による場合もあるが、UniTask.Delayなどはすぐ止まってくれる)。

// 1秒ごとに1mずつワープする
private async UniTask WarpIntervalAsync(CancellationToken token)
{
    while (true)
    {
        // キャンセル要求時に即座に停止してくれる
        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);

        // キャンセル要求時はここに到達しない(はず)
        transform.position += Vector3.one;
    }
}

一方のBは「キャンセル時は強制的に処理を終了させる(正常終了とする)」というやり方です。
そのため中断時に例外が飛ばないためパフォーマンス的に若干軽くなります

しかしこの手法は「CancellationTokenを手動で評価するまで処理が中断できない」「自分でメソッドから都度returnする必要がある」という欠点もあります。

たとえば、次のようなコードを書いてしまうと、MissingReferenceExceptionが発生してしまいます。

// 1秒ごとに1mずつワープする
private async UniTask WarpIntervalAsync(CancellationToken token)
{
    while (!token.IsCancellationRequested)
    {
        // キャンセルが要求されててもこの処理はやりきってしまう
        await UniTask.Delay(TimeSpan.FromSeconds(1));

        // キャンセル要求時でもここを実行してしまう可能性が非常に高い
        transform.position += Vector3.one;
    }
}

この実装をどうしてもBの手法で止めたいのなら、「awaitの順序をwhileの最後に持ってくる」や「await直後にCancellationTokenを評価してbreakする」などが必要でしょう。


まとめるとこうなります。

  • Aの方が汎用的かつ間違いは起きにくい

    • ただし中断時に発行される例外と付き合う必要あり
      • C#的には「この例外をちゃんとハンドリングしよう」が正しい
    • 例外で大域脱出できるので逆にキャンセル処理が楽になることもある
  • Bの方はシンプルである意味でわかりやすい

    • 中断時に例外が飛ばないので若干軽い
      • 例外のハンドリングを意識する必要がない
    • しかし、確実に処理が止まるかどうかは書き方次第になってしまう

覚えることが多く煩雑だが事故りにくいのはA。シンプルでとっつきやすいが事故る可能性があるのはB。という感じでしょうか。
どっちが初心者向けとも言い難いです。

AとBの併用はよいのか

AとBを併用すること自体は問題ありません。
ですが、Bの方が終了時に例外が出ないという点で低コストではあるので、「Bのみで書いて問題ないほど処理がシンプルなら、Bで書いたほうがよい」です。メソッドの内容が複雑になるなら大域脱出を使ったほうがよいのでAも混ぜて書いてOKです。

ただ、AとBをごちゃまぜに書くと「キャンセルしたときにキャンセル例外が飛んだり飛ばなかったりする」という状況になってハンドリングがややこしくなる場合があるので注意。「基本はAを使う。すごく単純な場合のみBを使う。」みたいなルールを設けたほうがよいかも。

// 併用パターン(この例だとBだけに寄せた方がいいかも)
private async UniTask HogeAsync(CancellationToken token)
{
    // while文に指定
    while (!token.IsCancellationRequested)
    {
        transform.position += Vector3.forward * Time.deltaTime;
        // awaitにも渡す
        await UniTask.Yield(token);
    }
}

まとめ

  • コルーチンとUniTask(async/await)の記法は似ている
    • ただし機械的に置換すればよいわけではない
  • コルーチンは勝手に止まるが、UniTaskは止まってくれない
    • GameObjectを破棄すればコルーチンは勝手にとまる
    • `UniTaskは常に手動で止める必要がある
  • UniTask(async/await)を止めるにはCancellationTokenを使うのが一般的
    • MonoBehaviour上ではthis.GetCancellationTokenOnDestroy()を使うとよい
  • CancellationTokenでUniTaskを止める方法は2つある
    • A: 「await対象にCancellationTokenを渡していく」
    • B: 「CancellationTokenを手動でチェックして処理を止める」
      • シンプルでわかりやすいが、事故る可能性も高い
      • パフォーマンス的にはAより有利ではある
    • AとBのどっちが初心者向けか、とかは言い難い
      • 両方覚えておいて状況に応じて使い分ける、がおそらく正解
      • 迷ったらとりあえず「Aの手法」で書いた方がよい

以下ポエム

この記事を書こうと思った発端は「初心者にいきなりUniTaskを教えるべきか、それともコルーチンから触らせるべきか」で議論が起きたからです。

コルーチンよりもUniTaskの方が圧倒的に機能が多く便利で汎用性も高いため、どっちか片方しか触らないのであれば「UniTask」を選ぶでしょう。

ですがUniTaskは学習する要素が多く、とくに「キャンセル周り」が鬼門だと思っています。一方のコルーチンは「適当に書いてもGameObjectが破棄されたり、シーン遷移したら止まる」という性質があります。そのためコルーチンは初心者が書いてもキャンセル周りで事故が起きにくく、安心感はあります。

「学習コストが若干高く挙動を理解するまでが大変なUniTask」か「とりあえず雑に書いても動くし事故も起きにくいコルーチン」のどちらかを初心者に教えるかと言われたら、自分は「コルーチン」かなぁと考えてはいました。(そもそもUniTaskを多用したゴリゴリの非同期処理を初心者が必要とするとは思えず、火力過剰になるのでまずはコルーチンからで良いのでは?とも)

とはいえど「コルーチンから触り始めたとしても、最終的にはUniTaskに乗り換えてほしい」という気持ちです。何だかんだでコルーチンって機能不足なので、凝ったことやろうとすると途端に辛くなるので。そうなったときに(コルーチンで覚えたことを活かして)UniTask(async/await)にステップアップしてほしいなと。

その時の助けになるんじゃないかな?という意図でこの記事を書きました。

追記

Unity 2023.1からAwaitable APIが追加されるのでそっちも気になる所です。

41
28
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
28