はじめに
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
におけるキャンセル処理では、次の一連の流れをすべて実装する必要があります。
-
CancellationToken
を適切なタイミングで生成し、キャンセルしたいタイミングでキャンセル状態にする -
async
メソッドを定義するときはCancellationToken
を引数にとる -
await
するときは、await
対象にCancellationToken
が渡せるなら渡す -
await
対象にCancellationToken
が渡せないのであれば、CancellationToken.ThrowIfCancellationRequested()
を適宜呼び出す -
async/await
とtry-catch
を併用する場合はOperationCancelledException
の扱いを考える
1. CancellationToken
を適切なタイミングで生成し、キャンセルしたいタイミングでキャンセル状態にする
CancellationToken
とは、async/await
において「処理のキャンセルを伝えるためのオブジェクト」です。
このCancellationToken
を適切なタイミングで生成し、処理を中止したいタイミングでキャンセル状態に変更することでasync/await
をキャンセルさせることができます。
具体的には、CancellationTokenSource
を使ってCancellationToken
を生成します。この親となったCancellationTokenSource
のCancel()
を呼び出すことで、ここから発行されたCancellationToken
がキャンセル状態になります。
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を引数にとるようにしましょう。
/// <summary>
/// asyncメソッドを定義した場合はCancellationTokenを引数に取る
/// 作法としては引数の一番最後をCancellationTokenにすることがほとんど
/// あとメソッド名のSuffixも-Asyncにしておく
/// </summary>
public async ValueTask DelayRunAsync(Action action, CancellationToken token)
{
// これから何か処理をする
}
3.await
するときは、await
対象にCancellationToken
が渡せるなら渡す
対象のメソッドがCancellationToken
を受け付けるのであれば、await
時にCancellationToken
を渡してあげます。
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()
です。
// 何か別のライブラリの非同期メソッドを実行したいが、そのライブラリのお行儀が悪く
// CancellationTokenを渡すことができない場合など
private async ValueTask UseOtherFrameworkAsync(CancellationToken token)
{
// NankaAsync()自体は走り出したらキャンセルできないので諦めるとして、
await OtherFramework.NankaAsync();
// キャンセル状態になっていたらこの時点で処理を止める
// (例外が発行されてここで中断される)
token.ThrowIfCancellationRequested();
// SugoiAsync()も走り出したら後から止めることはできない(諦める)
await OtherFramework.SugoiAsync();
}
また、Task.Run
などを使っている場合もこの手法を使う必要があります。
/// <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
)はキャンセル扱いになる」という性質があります。
次のコードをご覧ください。
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;
}
}
}
}
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
が正しくキャンセル状態になってくれないため)
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 処理のタイムアウトの書き方 まとめ タイムアウト周りの話をまとめました
捕捉
neuecc氏の記事も参考になると思います。
補足:「あえてキャンセルを書かない」というやり方について
CancellationToken
やキャンセル処理の例外処理を書くと、コードがわかりにくくなる(実際はそこまで複雑でもないですが)という問題点があります。そのためコストとリターンを考えて「あえてキャンセルを書かない」という方法もあります。
あえてキャンセルを書かないでも許されるシチュエーションとしては、「キャンセル要求がそもそも滅多に発生しない」「キャンセル処理が事実上意味がない」「キャンセルしなくても問題が起きない」などが挙げられます。「ここではキャンセルを書かなくても問題がない」と言い切れるような場面においてはキャンセル処理を書かないこともあります。
一方でasync/await
の使い方に不安が残る場合、見様見真似でやっているような状況、「キャンセル処理を書かないことよって発生する問題が未知である」ようなときはコストを支払ってでもキャンセル処理を書いておいた方が安全で良いでしょう。
あとUnityのような、コンテキストの寿命が非常に長い場合はキャンセル処理はほぼ必須で書いたほうが良いです。キャンセルを放置することによって発生する問題が致命的になる可能性が高いです。メモリリークやGCによるプチフリ、CPUの無駄使いなど、影響が無視できないことが起きやすいです。