UniTaskの cancelImmediately フラグについて

# はじめに
こちらの記事は[Unity Advent Calendar 2023](https://qiita.com/advent-calendar/2023/unity) 14日目の記事です。
[UniTask Ver.2.5.0](https://github.com/Cysharp/UniTask/releases/tag/2.5.0)で新しく追加された機能 `cancelImmediately` について解説しようと思います

記事の内容のサンプルはGitHubに上げてあります(https://github.com/euglenach/UniTaskCancelImmediately)

# cancelImmediately

cancelImmediatelyフラグは、その名の通りCancellationTokenがキャンセルになったとき、**タスクを即座にキャンセル状態にするか**(キャンセル例外が吐かれるか)を指定できるオプションです。
UniTask構造体やUniTaskAsyncEnumerableクラスで宣言されているファクトリメソッドや拡張メソッドの引数に追加されました
デフォルトではfalseです


「**じゃあ今まではすぐにキャンセルにならなかったこと？**」という疑問が湧くと思いますが、実はその通りです。以下の例を見てください

```csharp
private readonly CancellationTokenSource cts = new();

private async UniTaskVoid Start()
{
    Debug.Log($"DelayAfterFrame:{Time.frameCount}");
    try
    {
        await UniTask.Delay(1000, cancellationToken: cts.Token);
    } catch(OperationCanceledException)
    {
        Debug.Log($"CanceledFrame:{Time.frameCount}");
    }
}

private void LateUpdate()
{
    cts.Cancel();
}
```
`UniTask.Delay` にキャンセル例外を吐かせ、前後のフレームを見るというシンプルなものです

![a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260914/47f29c73-ea55-8c05-3e38-05e10b086b71.png)

結果は、catch句に入った時点では1フレーム遅れていることがわかります

`UniTask.Delay` は**毎フレーム タスクが完了・キャンセルされているかを確認**しています
その確認の後にキャンセルされていたら、同フレームであっても**例外が吐かれるのはその次のフレーム**の確認の際になります

![c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260914/e7665c6f-3bc8-cf18-7aab-b1ff3ff882e7.png)

余談ですが、確認のタイミングは引数の `delayTiming`オプション で指定できます

```csharp
await UniTask.Delay(1000, delayTiming: PlayerLoopTiming.FixedUpdate);
```

さて、ここからが本題です

`cancelImmediately` をtrueにして確認してみましょう

```csharp
private readonly CancellationTokenSource cts = new();
private async UniTaskVoid Start()
{
    Debug.Log($"DelayAfterFrame:{Time.frameCount}");
    try
    {
        // cancelImmediatelyオプションを設定する
        await UniTask.Delay(1000, cancellationToken: cts.Token, cancelImmediately: true);
    } catch(OperationCanceledException)
    {
        Debug.Log($"CanceledFrame:{Time.frameCount}");
    }
}
private void LateUpdate()
{
    cts.Cancel();
}
```

![b.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260914/2d144205-026e-237d-1c25-b329520008a0.jpeg)

キャンセルした同フレームでcatch句に入っていることがわかります
`cancelImmediately`オプションを true にすると、即座に例外が吐かれるようになります

![d.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260914/e185dd2a-71b5-6af7-3791-8465025bddaf.png)


[該当の実装](https://github.com/Cysharp/UniTask/pull/517/files#diff-04e3a751f1be2055de05f98588a43159a9794c0e5d3c623a8188f2673b96e855R700-R707)を見ると、`CancellationToken.Register` でタスクをキャンセル状態にしているようです。`CancellationTokenSource.Cancel` が呼ばれると内部でそのままコールバックが呼ばれるので**本当に即キャンセル**しています。(その様子はスタックトレースで見て取れます)

さて、UniTaskで動く全ての非同期処理のキャンセルが遅れていたかと言うとそうではありません。
先ほど述べたように **PlayerLoop でキャンセルの確認をしている** IUniTaskSource が対象なのであって、それらを使っていない非同期処理や `UniTaskCompletionSource` はその限りではありません。
対応されたメソッドは、こちらのPR(https://github.com/Cysharp/UniTask/pull/517) から一覧を見ることができます。

## メリット

cancelImmediatelyをtrueにすると **プログラムの見た目と挙動に乖離が生まれる** ことを解消できます

即時にキャンセルすると何が良いのか、1つ例を出したいと思います

以下は、「**カウントダウンしている間、文字が赤くなるカウントダウンタイマー**」です

```csharp
[SerializeField] private Button button;
[SerializeField] private Text timeText;

async UniTask StartCountdown(float seconds, CancellationToken cancellationToken)
{
    // カウントダウンの開始時に文字を赤くする
    timeText.color = Color.red;

    try
    {
        while(!cancellationToken.IsCancellationRequested)
        {
            // 時間を更新
            timeText.text = seconds.ToString("N1");
            seconds = Mathf.Max(0, seconds - Time.deltaTime);
            if(seconds <= 0) break;
            
            // cancelImmediately falseで待つ
            await UniTask.NextFrame(cancellationToken);
        }
    } finally
    {
        // カウントダウンが終わったら文字を白くする
        if(timeText) timeText.color = Color.white;
    }
}
```

これを任意のタイミングでキャンセルし再び起動できるようにします。

```csharp
[SerializeField] private Button button;
private CancellationTokenSource timerCancellation;

private void Start()
{
    button.onClick.AddListener(() =>
    {
        timerCancellation?.Cancel();
        timerCancellation = new();
        StartCountdown(10, timerCancellation.Token).Forget();
    });
}
```

タイマーの起動中にボタンを押したときの挙動を `UniTask.NextFrame` に渡す `cancelImmediately` の違いでどう変わるかを見ていきます

### cancelImmediately = false

![e.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260914/e817c84f-3425-c981-0710-f0e0ea87a19f.gif)

2回目の StartCountdown で文字が白くなった状態でカウントダウンしています。

![e.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260914/3abf635d-2836-57cd-7bce-f724d82cbfbd.png)

### cancelImmediately = true

![f.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260914/26448b41-77ab-536d-66ba-ca2b0824447d.gif)

```csharp
// cancelImmediately trueで待つ
await UniTask.NextFrame(cancellationToken, cancelImmediately: true);
```

無事タイマーが起動しているときだけ赤くなってくれました
文字通り即キャンセルしてくれるので以下の図のような挙動になります

![f.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260914/7fcff768-2dd3-bbfc-2c4c-bbd13709becc.png)

こんな感じで **同じ時間に1つしか起動してほしくない、いつでもリスタートできる何かしらの非同期処理** とかに便利だったりします。UIアニメーションとかでよくある

## デメリット

> Note: Setting cancelImmediately to true and detecting an immediate cancellation is more costly than the default behavior. This is because it uses CancellationToken.Register; it is heavier than checking CancellationToken on the player loop.

[公式のREADME](https://github.com/Cysharp/UniTask#cancellation-and-exception-handling)にある通り `cancelImmediately` がtrueだと少し重いらしいです。ほとんどの場合で気にしなくて良いとは思いますが...

# まとめ
- [UniTask2.5.0](https://github.com/Cysharp/UniTask/releases/tag/2.5.0) でファクトリメソッドや拡張メソッドに `cancelImmediately` オプションが追加された
- 今までは `UniTask.Delay` などはキャンセル命令が来てから実際にキャンセル状態になるまでにラグがあった
  - UniTaskで動く全ての非同期処理にラグがあるわけではない
  - PlayerLoopでキャンセル確認しているものが対象
- `cancelImmediately` をtrue にするとキャンセル命令が来たときタスクが即キャンセル状態になる
  - 内部で`CancellationToken.Register`使っているのでちょっと重い


# おわりに

閲覧いただきありがとうございました。

今回は UniTask の新しい機能 `cancelImmediately` について解説しました。サンプルは[こちら](https://github.com/euglenach/UniTaskCancelImmediately)
間違っている箇所があれば、やさ～～～～～～～～しく教えていただけると助かります。
何卒何卒。では:raised_hand:

明日の記事は [はなちるさん](https://qiita.com/hanaaaaaachiru)の『[【Unity】Debug.Logはもう古い！？ Unity公式のLoggingパッケージ「Unity Logging」の使い方まとめ](https://www.hanachiru-blog.com/entry/2023/12/15/070000)』です！