ノートの端の書き残し

UnityやらC#やら。設計が得意かもしれない。

ToUniTaskしたコルーチンの寿命について

コルーチンとasyncの寿命

コルーチンの寿命はコンポーネントに依存

Unityにおいて、以下のコンポーネントを適当なゲームオブジェクトにアタッチすると、コンポーネントが破棄されるとログ出力は止まります。

public class CoroutineSample : MonoBehaviour
{
    private void Start()
    {
        StartCoroutine(LogAndDestroyCoroutine());
    }

    private IEnumerator LogAndDestroyCoroutine()
    {
        StartCoroutine(LogCoroutine()); // ログ出力を開始する
        yield return new WaitForSeconds(3); // 3秒待つ
        Destroy(this); // 自分を破棄する
    }

    private IEnumerator LogCoroutine()
    {
        // 毎フレーム1回ログを出力する
        while(true)
        {
            Debug.Log("ログ");
            yield return null;
        }
    }
}

asyncの寿命はコンポーネントに非依存

一方で、asyncで似たようなコンポーネントを作ると、コンポーネントが破棄された後もログが出力され続けます。

public class AsyncSample : MonoBehaviour
{
    private async void Start()
    {
        LogAsync().Forget(); // ログ出力を開始する
        await UniTask.Delay(3000); // 3秒待つ
        Destroy(this); // 自分を破棄する
    }

    private async UniTask LogAsync()
    {
        var yieldAwaitable = UniTask.Yield();
        // 毎フレーム1回ログを出力する
        while(true)
        {
            Debug.Log("ログ");
            await yieldAwaitable;
        }
    }
}

これはコルーチンを使ってきた人からすると意外なことで、結構バグにつながる違いかと思います。

コルーチンのToUniTask

UniTaskライブラリには、コルーチンをUniTaskとして扱う拡張メソッドが用意されています。
では、以下のコンポーネントをアタッチした場合、コンポーネントが破棄された後ログは出力されるでしょうか。

public class ToUniTaskSample : MonoBehaviour
{
    private async void Start()
    {
        LogCoroutine().ToUniTask(); // ログ出力を開始する
        await UniTask.Delay(3000); // 3秒待つ
        Destroy(this); // 自分を破棄する
    }

    private IEnumerator LogCoroutine()
    {
        // 毎フレーム1回ログを出力する
        while(true)
        {
            Debug.Log("ログ");
            yield return null;
        }
    }
}

答えは、出力されます

従来のコルーチンのようにコンポーネントの破棄に合わせて出力を止めるには、ToUniTaskの引数に、寿命を依存させたいコンポーネント、今なら自分を渡せば良いです。

public class ToUniTaskSample : MonoBehaviour
{
    private async void Start()
    {
        // thisを渡す
        LogCoroutine().ToUniTask(this); // ログ出力を開始する
        await UniTask.Delay(3000); // 3秒待つ
        Destroy(this); // 自分を破棄する
    }

    private IEnumerator LogCoroutine()
    {
        // 毎フレーム1回ログを出力する
        while(true)
        {
            Debug.Log("ログ");
            yield return null;
        }
    }
}

それぞれの寿命の理由

Unityにおけるコルーチンとは

そもそもコルーチンはどのようにして動いているのでしょうか。
一般的に(Unity関係無い文脈において)、コルーチンとは、いろんな作業を少しずつ、中断と再開を繰り返してほぼ同時進行する仕組みを言います。1人の人間がマルチタスクをこなすのと同じです。
その具体的手法として、Unityではイテレータの仕組みと毎フレーム走るPlayerLoopの仕組みを組み合わせて利用しているに過ぎません。
つまり、1フレーム毎に1回MoveNextを呼べば実質非同期処理じゃん、というのがUnityにおけるコルーチンです。(上手いハックだと思います)
ということで、このコルーチンの本質となる1フレーム毎のMoveNextを呼んでいるのは誰か、というのが寿命問題の鍵です。

StartCoroutineした場合

StartCoroutineメソッドが属するそのコンポーネントインスタンスが呼んでいると思われます。(C++部分のコードが非公開ですが、少なくとも、コンポーネントインスタンスに紐づいた挙動をします)なので、そのコンポーネントが破棄されればコルーチンも止まります。

引数無しToUniTaskした場合

PlayerLoopを管理するstaticクラスが呼んでいます。間に色々あって複雑ですが、結果的にはstaticクラスの持つデリゲートに処理を登録しているためです。
このデリゲートが、コルーチンの実行と同じように1フレーム毎にMoveNextを呼んで、擬似的にコルーチンのような処理を行なっています。 そのため、コンポーネントの寿命とか関係ありません。

引数有りToUniTaskした場合

内部的に、引数で渡されたコンポーネントのStartCoroutineでコルーチンを開始しています。そのため、「StartCoroutineした場合」と全く同じで、コンポーネントの寿命に依存します。

結論

引数無しのToUniTaskしたコルーチンはコンポーネントの寿命に依存しません。

ですが大事なのは、コルーチンはそれを走らせている者が存在しており、それが何なのかを知ることです。
それを理解すれば、そもそも「コルーチンの寿命はコンポーネントの寿命に依存する」というのが、間違いとまで言えなくとも、それだけで完全に正しい説明とは言えないこともわかってきますね。
ところでUnityはC++部分の実装を公開してほしいです。

(そして、asyncに慣れてるならコルーチンはもう使わないでいいと思います。)