この記事は非同期メソッドにCancellationTokenをいちいち渡すことがめんどくさいという話とそれを楽にするための考察について書きます。
実用的かどうかは微妙なので注意してください。
はじめに
C#で非同期メソッドを使用するときキャンセルするためにはCancellationTokenを引数で渡す必要があります。
渡さなかった場合キャンセルできないため思いもよらぬバグに遭遇することがあります。
例えば以下のようなコードです。
(Unity用のコードですがだいたい察せると思います。)
// 1秒間隔で表示を更新するclassHoge:MonoBehaviour{[SerializeField]privateTextMeshProUGUItext=default;voidStart(){_=HogeAsync();}asyncUniTaskHogeAsync(){for(vari=0;;i++){awaitUniTask.Delay(1000);text.text=$"count:{i}";}}}
このコードはHogeAsyncを止める手段がありません。
これによってシーン遷移などでtextが破棄された後にtextにアクセスしてしまうということが起こります。
CancellationTokenを渡すように修正すると以下のようになります。
classHoge:MonoBehaviour{[SerializeField]privateTextMeshProUGUItext=default;voidStart(){// 破棄されるときにキャンセル状態になるCancellationToken// thisとtextの寿命が違う場合はこれではまずいがとりあえず一緒とするvarcancellationToken=this.GetCancellationTokenOnDestroy();_=HogeAsync(cancellationToken);}asyncUniTaskHogeAsync(CancellationTokencancellationToken){for(vari=0;;i++){awaitUniTask.Delay(1000,cancellationToken:cancellationToken);text.text=$"count:{i}";}}}
従来のコルーチンを使用した方法では自動的に寿命がゲームオブジェクトと結びついていたので非同期にすると少し面倒になっているように感じます。
処理が長くなり複数の非同期メソッドを使用する場合はCancellationTokenを渡し忘れないようにする必要があります。
そもそもCancellationTokenを引数に取るオーバーロードがない場合はCancellationToken.ThrowIfCancellationRequested()
を使用してキャンセルされているかどうかチェックする必要があります。
考察
確実にキャンセルされない/キャンセルできない処理、カジュアルな用途の場合はCancellationTokenを渡さないという選択肢もありだと思います。
そうはいっても渡さなければいけないことも多いと思うので以下のような書き方を考えました。
staticasyncYTaskHogeAsync(CancellationTokentoken){// CancellationTokenを挿入する// この後の処理でawaitを使用するとawait抜ける際にキャンセル状態がチェックされるようになるawaitYTask.Inject(token);// 無限ループなのでキャンセルしないと終わらないfor(vari=0;;i++){Console.WriteLine(i);// CancellationTokenを渡してなくても勝手にキャンセルされるawaitTask.Delay(1000);}}
実際に動かしてみると以下のような結果になります。
staticasyncTaskMain(string[]args){varcts=newCancellationTokenSource();// 5秒後にキャンセルする_=Task.Run(async()=>{awaitTask.Delay(5000);cts.Cancel();});try{awaitHogeAsync(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変数を使用すれば楽になりますがマルチスレッドになった瞬間崩壊します。
staticasyncYTaskFugaAsync(stringname,CancellationTokentoken){// 最上位の非同期メソッドでstatic領域にInjectするawaitYTask.InjectToStatic(token);awaitTask.Delay(300);// await後にstatic領域のCancellationTokenがFugaAsyncの最初にInjectされたものに戻る// よって複数の非同期メソッドを別々のCancellationTokenで同時に動かしてもシングルスレッドの場合は正常に動作するawaitPiyoAsync(name);}staticasyncYTaskPiyoAsync(stringname){// 下位の非同期メソッドではstatic領域から拾ってきてInjectするawaitYTask.InjectFromStatic();for(vari=0;;i++){Debug.Log($"{name}:{i}");awaitTask.Delay(1000);}}
まとめ
ちょっと思いついたので書いてみましたがよく考えると微妙でしたという感じです。
結局のところ毎回引数にCancellationTokenを渡すのはそういうものだと思って書くのが楽かもしれません。
Unityについてのみ考えるのなら非同期メソッドの先頭で常にGetCancellationTokenOnDestroyで取得したものをInjectするというのもありかもしれません。(うーん...)
asyncYTaskHogeAsync(){awaitYTask.Inject(this.GetCancellationTokenOnDestroy());// FugaAsyncの中でも先頭でInjectしているはずなのでCancellationTokenを渡さないawaitFugaAsync();}asyncYTaskFugaAsync(){awaitYTask.Inject(this.GetCancellationTokenOnDestroy());// 適当な後続処理...}