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

# はじめに

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

# 先に結論

`TimeoutController`を使おう

# 「タイムアウト」とは

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

しかし、[こちらの記事](https://qiita.com/toRisouP/items/60673e4a39319e69fbc0)で「キャンセルには２パターン意味がある」と説明しました。

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

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


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

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

```cs
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/48609a1f-703c-651f-77e0-6b0713d2f328.gif)



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



## A.TimeoutControllerを使う（推奨）

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

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

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

```cs
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/8a20aed2-c269-ac5d-a902-0d43136999e0.gif)
（タイムアウト発生時、Cubeの動きが止まるのと同時に`await`も終わっています）


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


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


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

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

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

```cs
// 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`で指定するとどうなるか。

```cs
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/f2bdf279-a317-c465-28b3-b42701f4047c.gif)


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


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


## B.CancellationTokenSource.CancelAfterを使う（非推奨）

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

```cs
// 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`の方がゼロアロケーションになるけど）

```cs
// 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()`を`CancellationTokenSource`を`Dispose()`する前に呼び出す必要があります。
（`CancelAfterSlim`側の`Dispose()`を忘れると、タイムアウト発火後に`ObjectDisposedException`が飛びます）


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

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


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


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

```cs
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/1f399177-f7ab-272c-6b2f-bfb91c24ba09.gif)


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



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

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

```cs
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`の場合は「タイムアウト他、例外が起きたなどして何か問題が起きた」です。


```cs
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/884e028d-15e9-8e82-8666-5ccb73797e4f.gif)



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

## F. `UniTask.WhenAny`でタイムアウトさせる（非推奨）

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

```cs
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()メソッドの中身と同じことを書いているだけ**](https://github.com/Cysharp/UniTask/blob/master/src/UniTask/Assets/Plugins/UniTask/Runtime/UniTaskExtensions.cs#L375-L381)だったりします。
なので`Timeout()`メソッドと同様に、これも非推奨です。


# まとめ

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


# 最後に

ぶっちゃけ[neuecc氏のこの記事](https://neue.cc/2021/02/26_599.html)に全部書いてあります。
こちらの記事を参考にまとめなおしました。