LoginSignup
13
7

More than 1 year has passed since last update.

【Unity】UniTask Coroutineとの違いとキャンセル処理の挙動

Last updated at Posted at 2021-05-08

UniTaskとは?

Unityでasync/awaitを使う場合ほぼ必須のライブラリです。

ライセンス : The MIT License (MIT)
著作者 : Yoshifumi Kawai / Cysharp, Inc.

Coroutineとの違い

非同期処理する上での定番Coroutineとの置き換えを検討すると思いますが、多少挙動が違うので理解が必要です。

基本的な挙動の比較

Coroutine UniTask
GameObjectがDestroyされると止まる GameObjectがDestroyされても止まらない
GameObject.SetActive(false)されると止まる GameObject.SetActive(false)されても止まらない
MonoBehaviourからしか呼べない MonoBehaviourでは無くても呼べる
StopCoroutineで停止 CancellationTokenで停止
StopCoroutineで子も停止 CancellationTokenで子は停止しない(停止させる方法は後述)
停止時例外を吐かない awaitした場合のみ停止時例外を吐く

UniTaskをCoroutineの挙動に近づける

GameObjectがGameObject.SetActive(false)、Destroyされると止まるようにしたい

DestroyはGetCancellationTokenOnDestroy()を使うのが一番簡単です。

var token = this.GetCancellationTokenOnDestroy();
await UniTask.DelayFrame(1000, cancellationToken: token);

CancellationTokenSourceを使いOnDestroy()内で明示的に停止する事も可能です。
GameObject.SetActive(false)時に止めたい場合は、OnDisableに記述します。
何もしないとawait時、例外が発生するので注意してください。解決方法は後述します。


var cts = new CancellationTokenSource();

async void Start()
{
    await UniTask.DelayFrame(1000, cancellationToken: cts.Token);
}

void OnDestroy()
{
    cts?.Cancel();
    cts?.Dispose();
}

停止する際は子も停止してほしい

子にCancellationTokenを渡す事で止まってくれます。

UniTaskを使う際はCancellationTokenを引き回す必要があるので必ず引数にCancellationTokenを取るべきです。

async void Start()
{
    var cts = new CancellationTokenSource();
    // 0.5f後にキャンセル
    Observable.Timer(TimeSpan.FromSeconds(0.5f))
            .Subscribe(_ => cts.Cancel());

    await Simple(cts.Token);
}

async UniTask Simple(CancellationToken token)
{
    await UniTask.Delay(1000, cancellationToken: token); // 毎回tokenを渡す
}

// await中に停止するとキャンセルした旨の例外が発生する
// OperationCanceledException: The operation was canceled.

await中の停止時に例外を発生させたくない

例外を無視しても支障が無い事を前提に解説します。
処理中にオブジェクトが削除されるなど、停止する可能性がある場合、例外が発生した以降の処理は走りませんので必ず処理する必要があります。

[方法1] try-catch を使う

▼ 呼び出し元がキャンセルされた事を知りたい場合
async void Start()
{
    stopwatch.Start();
    var cts = new CancellationTokenSource();
    // 0.5f後にキャンセル
    Observable.Timer(TimeSpan.FromSeconds(0.5f))
        .Subscribe(_ => cts.Cancel());

    try
    {
        await Simple(cts.Token);
    }
    catch (OperationCanceledException)
    {
        // 例外が発生するとここが実行される。
        // 何か処理する必要があればここに記述
        Debug.Log("キャンセルされました");
    }

    Debug.Log("Completed");
}

// try-catchで処理しているのでawait中に停止しても例外は発生しません
// [Console]
// "キャンセルされました"
// "Completed"
▼ 呼び出し元がキャンセルされた事を知る必要がない。且つ 呼び出し先でキャンセルされた事を知りたい場合
async void Start()
{
    var cts = new CancellationTokenSource();
    // 0.5f後にキャンセル
    Observable.Timer(TimeSpan.FromSeconds(0.5f))
            .Subscribe(_ => cts.Cancel());

    await Simple(cts.Token);

    Debug.Log("Completed");
}

async UniTask Simple(CancellationToken token)
{
    try
    {
        await UniTask.Delay(1000, cancellationToken: token);
        // 0.5fでキャンセルした場合、ここ以降は走らない
        await UniTask.Delay(1000, cancellationToken: token);
    }
    catch (OperationCanceledException)
    {
        // 例外が発生するとここが実行される。
        // 何か処理する必要があればここに記述
        Debug.Log("キャンセルされました");
    }

    // これ以降に処理があれば走るので走らせたくない場合は、フラグをチェック
    if(token.IsCancellationRequested)
    {
        return; 
    }

    // (略)

// try-catchで処理しているのでawait中に停止しても例外は発生しません
// [Console]
// "キャンセルされました"
// "Completed"
▼ 呼び出し元、呼び出し先どちらもキャンセルされた事を知りたい場合
async void Start()
{
    var cts = new CancellationTokenSource();
    // 0.5f後にキャンセル
    Observable.Timer(TimeSpan.FromSeconds(0.5f))
        .Subscribe(_ => cts.Cancel());

    try
    {
        await Simple(cts.Token);
    }
    catch (OperationCanceledException)
    {
        // 例外が発生するとここが実行される。
        // 何か処理する必要があればここに記述
        Debug.Log("キャンセルされました");
    }

    Debug.Log("Completed");
}

async UniTask Simple(CancellationToken token)
{
    try
    {
        await UniTask.Delay(1000, cancellationToken: token);
        // 0.5fでキャンセルした場合、ここ以降は走らない
        await UniTask.Delay(1000, cancellationToken: token); 
    }
    catch (OperationCanceledException)
    {
        // 例外が発生するとここが実行される。
        // 何か処理する必要があればここで処理
        Debug.Log("Simple - キャンセルされました");
        // 呼び出し元に例外を投げる
        throw;
    }

    // キャンセルされた場合、throwしてるのでここ以降処理は走らない
}

// try-catchで処理しているのでawait中に停止しても例外は発生しません
// [Console]
// "Simple - キャンセルされました"
// "キャンセルされました"
// "Completed"

[方法2] SuppressCancellationThrow()を使う

SuppressCancellationThrow()で例外が発生しなくなる代わりにパフォーマンスが向上し、キャンセル確認が可能なフラグが返されます。

  • 使用の際は呼び出しの大元で使ってください。それ以外での利用は、明確な意図を以って使用するのをおすすめします。
async void Start()
{
    var cts = new CancellationTokenSource();
    // 0.5f後にキャンセル
    Observable.Timer(TimeSpan.FromSeconds(0.5f))
            .Subscribe(_ => cts.Cancel());

    // SuppressCancellationThrow()を追加するとキャンセルを判定できるフラグが使える
    var isCanceled = await Simple(cts.Token).SuppressCancellationThrow();
    if (isCanceled)
    {
        Debug.Log("キャンセルされました。");
    }

    Debug.Log("Completed");
}

async UniTask Simple(CancellationToken token)
{
    await UniTask.Delay(1000, cancellationToken: token);
    // 0.5fでキャンセルした場合、ここ以降は走らない
    await UniTask.Delay(1000, cancellationToken: token); 
}

// SuppressCancellationThrowで処理しているのでawait中に停止しても例外は発生しません
// [Console]
// "キャンセルされました"
// "Completed"

CancellationTokenをリンクさせる

CancellationTokenをリンクさせたいシチュエーションがあります。

例えば、「ゲームオブジェクトが破棄されるタイミング」+「何かの処理をキャンセル」する場合です。

CreateLinkedTokenSourceを使う事で、どちらか一方のキャンセルが呼ばれると止まってくれます。

var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(destroyToken, actionToken);

[サンプル] キャンセル + 処理中にオブジェクトを削除する

処理中にキャンセルしたり、オブジェクトを削除しても問題無く動作させるコードです。

GetCancellationTokenOnDestroy()CreateLinkedTokenSourceを併用する事で楽に書けます。

ただ一点分からないのは、使用完了したトークンの扱いです。とりあえず今回はすべてDispose()するように書きました。

オブジェクト

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

/// <summary>
/// UniTaskObjectTestから生成されるオブジェクト
/// </summary>
public sealed class UniTaskObjectTest_Object : MonoBehaviour
{
    CancellationToken destroyToken;

    void Awake()
    {
        destroyToken = this.GetCancellationTokenOnDestroy();
    }

    public async UniTask FooAsync(int id, CancellationToken token)
    {
        var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(destroyToken, token);

        try
        {
            Debug.Log($"FooAsync {id} A.");
            await UniTask.Delay(1000, cancellationToken: linkedTokenSource.Token);
            Debug.Log($"FooAsync {id} B.");
            await UniTask.Delay(1000, cancellationToken: linkedTokenSource.Token);
            Debug.Log($"FooAsync {id} Completed.");
            linkedTokenSource.Dispose();
        }
        catch (OperationCanceledException)
        {
            linkedTokenSource.Dispose();
            if (token.IsCancellationRequested)
            {
                Debug.Log($"FooAsync {id} Canceled.");
            }
            else if (destroyToken.IsCancellationRequested)
            {
                Debug.Log($"FooAsync {id} Destroyed.");
            }
            throw;
        }
    }
}

管理

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

/// <summary>
/// 処理中にキャンセル + 処理中にオブジェクトを削除する
/// </summary>
public sealed class UniTaskObjectTest : MonoBehaviour
{
    async void Start()
    {
        var obj = CreateObject();
        var destroyToken = this.GetCancellationTokenOnDestroy();

        // 処理中にオブジェクトを削除
        {
            Observable.Timer(TimeSpan.FromSeconds(1.5f))
                .Subscribe(_ =>
                {
                    Debug.Log("Destroy Object.");
                    Destroy(obj.gameObject);
                })
                .AddTo(this);
        }

        // 処理中にこのオブジェクトを削除
        // {
        //     Observable.Timer(TimeSpan.FromSeconds(2f))
        //         .Subscribe(_ =>
        //         {
        //             Debug.Log("Destroy Me.");
        //             Destroy(gameObject);
        //         })
        //         .AddTo(this);
        // }

        // 処理途中にキャンセル
        {
            Debug.Log("Start RepeatCancel.");
            await RepeatCancel(obj, destroyToken);
            Debug.Log("All Completed.");
        }
    }

    /// <summary>
    /// 処理途中にキャンセル
    /// キャンセルされてもすべての処理が終われば正常終了という扱いにする
    /// オブジェクトが削除されても正常終了という扱いにする
    /// </summary>
    /// <param name="obj"></param>
    /// <param name="token"></param>
    /// <returns></returns>
    async UniTask RepeatCancel(UniTaskObjectTest_Object obj, CancellationToken token)
    {
        // 3回繰り返す
        for (var i = 0; i < 3; i++)
        {
            // 渡されたトークンがキャンセルされていれば終了
            if (token.IsCancellationRequested)
            {
                break;
            }

            var id = i + 1;
            // 00秒後にキャンセルする用
            var ctsForCancel = new CancellationTokenSource();
            // トークンをリンクする
            var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ctsForCancel.Token, token);

            // 1秒後にキャンセルするタイマー
            Observable.Timer(TimeSpan.FromSeconds(1))
                .Subscribe(_ =>
                {
                    Debug.Log($"FooAsync {id} Cancel.");
                    linkedTokenSource?.Cancel();
                })
                .AddTo(linkedTokenSource.Token); // UniRxにCancellationTokenを渡す事ができる

            try
            {
                Debug.Log($"FooAsync {id} Start -------");
                await obj.FooAsync(id, linkedTokenSource.Token);
                linkedTokenSource.Dispose();
            }
            catch (OperationCanceledException)
            {
                Debug.Log($"FooAsync {id} Happened.");

                // オブジェクトが削除された場合、終了する
                if (obj == default)
                {
                    linkedTokenSource.Cancel();
                    linkedTokenSource.Dispose();
                    break;
                }
            }
        }
    }

    UniTaskObjectTest_Object CreateObject()
    {
        Debug.Log("Create Object");
        return new GameObject("UniTaskCancelTestObject").AddComponent<UniTaskObjectTest_Object>();
    }
}

キャンセル処理の挙動テスト

[テスト1] await中にキャンセルした場合

  • [関数内] 次の処理まで進まず止まる
  • [関数外] 次の処理まで進まず止まる
  • キャンセルした旨の例外が発生する
async void Start()
{
    stopwatch.Start();
    var token = new CancellationTokenSource();

    // 0.5fで停止する
    Observable.Timer(TimeSpan.FromSeconds(0.5f))
        .Subscribe(_ => token.Cancel());

    await Simple(token.Token);
    await Simple(token.Token);
    stopwatch.Stop();
}

async UniTask Simple(CancellationToken token)
{
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple Start Token:{token.IsCancellationRequested}");
    await UniTask.Delay(1000, cancellationToken: token);
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple 2 Token:{token.IsCancellationRequested}");
    await UniTask.Delay(1000, cancellationToken: token);
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple End Token:{token.IsCancellationRequested}");
}

// Console Log
// [0] Simple Start Token:False
// OperationCanceledException: The operation was canceled.

[テスト2] await中にキャンセルした場合 + 戻り値がある

テスト1と同じ挙動

//(省略) メソッド名をWithReturnValueにした以外[テスト1]と同じ

async UniTask<int> WithReturnValue(CancellationToken token)
{
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] WithReturnValue Start Token:{token.IsCancellationRequested}");
    await UniTask.Delay(1000, cancellationToken: token);
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] WithReturnValue 2 Token:{token.IsCancellationRequested}");
    await UniTask.Delay(1000, cancellationToken: token);
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] WithReturnValue End Token:{token.IsCancellationRequested}");

    return 100;
}

// Console Log
// [0] WithReturnValue Start Token:False
// OperationCanceledException: The operation was canceled.

[テスト3] キャンセルしたトークンを引数に渡した場合

  • テスト1と同じ挙動
  • キャンセルされていてもawaitまでの処理は走る。走ってほしくない場合はCancellationToken.IsCancellationRequestedフラグを見て判断する
async void Start()
{
    stopwatch.Start();
    var token = new CancellationTokenSource();
    // 即キャンセル
    token.Cancel();
    await Simple(token.Token);
    stopwatch.Stop();
}

async UniTask Simple(CancellationToken token)
{
    // ※ token.IsCancellationRequested == true
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple Start Token:{token.IsCancellationRequested}");
    // ※ ここまでは走る
    await UniTask.Delay(1000, cancellationToken: token);
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple 2 Token:{token.IsCancellationRequested}");
    await UniTask.Delay(1000, cancellationToken: token);
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple End Token:{token.IsCancellationRequested}");
}

// Console Log
// [0] Simple Start Token:True
// OperationCanceledException: The operation was canceled.

[テスト4] await中にシーンのロードをした場合

・ 処理は止まらず次のシーンでも完了まで処理が走り続ける

static bool isFirstTime = true;

async void Start()
{
    if (!isFirstTime)
    {
        return;
    }
    isFirstTime = false;

    stopwatch.Start();
    // シーンをリロード
    Observable.Timer(TimeSpan.FromSeconds(0.5f))
        .Subscribe(_ => SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex));

    var token = new CancellationTokenSource();
    VoidWithReference(transform, token.Token).Forget();
}

async UniTaskVoid VoidWithReference(Transform tr, CancellationToken token)
{
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] VoidWithReference Start Token:{token.IsCancellationRequested}");
    Debug.Log(tr);
    await UniTask.Delay(1000, cancellationToken: token);
    // ※ここ以降次のシーンで実行される
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] VoidWithReference 2 Token:{token.IsCancellationRequested}");
    Debug.Log(tr);
    await UniTask.Delay(1000, cancellationToken: token);
    Debug.Log($"[{stopwatch.Elapsed.Seconds}] VoidWithReference End Token:{token.IsCancellationRequested}");
    Debug.Log(tr);
}

// Console
// [0] VoidWithReference Start Token:False
// Main Camera (UnityEngine.Transform)
// [0] VoidWithReference 2 Token:False
// null
// [0] VoidWithReference End Token:False
// null

[テスト5] await中にシーンのロードをした場合 + Cancel + Dispose

  • 次のシーンに持ち越さず完全に処理は止まる
  • Dispose()を呼ぶだけではキャンセルされない。必ずCancel()を呼ぶ
CancellationTokenSource token = new CancellationTokenSource();

// (省略)tokenを外に出した以外は[テスト4]と同じ

// オブジェクトの破棄時に呼ぶ
void OnDestroy()
{
    token?.Cancel();
    token?.Dispose();
}

// Console
// [0] VoidWithReference Start Token:False
// Main Camera (UnityEngine.Transform)
// ※ awaitしていないので例外は発生しない

基本的な使用方法など

下記記事が大変参考になりますのでオススメです。

※ 記事内ではUnirx.Asyncというnamespaceを定義していますが、現バージョンではCysharp.Threading.Tasksというnamespaceに変更されています。

UniTask機能紹介
https://qiita.com/toRisouP/items/4445b6b9bf00e49eb147

13
7
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
13
7