26
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UniTask 処理のタイムアウトの書き方 まとめ

Last updated at Posted at 2023-02-10

はじめに

今回はUniTaskを使って「タイムアウト処理」を書く方法を紹介します。

先に結論

TimeoutControllerを使おう

「タイムアウト」とは

ここでいうタイムアウトとは、「処理が規定時間内に終わらなかったのでasync/awaitによる非同期処理をキャンセルする」という意味です。

しかし、こちらの記事で「キャンセルには2パターン意味がある」と説明しました。

  • await自体のキャンセル
  • awaitしている対象が実行している処理自体のキャンセル

UniTaskでタイムアウト処理を書くとき、「この書き方だと何がキャンセルされるのか」をしっかり意識しておく必要があります。

UniTaskで「タイムアウト」の実現方法

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

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

namespace Timeouts
{
    public class MoveSample : MonoBehaviour
    {
        private async UniTaskVoid Start()
        {
            transform.position = Vector3.zero;

            var token = this.GetCancellationTokenOnDestroy();

            Debug.Log("移動開始!");

            await MoveAsync(new Vector3(0, 0, 100), token);

            Debug.Log("移動終了!");
        }

        /// <summary>
        /// オブジェクトが対象座標に到着するまで移動させる
        /// </summary>
        private async UniTask MoveAsync(Vector3 targetPosition, CancellationToken ct)
        {
            while (true)
            {
                // 座標の差分
                var deltaPosition = (targetPosition - transform.position);

                // 0.1m以内に近づいていたら終了
                if (deltaPosition.magnitude < 0.1f) return;

                // 移動速度
                var moveSpeed = 1.0f;
                // 移動方向
                var direction = deltaPosition.normalized;

                // 移動させる
                transform.position += direction * moveSpeed * Time.deltaTime;

                // 1F待つ
                await UniTask.Yield(ct);
            }
        }
    }
}

このコードは「オブジェクトを一定速度で目標に向かって移動させる」というMoveAsync()UniTaskで実装しています。

Move.gif

このMoveAsync()を、いろんな方法でタイムアウトさせてみます。

A.TimeoutControllerを使う(推奨)

TimeoutControllerは「一定時間が経過した後にキャンセル状態となるCancellationTokenを生成する」機構です。

使い方としては次のとおりです。

  1. TimeoutControllerを作る
  2. TimeoutController.Timeout()から一定時間後にキャンセルされるCancellationTokenを生成する
  3. 必要に応じて既存のCancellationTokenと合成する
  4. await時にこの合成したCancellationTokenを渡す
  5. 正常終了時はTimeoutController.Reset()を実行する
  6. 例外発生時はTimeoutController.IsTimeout()でタイムアウトかどうかを判定する
private async UniTaskVoid Start()
{
    transform.position = Vector3.zero;

    // TimeoutControllerを生成
    var timeoutController = new TimeoutController();

    Debug.Log("移動開始!");

    try
    {
        // TimeoutControllerから指定時間後にキャンセルされるCancellationTokenを生成
        var timeoutToken = timeoutController.Timeout(TimeSpan.FromSeconds(1));

        // このGameObjectが破棄されたらキャンセルされるCancellationTokenを生成
        var destroyToken = this.GetCancellationTokenOnDestroy();

        // タイムアウトとDestroyのどちらもでキャンセルするようにTokenを生成
        var linkedToken = CancellationTokenSource
            .CreateLinkedTokenSource(timeoutToken, destroyToken)
            .Token;

        // 1秒でタイムアウトさせてみる
        await MoveAsync(new Vector3(0, 0, 100), linkedToken);

        // 使い終わったらReset()してあげる必要あり
        timeoutController.Reset();
    }
    catch (Exception ex)
    {
        Debug.LogException(ex);
        if (timeoutController.IsTimeout())
        {
            Debug.LogError("Timeoutによるキャンセルです");
        }
    }
}

TimeoutController.gif
(タイムアウト発生時、Cubeの動きが止まるのと同時にawaitも終わっています)

UniTaskでタイムアウトによりキャンセルを実装する場合は、このTimeoutControllerを用いることを推奨します。
理由としては「CancellationTokenによる通常でのキャンセル処理が実行できる」「タイムアウトが発生しなかったときにゼロアロケーションで動作する」からです。

なお、今回はCancellationTokenSource.CreateLinkedTokenSourceで合成しましたが、TimeoutControllerのコンストラクタにCancellationTokenSourceを渡すやり方でもOKです。

捕捉:CancellationTokenを複数指定するときの注意点

別の条件で発火する複数のCancellationTokenがあった場合、CancellationTokenSource.CreateLinkedTokenSourceを使って合成して1つのCancellationTokenにまとめてからawait対象に渡してください。

CreateLinkedTokenSourceを使うことで「どれか1つでもCancellationTokenが発火したら処理がキャンセルされる」を正しく実現することができます。

// TimeoutControllerから指定時間後にキャンセルされるCancellationTokenを生成
var timeoutToken = timeoutController.Timeout(TimeSpan.FromSeconds(1));

// このGameObjectが破棄されたらキャンセルされるCancellationTokenを生成
var destroyToken = this.GetCancellationTokenOnDestroy();

// タイムアウトとDestroyのどちらもでキャンセルするようにTokenを生成
var linkedToken = CancellationTokenSource
    .CreateLinkedTokenSource(timeoutToken, destroyToken)
    .Token;

そしてここからが注意点。
UniTaskにはAttachExternalCancellation()という拡張メソッドがあるのですが、これは基本的に使わないでください。
AttachExternalCancellationは後述するTimeout()拡張メソッドと同様に、「await自体は止めるけど走っている処理自体は止めない」という動作をします。

たとえば、上記のTimeoutControllerから作ったCancellationTokenを誤ってAttachExternalCancellationで指定するとどうなるか。

private async UniTaskVoid Start()
{
    transform.position = Vector3.zero;

    // TimeoutControllerを生成
    var timeoutController = new TimeoutController();

    Debug.Log("移動開始!");

    try
    {
        // TimeoutControllerから指定時間後にキャンセルされるCancellationTokenを生成
        var timeoutToken = timeoutController.Timeout(TimeSpan.FromSeconds(1));

        // このGameObjectが破棄されたらキャンセルされるCancellationTokenを生成
        var destroyToken = this.GetCancellationTokenOnDestroy();
        
        // MoveAsync()には destroyToken を渡し、
        // AttachExternalCancellation()に timeoutToken を渡してみた
        await MoveAsync(new Vector3(0, 0, 100), destroyToken)
            .AttachExternalCancellation(timeoutToken);

        // 使い終わったらReset()してあげる必要あり
        timeoutController.Reset();
    }
    catch (Exception ex)
    {
        Debug.LogException(ex);
        if (timeoutController.IsTimeout())
        {
            Debug.LogError("Timeoutによるキャンセルです");
        }
    }
}

Attach.gif

例外がでてタイムアウトが検知できるまでは同じなのですが、ブロックの動きが止まっていません。
つまりMoveAsync()awaitは中断できているが、MoveAsync()の処理そのものは止められていません。

このようにAttachExternalCancellation()は便利そうに見えますが、安易に使うと処理の止め忘れが起きるので基本的には使わないようにしましょう。

B.CancellationTokenSource.CancelAfterを使う(非推奨)

CancellationTokenSourceには一定時間後にキャンセルするという機能が実は最初から備わっています。

// CancellationTokenを用意
var cts = new CancellationTokenSource();

// 1秒後にキャンセルさせる
// CancelAfterSlimの戻り値はIDisposable
cts.CancelAfter(TimeSpan.FromSeconds(1));

// Timeout用のToken生成
var timeoutToken = cts.Token;

var destroyToken = this.GetCancellationTokenOnDestroy();
using var linkedCts = 
    CancellationTokenSource.CreateLinkedTokenSource(timeoutToken, destroyToken);
var linkedToken = linkedCts.Token;

try
{
    await MoveAsync(new Vector3(0, 0, 100), linkedToken);
}
finally
{
    // 終わり際に破棄
    cts.Dispose();
}

見た目はTimeoutControllerとくらべてシンプルなのですが、Unityに最適化されていません。
そのためCancelAfterをUnityで使うことは非推奨です。

C.CancellationTokenSource.CancelAfterSlimを使う(やや推奨)

UniTaskにはCancelAfterを軽量化してUnity向けに最適化したCancelAfterSlimが用意されています。
そのためCancelAfterをもし使っている場合は、CancelAfterSlimに乗り換えましょう(ただ乗り換えるならTimeoutControllerの方がゼロアロケーションになるけど)

// CancellationTokenを用意
var cts = new CancellationTokenSource();

// 1秒後にキャンセルさせる
// CancelAfterSlimの戻り値はIDisposable
var disposable = cts.CancelAfterSlim(TimeSpan.FromSeconds(1));

// Timeout用のToken生成
var timeoutToken = cts.Token;

var destroyToken = this.GetCancellationTokenOnDestroy();
var linkedToken = CancellationTokenSource
    .CreateLinkedTokenSource(timeoutToken, destroyToken)
    .Token;

try
{
    await MoveAsync(new Vector3(0, 0, 100), linkedToken);
}
finally
{
    // CancelAfterSlim自体も止める必要あり
    disposable.Dispose();
    cts.Dispose();
}

注意点としてはCancelAfterSlimが戻り値としてIDisposableを返すことです。
このIDisposable.Dispose()CancellationTokenSourceDispose()する前に呼び出す必要があります。
CancelAfterSlim側のDispose()を忘れると、タイムアウト発火後にObjectDisposedExceptionが飛びます)

D. Timeout()メソッドを使う(非推奨)

UniTaskにはTimeout()という拡張メソッドが定義されています。
このTimeout()メソッドを使うと、「await(相手の処理が終わるのを待つの)を時間で中断する」という動作をします。規定時間が経過後にTimeoutExceptionを発行します。
気をつけたいポイントは、実行中の処理自体は止まってくれないというところです。

public static async UniTask Timeout(this UniTask task, TimeSpan timeout, DelayType delayType = DelayType.DeltaTime, PlayerLoopTiming timeoutCheckTiming = PlayerLoopTiming.Update, CancellationTokenSource taskCancellationTokenSource = null)

MoveSampleStart()メソッドをこのように書き換えて様子を見てみます。

private async UniTaskVoid Start()
{
    transform.position = Vector3.zero;

    var token = this.GetCancellationTokenOnDestroy();

    Debug.Log("移動開始!");

    try
    {
        // 1秒でタイムアウトさせてみる
        await MoveAsync(new Vector3(0, 0, 100), token)
            .Timeout(TimeSpan.FromSeconds(1));
    }
    catch (Exception ex)
    {
        Debug.LogException(ex);
    }

    Debug.Log("移動終了!");
}

Timeout.gif

実行開始から1秒後にTimeoutExceptionが発行されてはいるものの、移動自体は止まらずにずっと動いてしまっています。つまり、await自体は止めたけどMoveAsync()の処理自体は止まっていないということになります。

E. TimeoutWithoutException()メソッドを使う(非推奨)

TimeoutWithoutException()Timeout()の変形で、「Timeout()と同じ機能」 + 「内部で発生した例外をすべてboolで返す」という機能になっています。

public static async UniTask<bool> TimeoutWithoutException(this UniTask task, TimeSpan timeout, DelayType delayType = DelayType.DeltaTime, PlayerLoopTiming timeoutCheckTiming = PlayerLoopTiming.Update, CancellationTokenSource taskCancellationTokenSource = null)

戻り値のboolは「処理が正常に完遂できなかったか」です。
falseの場合は「正常終了」で、trueの場合は「タイムアウト他、例外が起きたなどして何か問題が起きた」です。

private async UniTaskVoid Start()
{
    transform.position = Vector3.zero;

    var token = this.GetCancellationTokenOnDestroy();

    Debug.Log("移動開始!");
    
    // 1秒でタイムアウトさせてみる
    var result = await MoveAsync(new Vector3(0, 0, 100), token)
        .TimeoutWithoutException(TimeSpan.FromSeconds(1));

    if (result)
    {
        Debug.LogError("何か起きた");
    }
    else
    {
        Debug.Log("正常終了!");
    }
}

Timeoutwith.gif

TimeoutWithoutExceptionTimeoutの派生なのですが、キャンセル周りの挙動はTimeoutと同じです。

F. UniTask.WhenAnyでタイムアウトさせる(非推奨)

UniTask.Delayを用意してUniTask.WhenAnyでどっちが先に終わるかを待つというやり方です。

private async UniTaskVoid Start()
{
    transform.position = Vector3.zero;

    var token = this.GetCancellationTokenOnDestroy();

    // 2つUniTaskを用意して
    var moveTask = MoveAsync(new Vector3(0, 0, 100), token);
    var delayTask = UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);

    // どっちが先に終わるかを待つ
    await UniTask.WhenAny(moveTask, delayTask);
}

が、これは実はTimeout()メソッドの中身と同じことを書いているだけだったりします。
なのでTimeout()メソッドと同様に、これも非推奨です。

まとめ

  • タイムアウトをしたいならTimeoutControllerを使おう
    • 既存のCancellationTokenとの合成も忘れずに行うこと
    • AttachExternalCancellation()は使うべきではない
  • パフォーマンス上の理由からCancelAfterは非推奨
    • もし使うならCancelAfterSlimの方を使う
    • ただTimeoutControllerの方がよりパフォーマンスは良い
  • Timeout()/TimeoutWithoutException()も非推奨
    • 名前に釣られて使うと事故る可能性が非常に高い

最後に

ぶっちゃけneuecc氏のこの記事に全部書いてあります。
こちらの記事を参考にまとめなおしました。

26
22
2

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
26
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?