【C#】async/awaitのキャンセル処理まとめ

# はじめに

C#における`async/await`を使う上で、絶対に意識しないといけないものは「キャンセル処理」です。
正しく処理をキャンセルしないとメモリリークを起こしたり、デッドロックやデータ不整合を引き起こす可能性があります。


今回はこの「`async/await`におけるキャンセル処理」について話します。


# 対象

* C#における`async/await`全般
  * `Task`/`ValueTask`/`UniTask`すべてに共通します
  * Unity含む
* C#の`asyc/await`についてイマイチ自信が持ててない人


# 先に「結論」

* `async`メソッドは`CancellationToken`を引数に取るべき
* `await`対象が引数に`CancellationToken`を要求する場合は省略せずに渡すべき
* `OperationCanceledException`の取り扱いを意識するべき

# 解説

## そもそも「キャンセル」とは何を指すのか

そもそも「`async/await`におけるキャンセル」とは何か。これには2つの意味があります。

* `await`をキャンセルする
* `await`対象の実行中の処理をキャンセルする

「キャンセル処理」といえばこの2つをまとめて指すことが多いのですが、文脈によっては片方しか意味していないこともあります。

### 「`await`をキャンセルする」

`await`をキャンセルするとは、「今裏で実行している処理そのものは止めず、待つのをやめる」という意味です。「処理が終わるのを待つのを諦める」に近いです。
たとえば「レストランで注文して料理を作ってもらっているが、時間がかかりすぎているので諦めて**店員に何も伝えずに店を出てきた（待つのを止めた）**」みたいな。


### 「`await`対象の実行中の処理をキャンセルする」

こちらは「裏で走っている処理を止める」という、おそらく「キャンセル処理」という名称からイメージする内容だと思います。
先程のレストランの例でいうと、「レストランで注文して料理を作ってもらっているが、気が変わったので**店員に伝えて作るのを止めてもらった**」みたいな。

### `async/await`のキャンセル処理では、このどちらを意識すればいいのか

答:**両方意識してください。**

「`await`はキャンセルしたが、処理自体はスレッドプールで走ったままだった」みたいな事故はよく起きます。
（とくに`Task.Run`を使っているとき）
そのため「このキャンセル処理は何を止めればいいのか」をちゃんと把握した上でキャンセルを実装する必要があります。

ひとまず、これから紹介する内容を守れば「`await`のキャンセル」「`await`対象の実行処理のキャンセル」の2つは実現できます。

## `async/await`のキャンセル方法

ここからが本題。`async/await`にキャンセル処理をつける場合はどうしたらいいのか。

結論からいうと、`async/await`におけるキャンセル処理では、次の一連の流れをすべて実装する必要があります。

1. `CancellationToken`を適切なタイミングで生成し、キャンセルしたいタイミングでキャンセル状態にする
2. `async`メソッドを定義するときは`CancellationToken`を引数にとる
3. `await`するときは、`await`対象に`CancellationToken`が渡せるなら渡す
4. `await`対象に`CancellationToken`が渡せないのであれば、`CancellationToken.ThrowIfCancellationRequested()`を適宜呼び出す
5. `async/await`と`try-catch`を併用する場合は`OperationCancelledException`の扱いを考える

## 1. `CancellationToken`を適切なタイミングで生成し、キャンセルしたいタイミングでキャンセル状態にする

`CancellationToken`とは、`async/await`において「処理のキャンセルを伝えるためのオブジェクト」です。
この`CancellationToken`を適切なタイミングで生成し、処理を中止したいタイミングでキャンセル状態に変更することで`async/await`をキャンセルさせることができます。

具体的には、`CancellationTokenSource`を使って`CancellationToken`を生成します。この親となった`CancellationTokenSource`の`Cancel()`を呼び出すことで、ここから発行された`CancellationToken`がキャンセル状態になります。

```cs
using System;
using System.Threading;

namespace CancelSamples
{
    /// <summary>
    /// たとえば、このクラスのインスタンスの寿命に紐づけたCancellationTokenが欲しい場合
    /// </summary>
    public class AsyncProcessSample : IDisposable
    {
        // フィールド初期化なり、コンストラクタなり、とにかくCancellationTokenSourceを用意
        private readonly CancellationTokenSource _cancellationTokenSource =
            new CancellationTokenSource();


        // CancellationToken を CancellationTokenSourceから生成する
        // （サンプルだからpublicにしてるけど、別にprivateにしてクラス内で使ってもいい）
        public CancellationToken Token => _cancellationTokenSource.Token;


        /// <summary>
        /// IDisposable.Dispose()でキャンセル実行する
        /// Unityの場合はOnDestroy()とか
        /// </summary>
        public void Dispose()
        {
            // クラスを破棄するタイミングでキャンセル実行
            _cancellationTokenSource.Cancel();

            // 破棄
            _cancellationTokenSource.Dispose();
        }
    }
}
```

このコードで重要なのは次の2点です。

* `CancellationTokenSource`から`CancellationToken`を生成している
* 処理をキャンセルしたいタイミングで`CancellationTokenSource.Cancel()`を実行する


## 2.`async`メソッドを定義するときは`CancellationToken`を引数にとる

`CancellationToken`が用意できているなら、これを`async`メソッドに渡す必要があります。そのためにも**asyncメソッドはCancellationTokenを引数にとる**ようにしましょう。


```cs
/// <summary>
/// asyncメソッドを定義した場合はCancellationTokenを引数に取る
/// 作法としては引数の一番最後をCancellationTokenにすることがほとんど
/// あとメソッド名のSuffixも-Asyncにしておく
/// </summary>
public async ValueTask DelayRunAsync(Action action, CancellationToken token)
{
    // これから何か処理をする
}
```

## 3.`await`するときは、`await`対象に`CancellationToken`が渡せるなら渡す

対象のメソッドが`CancellationToken`を受け付けるのであれば、`await`時に`CancellationToken`を渡してあげます。


```cs
using System;
using System.Threading;
using System.Threading.Tasks;

namespace CancelSamples
{
    /// <summary>
    /// たとえば、このクラスのインスタンスの寿命に紐づけたCancellationTokenが欲しい場合
    /// </summary>
    public class AsyncProcessSample : IDisposable
    {
        // フィールド初期化なり、コンストラクタなり、とにかくCancellationTokenSourceを用意
        private readonly CancellationTokenSource _cancellationTokenSource =
            new CancellationTokenSource();

        
        // async/awaitの起点
        public void PrintMessage()
        {
            // CancellationTokenを生成
            var token = _cancellationTokenSource.Token;

            // async/awaitの起点部分（最上流）でCancellationTokenを渡す
            _ = PrintMessagesAsync(token);
        }


        /// <summary>
        /// 連続してメッセージを表示する
        /// </summary>
        private async ValueTask PrintMessagesAsync(CancellationToken token)
        {
            // 受け取ったTokenはすべて下流にも渡す
            await DelayRunAsync(() => Console.WriteLine("Hello!"), token);
            await DelayRunAsync(() => Console.WriteLine("World!"), token);
            await DelayRunAsync(() => Console.WriteLine("Bye!"), token);
        }


        /// <summary>
        /// 1秒後にActionを実行する
        /// </summary>
        private async ValueTask DelayRunAsync(Action action, CancellationToken token)
        {
            // 1秒待つ、にキャンセル処理を仕込む
            await Task.Delay(TimeSpan.FromSeconds(1), token);
            action();
        }

        /// <summary>
        /// IDisposable.Dispose()でキャンセル実行する
        /// Unityの場合はOnDestroy()とか
        /// </summary>
        public void Dispose()
        {
            // クラスを破棄するタイミングでキャンセル実行
            _cancellationTokenSource.Cancel();

            // 破棄
            _cancellationTokenSource.Dispose();
        }
    }
}
```

`PrintMessage` -> `PrintMessagesAsync` -> `DelayRunAsync` -> `Task.Delay` と、`CancellationToken`がバケツリレーされていきます。
そして最終的に最下流（一番ネストが深い部分）でバケツリレーが止まります。これが`async/await`の正しい「キャンセルの通知方法」です。

## 4.`await`時に`CancellationToken`が渡せないのであれば、`CancellationToken.ThrowIfCancellationRequested()`を適宜呼び出す

`CancellationToken.ThrowIfCancellationRequested()`メソッドは、「`CancellationToken`がキャンセル状態になっていたときに`OperationCanceledException`を発行する」というメソッドです。
あとで後述しますが、`async/await`では（正確にいうと`Task`たちは）`OperationCanceledException`を特殊な例外として扱っています。

もし`await`実行時に`CancellationToken`が相手に渡せない場合は、キャンセル時にこの`OperationCanceledException`を発行してあげる必要があります。
それを1行でやってくれるのが`CancellationToken.ThrowIfCancellationRequested()`です。


```cs
// 何か別のライブラリの非同期メソッドを実行したいが、そのライブラリのお行儀が悪く
// CancellationTokenを渡すことができない場合など
private async ValueTask UseOtherFrameworkAsync(CancellationToken token)
{
    // NankaAsync()自体は走り出したらキャンセルできないので諦めるとして、
    await OtherFramework.NankaAsync();

    // キャンセル状態になっていたらこの時点で処理を止める
    // （例外が発行されてここで中断される）
    token.ThrowIfCancellationRequested();
    
    // SugoiAsync()も走り出したら後から止めることはできない（諦める）
    await OtherFramework.SugoiAsync();
}
```


また、`Task.Run`などを使っている場合もこの手法を使う必要があります。

```cs
/// <summary>
/// 1秒後にActionをスレッドプールで実行する
/// </summary>
private async ValueTask DelayRunOnThreadPoolAsync(Action action,
    CancellationToken token)
{
    // スレッドプールで処理を実行する
    await Task.Run(() =>
    {
        // 1秒スレッドを止める
        Thread.Sleep(TimeSpan.FromSeconds(1));

        // 1秒経過後にCancellationTokenをチェックし、
        // キャンセル状態になっていたら例外を出して終了
        token.ThrowIfCancellationRequested();
        
        // キャンセル状態になってないなら処理を実行する
        action();
        
    // ↓このCancellationTokenは「スレッドプール上で処理を実行開始するか」に
    // 　対するキャンセルであり、処理がスレッドプールで走りはじめてしまうと
    //  止めることができない
    }, token);
}
```

とくに`Task.Run`などを使っている場合は気をつけないといけないです。
`Task.Run`の引数で渡す`CancellationToken`は「スレッドプールに処理を投げる前まで」しか効力がありません。
スレッドプールで処理が走りはじめてしまうと、そのままでは止めることができないため**自身で`Task.Run`の本処理内で`CancleationToken`のチェックを行う必要があります。**


## `async/await`と`try-catch`を併用する場合は`OperationCancelledException`の扱いを考える

### そもそも`OperationCancelledException`とは

`OperationCancelledException`はC#において特殊な例外として設定されています。
この例外は「`async`メソッド内から外に向かって発行された場合、そのメソッドに紐づいた`Task`（`ValueTask`/`UniTask`）はキャンセル扱いになる」という性質があります。

次のコードをご覧ください。

```cs
using System;
using System.Threading;
using System.Threading.Tasks;

namespace CSharpSandbox
{
    class Program
    {
        static void Main(string[] args)
        {
            var cts = new CancellationTokenSource();

            // CancellationTokenを用意
            var token = cts.Token;

            // 非同期メソッド実行開始
            var task = TestAsync(token);

            // Taskの内部状態を確認
            Console.WriteLine($"A:{task.Status}");

            // キャンセル実行
            cts.Cancel();

            // キャンセル実行後にTaskの内部状態を確認
            Console.WriteLine($"B:{task.Status}");
        }

        private static async Task TestAsync(CancellationToken token)
        {
            try
            {
                await Task.Delay(TimeSpan.FromSeconds(10), token);
            }
            catch (OperationCanceledException ex)
            {
                // 例外の型を出力
                Console.WriteLine($"--{ex.GetType()}");

                // 発生した例外をキャッチして再throw
                throw;
            }
        }
    }
}
```

```cs
A:WaitingForActivation
--System.Threading.Tasks.TaskCanceledException
B:Canceled
```

`Task.Delay()`を待ち受けていますが、`Cancel()`を呼びだしたタイミングで`TaskCanceledException`が発行されていることがわかります。
`TaskCanceledException`は`OperationCanceledException`の派生なので、事実上`OperationCanceledException`が発行されたことになります。

この`OperationCanceledException`の発行を受けて`task.Status`が`Canceled`になっていることがわかります。

また、**すでにStatusがCanceledになっているTaskをawaitしようとした場合、即座に`OperationCanceledException`が`throw`される仕組みになっています。**

### なぜ`async/await`はキャンセル時に例外を使っているのか

答えは「大域脱出を実現するため」です。処理のキャンセル時には「以後の処理をすべてスキップして」「呼び出し元に返る」必要があります。
その実現に「例外」がちょうどよかったので、`async/await`は`OperationCanceledException`を使った制御の仕組みとなっているのです。

### 「例外処理」を用いずにキャンセルは実現できないのか


C#の言語仕様として`OperationCanceledException`を使うという仕組みになっているため、基本的にはこの例外を投げてキャンセルを推奨しますが、何らかの理由で例外処理が使えない場合はどうするか。

`CancellationToken.IsCancellationRequested` を用いて手動でメソッドから`return`すれば`OperationCanceledException`を用いずにキャンセル処理自体は実装できます。

しかしこの場合は`TaskStatus`が`RanToCompletion`となってしまうため、**外からみてこのTaskが正常終了したのかキャンセルしたのかの区別がつかなくなる**という欠点もあります。


### `OperationCancelledException`をどう扱えばよいか

`async/await`において`OperationCancelledException`は「`catch`せずに通過させる」または「`catch`した場合は再`throw`する」を推奨します。
（`OperationCancelledException`を上流まで伝播させないと`Task`が正しくキャンセル状態になってくれないため）

```cs
private static async ValueTask TestAsync(CancellationToken token)
{
    try
    {
        await HogeAsync(token);
    }
    // 例外のうち OperationCanceledException は「キャッチしない」
    catch (Exception ex) when (!(ex is OperationCanceledException))
    {
        Console.WriteLine(ex);
    }
}
```

# まとめ

* `CancellationToken`を使ってキャンセル要求を行う
    * `CancellationToken`は`CancellationTokenSource`から作れる
    * `CancellationTokenSource.Cancel()`でキャンセル要求を行える
    * `Cancel()`をどのタイミングで呼ぶかは適宜考える必要あり
        * `Dispose()`だったり、Unityの場合は`OnDestroy`だったり
* `async/await`と`CancellationToken`はセットで取り扱うことを意識する
    * `async`メソッドを定義するときは必ず引数で受け取る
    * `await`するときは相手に渡す
    * `await`相手に渡せないときは`ThrowIfCancellationRequested()`を使う
* `OperationCancelledException`が発行されることを常に意識する   
    * 「`await`しているときに`CancellationToken`がキャンセル状態になったとき」
    * 「すでにキャンセル済みになった`Task`を`await`しようとしたとき」
    * 「キャンセル後に`ThrowIfCancellationRequested()`を呼び出した時」
* `OperationCancelledException`は発行時に`catch`してもみ消さない
    * `catch`した場合は再`throw`する
    * またはそもそも`catch`しないようにしておく


正直、キャンセルの取り扱いはかなり面倒くさいです。


### 今回は話さなかったこと

ややこしいパターンに触れてしまうと、脱線気味になって一気に難しくなるので次の内容は今回触れませんでした。
興味がある方は調べてください。

* 「`Task.WhenAny`」による`await`の中断
* `CancellationTokenSource.CreateLinkedTokenSource`
* `CancellationTokenSource.CancelAfter`
* `CancellationToken.Register`



【追記】 [UniTask 処理のタイムアウトの書き方 まとめ](https://qiita.com/toRisouP/items/69dc07ba8d1c81b0e7ac)　タイムアウト周りの話をまとめました

### 捕捉

[neuecc氏の記事](https://neue.cc/2022/07/13_Cancellation.html)も参考になると思います。

### 補足：「あえてキャンセルを書かない」というやり方について

`CancellationToken`やキャンセル処理の例外処理を書くと、コードがわかりにくくなる（実際はそこまで複雑でもないですが）という問題点があります。そのためコストとリターンを考えて「あえてキャンセルを書かない」という方法もあります。

あえてキャンセルを書かないでも許されるシチュエーションとしては、「キャンセル要求がそもそも滅多に発生しない」「キャンセル処理が事実上意味がない」「キャンセルしなくても問題が起きない」などが挙げられます。「ここではキャンセルを書かなくても問題がない」と言い切れるような場面においてはキャンセル処理を書かないこともあります。

一方で`async/await`の使い方に不安が残る場合、見様見真似でやっているような状況、「キャンセル処理を書かないことよって発生する問題が未知である」ようなときはコストを支払ってでもキャンセル処理を書いておいた方が安全で良いでしょう。

あとUnityのような、コンテキストの寿命が非常に長い場合はキャンセル処理はほぼ必須で書いたほうが良いです。キャンセルを放置することによって発生する問題が致命的になる可能性が高いです。メモリリークやGCによるプチフリ、CPUの無駄使いなど、影響が無視できないことが起きやすいです。