はじめに
今回はUniTask
を使って「タイムアウト処理」を書く方法を紹介します。
先に結論
TimeoutController
を使おう
「タイムアウト」とは
ここでいうタイムアウトとは、「処理が規定時間内に終わらなかったのでasync/await
による非同期処理をキャンセルする」という意味です。
しかし、こちらの記事で「キャンセルには2パターン意味がある」と説明しました。
-
await
自体のキャンセル -
await
している対象が実行している処理自体のキャンセル
UniTask
でタイムアウト処理を書くとき、「この書き方だと何がキャンセルされるのか」をしっかり意識しておく必要があります。
UniTaskで「タイムアウト」の実現方法
次のようなコードがあったとします。
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Timeouts
{
public class MoveSample : MonoBehaviour
{
private async UniTaskVoid Start()
{
transform.position = Vector3.zero;
var token = this.GetCancellationTokenOnDestroy();
Debug.Log("移動開始!");
await MoveAsync(new Vector3(0, 0, 100), token);
Debug.Log("移動終了!");
}
/// <summary>
/// オブジェクトが対象座標に到着するまで移動させる
/// </summary>
private async UniTask MoveAsync(Vector3 targetPosition, CancellationToken ct)
{
while (true)
{
// 座標の差分
var deltaPosition = (targetPosition - transform.position);
// 0.1m以内に近づいていたら終了
if (deltaPosition.magnitude < 0.1f) return;
// 移動速度
var moveSpeed = 1.0f;
// 移動方向
var direction = deltaPosition.normalized;
// 移動させる
transform.position += direction * moveSpeed * Time.deltaTime;
// 1F待つ
await UniTask.Yield(ct);
}
}
}
}
このコードは「オブジェクトを一定速度で目標に向かって移動させる」というMoveAsync()
をUniTask
で実装しています。
このMoveAsync()
を、いろんな方法でタイムアウトさせてみます。
A.TimeoutControllerを使う(推奨)
TimeoutController
は「一定時間が経過した後にキャンセル状態となるCancellationToken
を生成する」機構です。
使い方としては次のとおりです。
-
TimeoutController
を作る -
TimeoutController.Timeout()
から一定時間後にキャンセルされるCancellationToken
を生成する - 必要に応じて既存の
CancellationToken
と合成する -
await
時にこの合成したCancellationToken
を渡す - 正常終了時は
TimeoutController.Reset()
を実行する - 例外発生時は
TimeoutController.IsTimeout()
でタイムアウトかどうかを判定する
private async UniTaskVoid Start()
{
transform.position = Vector3.zero;
// TimeoutControllerを生成
var timeoutController = new TimeoutController();
Debug.Log("移動開始!");
try
{
// TimeoutControllerから指定時間後にキャンセルされるCancellationTokenを生成
var timeoutToken = timeoutController.Timeout(TimeSpan.FromSeconds(1));
// このGameObjectが破棄されたらキャンセルされるCancellationTokenを生成
var destroyToken = this.GetCancellationTokenOnDestroy();
// タイムアウトとDestroyのどちらもでキャンセルするようにTokenを生成
var linkedToken = CancellationTokenSource
.CreateLinkedTokenSource(timeoutToken, destroyToken)
.Token;
// 1秒でタイムアウトさせてみる
await MoveAsync(new Vector3(0, 0, 100), linkedToken);
// 使い終わったらReset()してあげる必要あり
timeoutController.Reset();
}
catch (Exception ex)
{
Debug.LogException(ex);
if (timeoutController.IsTimeout())
{
Debug.LogError("Timeoutによるキャンセルです");
}
}
}
(タイムアウト発生時、Cubeの動きが止まるのと同時にawait
も終わっています)
UniTask
でタイムアウトによりキャンセルを実装する場合は、このTimeoutController
を用いることを推奨します。
理由としては「CancellationToken
による通常でのキャンセル処理が実行できる」「タイムアウトが発生しなかったときにゼロアロケーションで動作する」からです。
なお、今回はCancellationTokenSource.CreateLinkedTokenSource
で合成しましたが、TimeoutController
のコンストラクタにCancellationTokenSource
を渡すやり方でもOKです。
捕捉:CancellationTokenを複数指定するときの注意点
別の条件で発火する複数のCancellationToken
があった場合、CancellationTokenSource.CreateLinkedTokenSource
を使って合成して1つのCancellationToken
にまとめてからawait
対象に渡してください。
CreateLinkedTokenSource
を使うことで「どれか1つでもCancellationToken
が発火したら処理がキャンセルされる」を正しく実現することができます。
// TimeoutControllerから指定時間後にキャンセルされるCancellationTokenを生成
var timeoutToken = timeoutController.Timeout(TimeSpan.FromSeconds(1));
// このGameObjectが破棄されたらキャンセルされるCancellationTokenを生成
var destroyToken = this.GetCancellationTokenOnDestroy();
// タイムアウトとDestroyのどちらもでキャンセルするようにTokenを生成
var linkedToken = CancellationTokenSource
.CreateLinkedTokenSource(timeoutToken, destroyToken)
.Token;
そしてここからが注意点。
UniTask
にはAttachExternalCancellation()
という拡張メソッドがあるのですが、これは基本的に使わないでください。
AttachExternalCancellation
は後述するTimeout()
拡張メソッドと同様に、「await
自体は止めるけど走っている処理自体は止めない」という動作をします。
たとえば、上記のTimeoutController
から作ったCancellationToken
を誤ってAttachExternalCancellation
で指定するとどうなるか。
private async UniTaskVoid Start()
{
transform.position = Vector3.zero;
// TimeoutControllerを生成
var timeoutController = new TimeoutController();
Debug.Log("移動開始!");
try
{
// TimeoutControllerから指定時間後にキャンセルされるCancellationTokenを生成
var timeoutToken = timeoutController.Timeout(TimeSpan.FromSeconds(1));
// このGameObjectが破棄されたらキャンセルされるCancellationTokenを生成
var destroyToken = this.GetCancellationTokenOnDestroy();
// MoveAsync()には destroyToken を渡し、
// AttachExternalCancellation()に timeoutToken を渡してみた
await MoveAsync(new Vector3(0, 0, 100), destroyToken)
.AttachExternalCancellation(timeoutToken);
// 使い終わったらReset()してあげる必要あり
timeoutController.Reset();
}
catch (Exception ex)
{
Debug.LogException(ex);
if (timeoutController.IsTimeout())
{
Debug.LogError("Timeoutによるキャンセルです");
}
}
}
例外がでてタイムアウトが検知できるまでは同じなのですが、ブロックの動きが止まっていません。
つまりMoveAsync()
のawait
は中断できているが、MoveAsync()
の処理そのものは止められていません。
このようにAttachExternalCancellation()
は便利そうに見えますが、安易に使うと処理の止め忘れが起きるので基本的には使わないようにしましょう。
B.CancellationTokenSource.CancelAfterを使う(非推奨)
CancellationTokenSource
には一定時間後にキャンセルするという機能が実は最初から備わっています。
// CancellationTokenを用意
var cts = new CancellationTokenSource();
// 1秒後にキャンセルさせる
// CancelAfterSlimの戻り値はIDisposable
cts.CancelAfter(TimeSpan.FromSeconds(1));
// Timeout用のToken生成
var timeoutToken = cts.Token;
var destroyToken = this.GetCancellationTokenOnDestroy();
using var linkedCts =
CancellationTokenSource.CreateLinkedTokenSource(timeoutToken, destroyToken);
var linkedToken = linkedCts.Token;
try
{
await MoveAsync(new Vector3(0, 0, 100), linkedToken);
}
finally
{
// 終わり際に破棄
cts.Dispose();
}
見た目はTimeoutController
とくらべてシンプルなのですが、Unityに最適化されていません。
そのためCancelAfter
をUnityで使うことは非推奨です。
C.CancellationTokenSource.CancelAfterSlimを使う(やや推奨)
UniTask
にはCancelAfter
を軽量化してUnity向けに最適化したCancelAfterSlim
が用意されています。
そのためCancelAfter
をもし使っている場合は、CancelAfterSlim
に乗り換えましょう(ただ乗り換えるならTimeoutController
の方がゼロアロケーションになるけど)
// CancellationTokenを用意
var cts = new CancellationTokenSource();
// 1秒後にキャンセルさせる
// CancelAfterSlimの戻り値はIDisposable
var disposable = cts.CancelAfterSlim(TimeSpan.FromSeconds(1));
// Timeout用のToken生成
var timeoutToken = cts.Token;
var destroyToken = this.GetCancellationTokenOnDestroy();
var linkedToken = CancellationTokenSource
.CreateLinkedTokenSource(timeoutToken, destroyToken)
.Token;
try
{
await MoveAsync(new Vector3(0, 0, 100), linkedToken);
}
finally
{
// CancelAfterSlim自体も止める必要あり
disposable.Dispose();
cts.Dispose();
}
注意点としてはCancelAfterSlim
が戻り値としてIDisposable
を返すことです。
このIDisposable.Dispose()
をCancellationTokenSource
をDispose()
する前に呼び出す必要があります。
(CancelAfterSlim
側のDispose()
を忘れると、タイムアウト発火後にObjectDisposedException
が飛びます)
D. Timeout()メソッドを使う(非推奨)
UniTask
にはTimeout()
という拡張メソッドが定義されています。
このTimeout()
メソッドを使うと、「await
(相手の処理が終わるのを待つの)を時間で中断する」という動作をします。規定時間が経過後にTimeoutException
を発行します。
気をつけたいポイントは、実行中の処理自体は止まってくれないというところです。
public static async UniTask Timeout(this UniTask task, TimeSpan timeout, DelayType delayType = DelayType.DeltaTime, PlayerLoopTiming timeoutCheckTiming = PlayerLoopTiming.Update, CancellationTokenSource taskCancellationTokenSource = null)
MoveSample
のStart()
メソッドをこのように書き換えて様子を見てみます。
private async UniTaskVoid Start()
{
transform.position = Vector3.zero;
var token = this.GetCancellationTokenOnDestroy();
Debug.Log("移動開始!");
try
{
// 1秒でタイムアウトさせてみる
await MoveAsync(new Vector3(0, 0, 100), token)
.Timeout(TimeSpan.FromSeconds(1));
}
catch (Exception ex)
{
Debug.LogException(ex);
}
Debug.Log("移動終了!");
}
実行開始から1秒後にTimeoutException
が発行されてはいるものの、移動自体は止まらずにずっと動いてしまっています。つまり、await自体は止めたけどMoveAsync()の処理自体は止まっていないということになります。
E. TimeoutWithoutException()メソッドを使う(非推奨)
TimeoutWithoutException()
はTimeout()
の変形で、「Timeout()
と同じ機能」 + 「内部で発生した例外をすべてbool
で返す」という機能になっています。
public static async UniTask<bool> TimeoutWithoutException(this UniTask task, TimeSpan timeout, DelayType delayType = DelayType.DeltaTime, PlayerLoopTiming timeoutCheckTiming = PlayerLoopTiming.Update, CancellationTokenSource taskCancellationTokenSource = null)
戻り値のbool
は「処理が正常に完遂できなかったか」です。
false
の場合は「正常終了」で、true
の場合は「タイムアウト他、例外が起きたなどして何か問題が起きた」です。
private async UniTaskVoid Start()
{
transform.position = Vector3.zero;
var token = this.GetCancellationTokenOnDestroy();
Debug.Log("移動開始!");
// 1秒でタイムアウトさせてみる
var result = await MoveAsync(new Vector3(0, 0, 100), token)
.TimeoutWithoutException(TimeSpan.FromSeconds(1));
if (result)
{
Debug.LogError("何か起きた");
}
else
{
Debug.Log("正常終了!");
}
}
TimeoutWithoutException
はTimeout
の派生なのですが、キャンセル周りの挙動はTimeout
と同じです。
F. UniTask.WhenAny
でタイムアウトさせる(非推奨)
UniTask.Delay
を用意してUniTask.WhenAny
でどっちが先に終わるかを待つというやり方です。
private async UniTaskVoid Start()
{
transform.position = Vector3.zero;
var token = this.GetCancellationTokenOnDestroy();
// 2つUniTaskを用意して
var moveTask = MoveAsync(new Vector3(0, 0, 100), token);
var delayTask = UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
// どっちが先に終わるかを待つ
await UniTask.WhenAny(moveTask, delayTask);
}
が、これは実はTimeout()メソッドの中身と同じことを書いているだけだったりします。
なのでTimeout()
メソッドと同様に、これも非推奨です。
まとめ
- タイムアウトをしたいなら
TimeoutController
を使おう- 既存の
CancellationToken
との合成も忘れずに行うこと -
AttachExternalCancellation()
は使うべきではない
- 既存の
- パフォーマンス上の理由から
CancelAfter
は非推奨- もし使うなら
CancelAfterSlim
の方を使う - ただ
TimeoutController
の方がよりパフォーマンスは良い
- もし使うなら
-
Timeout()
/TimeoutWithoutException()
も非推奨- 名前に釣られて使うと事故る可能性が非常に高い
最後に
ぶっちゃけneuecc氏のこの記事に全部書いてあります。
こちらの記事を参考にまとめなおしました。