この記事は非同期メソッドにCancellationTokenをいちいち渡すことがめんどくさいという話とそれを楽にするための考察について書きます。
実用的かどうかは微妙なので注意してください。
はじめに
C#で非同期メソッドを使用するときキャンセルするためにはCancellationTokenを引数で渡す必要があります。
渡さなかった場合キャンセルできないため思いもよらぬバグに遭遇することがあります。
例えば以下のようなコードです。
(Unity用のコードですがだいたい察せると思います。)
// 1秒間隔で表示を更新する
class Hoge : MonoBehaviour
{
[SerializeField]
private TextMeshProUGUI text = default;
void Start()
{
_ = HogeAsync();
}
async UniTask HogeAsync()
{
for (var i = 0; ; i++)
{
await UniTask.Delay(1000);
text.text = $"count:{i}";
}
}
}
このコードはHogeAsyncを止める手段がありません。
これによってシーン遷移などでtextが破棄された後にtextにアクセスしてしまうということが起こります。
CancellationTokenを渡すように修正すると以下のようになります。
class Hoge : MonoBehaviour
{
[SerializeField]
private TextMeshProUGUI text = default;
void Start()
{
// 破棄されるときにキャンセル状態になるCancellationToken
// thisとtextの寿命が違う場合はこれではまずいがとりあえず一緒とする
var cancellationToken = this.GetCancellationTokenOnDestroy();
_ = HogeAsync(cancellationToken);
}
async UniTask HogeAsync(CancellationToken cancellationToken)
{
for (var i = 0; ; i++)
{
await UniTask.Delay(1000, cancellationToken: cancellationToken);
text.text = $"count:{i}";
}
}
}
従来のコルーチンを使用した方法では自動的に寿命がゲームオブジェクトと結びついていたので非同期にすると少し面倒になっているように感じます。
処理が長くなり複数の非同期メソッドを使用する場合はCancellationTokenを渡し忘れないようにする必要があります。
そもそもCancellationTokenを引数に取るオーバーロードがない場合はCancellationToken.ThrowIfCancellationRequested()
を使用してキャンセルされているかどうかチェックする必要があります。
考察
確実にキャンセルされない/キャンセルできない処理、カジュアルな用途の場合はCancellationTokenを渡さないという選択肢もありだと思います。
そうはいっても渡さなければいけないことも多いと思うので以下のような書き方を考えました。
static async YTask HogeAsync(CancellationToken token)
{
// CancellationTokenを挿入する
// この後の処理でawaitを使用するとawait抜ける際にキャンセル状態がチェックされるようになる
await YTask.Inject(token);
// 無限ループなのでキャンセルしないと終わらない
for (var i = 0; ; i++)
{
Console.WriteLine(i);
// CancellationTokenを渡してなくても勝手にキャンセルされる
await Task.Delay(1000);
}
}
実際に動かしてみると以下のような結果になります。
static async Task Main(string[] args)
{
var cts = new CancellationTokenSource();
// 5秒後にキャンセルする
_ = Task.Run(async () =>
{
await Task.Delay(5000);
cts.Cancel();
});
try
{
await HogeAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("catch OperationCanceledException");
}
}
/*
0
1
2
3
4
catch OperationCanceledException
*/
動くコードはyaegaki/YTaskに置いています。
ポイントは戻り値のYTaskとYTask.Injectです。
これによって自動で後続のawaitの後にCancellationTokenの確認処理が差し込まれます。
最初にInjectしておけばawaitの度にいちいちCancellationTokenを渡さなくていいので多少楽になります。
しかし、この方法には以下のようなデメリットがあります。
- awaitした非同期メソッドをキャンセルできていない
- ネストした非同期メソッドに対応できない
- 同期的に完了したか最初から完了していたタスクをawaitした場合、キャンセルされない
- 結局最初にInjectを書かないといけなくて面倒
1について、この方法では必ずawaitの後でキャンセルの確認が行われるためawait対象の非同期メソッド自体はキャンセルされていません。
2について、Injectの引数としてCancellationTokenが必要なので結局CancellationTokenが必要になる点は変わっていません。
3について、実装上の制約です。(AsyncMethodBuilderのAwaitOnCompletedが呼ばれないため)
4について、これはそのままです。
Unityのようにシングルスレッドが前提でコルーチン的に使用する場合はstatic変数を使用すれば楽になりますがマルチスレッドになった瞬間崩壊します。
static async YTask FugaAsync(string name, CancellationToken token)
{
// 最上位の非同期メソッドでstatic領域にInjectする
await YTask.InjectToStatic(token);
await Task.Delay(300);
// await後にstatic領域のCancellationTokenがFugaAsyncの最初にInjectされたものに戻る
// よって複数の非同期メソッドを別々のCancellationTokenで同時に動かしてもシングルスレッドの場合は正常に動作する
await PiyoAsync(name);
}
static async YTask PiyoAsync(string name)
{
// 下位の非同期メソッドではstatic領域から拾ってきてInjectする
await YTask.InjectFromStatic();
for (var i = 0; ; i++)
{
Debug.Log($"{name}:{i}");
await Task.Delay(1000);
}
}
まとめ
ちょっと思いついたので書いてみましたがよく考えると微妙でしたという感じです。
結局のところ毎回引数にCancellationTokenを渡すのはそういうものだと思って書くのが楽かもしれません。
Unityについてのみ考えるのなら非同期メソッドの先頭で常にGetCancellationTokenOnDestroyで取得したものをInjectするというのもありかもしれません。(うーん...)
async YTask HogeAsync()
{
await YTask.Inject(this.GetCancellationTokenOnDestroy());
// FugaAsyncの中でも先頭でInjectしているはずなのでCancellationTokenを渡さない
await FugaAsync();
}
async YTask FugaAsync()
{
await YTask.Inject(this.GetCancellationTokenOnDestroy());
// 適当な後続処理...
}