はじめに
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();
// Cancel()が確実に実行されるまで待つ
Thread.Sleep(1000);
// キャンセル実行後に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.CreateLinkedTokenSourceCancellationTokenSource.CancelAfterCancellationToken.Register
【追記】 UniTask 処理のタイムアウトの書き方 まとめ タイムアウト周りの話をまとめました
捕捉
neuecc氏の記事も参考になると思います。
補足:「あえてキャンセルを書かない」というやり方について
CancellationTokenやキャンセル処理の例外処理を書くと、コードがわかりにくくなる(実際はそこまで複雑でもないですが)という問題点があります。そのためコストとリターンを考えて「あえてキャンセルを書かない」という方法もあります。
あえてキャンセルを書かないでも許されるシチュエーションとしては、「キャンセル要求がそもそも滅多に発生しない」「キャンセル処理が事実上意味がない」「キャンセルしなくても問題が起きない」などが挙げられます。「ここではキャンセルを書かなくても問題がない」と言い切れるような場面においてはキャンセル処理を書かないこともあります。
一方でasync/awaitの使い方に不安が残る場合、見様見真似でやっているような状況、「キャンセル処理を書かないことよって発生する問題が未知である」ようなときはコストを支払ってでもキャンセル処理を書いておいた方が安全で良いでしょう。
あとUnityのような、コンテキストの寿命が非常に長い場合はキャンセル処理はほぼ必須で書いたほうが良いです。キャンセルを放置することによって発生する問題が致命的になる可能性が高いです。メモリリークやGCによるプチフリ、CPUの無駄使いなど、影響が無視できないことが起きやすいです。