はじめに
今回はUnityにおけるコルーチンをどうUniTaskに置き換える上で気をつけるべき点を紹介します。
以前、【C#】async/awaitのキャンセル処理まとめ
という記事を書いたのですが、この記事は「一般的なC#の話」となっています。
今回はもうちょっと話を「Unity寄り」かつ「初学者向け」にしています。
対象
対象者
- Unityのコルーチンは使えるが、async/await,UniTaskはわからないという人
なお、次の話はしません。
- async/awaitの文法について(触れません)
- UniTaskが提供するとコルーチン互換の機能について(触れません)
コルーチンからUniTaskへ
コルーチンからUniTaskに乗り換える上での注意点
コルーチンからUniTaskに乗り換える上で何を一番気をつけるべきか。それは「処理の寿命」です。コルーチンはMonoBehaviour
に紐づいて動作します。これは寿命管理をサボってもGameObjectの破棄と同時に処理が勝手に停止するということを意味します。そのためコルーチンを使う上で「寿命」を意識したことが無い人も多いかもしれません。
しかし、UniTaskにおいてはGameObjectを破棄しても勝手に処理は止まりません。UniTaskを用いたすべての処理をの寿命を常に意識し、必要に応じて処理を止められるように書く必要があります。
コルーチンをそのままUniTaskに置き換えるとどうなるのか
たとえば次のようなコードがあったとします。
using System.Collections;
using UnityEngine;
namespace Sample.Coroutines
{
public class CubeMover1 : MonoBehaviour
{
private void Start()
{
// コルーチンの起動
StartCoroutine(MoveLoopCoroutine());
}
/// <summary>
/// 指定したポイントを周回し続ける
/// </summary>
private IEnumerator MoveLoopCoroutine()
{
// 無限にループし続ける
while (true)
{
// (0,0,5)に移動するのを待つ
yield return MoveToCoroutine(new Vector3(0, 0, 5), 3f);
// (5,0,5)に移動するのを待つ
yield return MoveToCoroutine(new Vector3(5, 0, 5), 3f);
// (5,0,0)に移動するのを待つ
yield return MoveToCoroutine(new Vector3(5, 0, 0), 3f);
// (0,0,0)に移動するのを待つ
yield return MoveToCoroutine(new Vector3(0, 0, 0), 3f);
}
}
/// <summary>
/// 目的地に向かって一定速度で移動する
/// </summary>
private IEnumerator MoveToCoroutine(Vector3 target, float speed)
{
// 目的地値に到達するまで無限ループする
while (true)
{
var currentPosition = transform.position;
var delta = target - currentPosition;
var distance = delta.magnitude;
if (distance < speed * Time.deltaTime)
{
// ゴールに十分近いならゴールに移動して終了
transform.position = target;
// コルーチン終了
yield break;
}
else
{
// ゴールまで距離があるなら一定速度で移動
var direction = delta.normalized;
transform.position += direction * (speed * Time.deltaTime);
// 1フレーム待機する
yield return null;
}
}
}
private void OnGUI()
{
if (GUI.Button(new Rect(0, 0, 150, 50), "Destroy"))
{
Destroy(gameObject);
}
}
}
}
このコードは「基本的にコルーチンは無限ループで動作する(中断を考えない)」「GameObject
が破棄されると同時にコルーチンも停止する」という想定で書かれています。
実際にこの実装は問題なく動作し、Destroy
が実行されたとしてもエラーなく処理が止まります。
では、これとほぼ同等の処理をUniTask(+ async/await
)でほぼそのまま書き直してみます。
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Sample.Coroutines
{
public class CubeMover2Bad : MonoBehaviour
{
private void Start()
{
MoveLoopAsync().Forget();
}
/// <summary>
/// 指定したポイントを周回し続ける
/// </summary>
private async UniTask MoveLoopAsync()
{
// 無限にループし続ける
while (true)
{
// (0,0,5)に移動するのを待つ
await MoveToAsync(new Vector3(0, 0, 5), 3f);
// (5,0,5)に移動するのを待つ
await MoveToAsync(new Vector3(5, 0, 5), 3f);
// (5,0,0)に移動するのを待つ
await MoveToAsync(new Vector3(5, 0, 0), 3f);
// (0,0,0)に移動するのを待つ
await MoveToAsync(new Vector3(0, 0, 0), 3f);
}
}
/// <summary>
/// 目的地に向かって一定速度で移動する
/// </summary>
private async UniTask MoveToAsync(Vector3 target, float speed)
{
// 目的地値に到達するまで無限ループする
while (true)
{
var currentPosition = transform.position;
var delta = target - currentPosition;
var distance = delta.magnitude;
if (distance < speed * Time.deltaTime)
{
// ゴールに十分近いならゴールに移動して終了
transform.position = target;
// 終了
return;
}
else
{
// ゴールまで距離があるなら一定速度で移動
var direction = delta.normalized;
transform.position += direction * (speed * Time.deltaTime);
// 1フレーム待機する
await UniTask.Yield();
}
}
}
private void OnGUI()
{
if (GUI.Button(new Rect(0, 0, 150, 50), "Destroy"))
{
Destroy(gameObject);
}
}
}
}
この実装はコルーチンをただ機械的にUniTaskに置換しただけになります。
そしてこの実装でも一見問題なく動作します。
しかし、この実装ではGameObjectが破棄されたときに問題が発生します。
このように、GameObjectが破棄されたタイミングで例外が発生してしまいました。
内容としては「MissingReferenceException
」、つまりは「対象としているオブジェクトが無くなった」です。
エラーはMoveToAsync
の「transform.position
」にアクセスした瞬間に発生しました。
private async UniTask MoveToAsync(Vector3 target, float speed)
{
// 目的地値に到達するまで無限ループする
while (true)
{
// ここの「transform」が破棄されたことが原因
var currentPosition = transform.position;
var delta = target - currentPosition;
var distance = delta.magnitude;
コルーチンはMonoBehaviour
が破棄された(GameObjectがDestroy
された)タイミングで実行自体が停止します。
しかしUniTaskは処理そのものはGameObjectと独立して動作しています。GameObjectがDestroy
されたとしても、UniTaskの処理は関係なく継続してしまいます。そのため「破棄されたオブジェクトにアクセスしてしまい、エラーが起きた」という状態になってしまいました。
対策(寿命をどう制御すればいいのか)
では、コルーチンで書かれたコードをUniTaskに置き換えるときにどうしたらいいのか。(またはこれからUniTaskを使ってコードを書こうとしている人が、何に気をつければよいのか)
それは「処理の中断」をできるようにすることです。実行中のUniTaskの処理に対して「今GameObjectが破棄されたからすぐ処理を止めて!!!!」ということを伝えられる仕組みにしておく必要があります。
そのために用いると便利なオブジェクトがCancellationToken
です。
CancellationToken
CancellationToken
はC#標準で用意されているオブジェクトで、名前のとおり「処理を中止(キャンセル)したいときに使うトークン」です。
CancellationToken
を作る方法はいくつかありますが、一旦ここでは「コルーチンと同等の機能を持たせる」ことに的を絞ります。
コルーチンと同様に「GameObjectが破棄されたら処理が止まってほしい」としたい場合、this.GetCancellationTokenOnDestroy()
を使うのがオススメです。
private void Start()
{
// CancellationTokenの取得
CancellationToken ct = this.GetCancellationTokenOnDestroy();
// この↓に処理が続くとして
// ...
}
this.GetCancellationTokenOnDestroy()
はUniTaskが提供する拡張メソッドで、「このGameObjectの破棄時にキャンセル扱いになるCancellationToken
」を作ってくれます。
そのためコルーチンと同等の挙動を持たせたいなら、ひとまずこのthis.GetCancellationTokenOnDestroy()
を使いましょう。
備考: destroyCancellationToken
Unity 2022.2
からdestroyCancellationToken
がMonoBehaviour
に追加されたのでこっちを使ってもいいです。
private void Start()
{
// CancellationTokenの取得
CancellationToken ct = destroyCancellationToken;
// この↓に処理が続くとして
// ...
}
ちなみにthis.GetCancellationTokenOnDestroy()
を呼び出した場合destroyCancellationToken
が使えるなら勝手にそっちを使うようになっています。
CancellationTokenを使った処理の中断
CancellationToken
を使った処理の中断方法は2パターンあります。
A. CancellationTokenをawaitする相手に渡す
一番王道かつ、間違い無いのがこの手法です。
await
するときにCancellationToken
を相手に渡すことを徹底するというものです。
async/await
本来のキャンセルに則った手法のため間違いはありません。
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Sample.Coroutines
{
public class CubeMover2A : MonoBehaviour
{
private void Start()
{
// CancellationTokenの取得
var ct = this.GetCancellationTokenOnDestroy();
MoveLoopAsync(ct).Forget();
}
/// <summary>
/// 指定したポイントを周回し続ける
/// </summary>
private async UniTask MoveLoopAsync(CancellationToken ct)
{
// 無限にループし続ける
while (true)
{
// (0,0,5)に移動するのを待つ
await MoveToAsync(new Vector3(0, 0, 5), 3f, ct);
// (5,0,5)に移動するのを待つ
await MoveToAsync(new Vector3(5, 0, 5), 3f, ct);
// (5,0,0)に移動するのを待つ
await MoveToAsync(new Vector3(5, 0, 0), 3f, ct);
// (0,0,0)に移動するのを待つ
await MoveToAsync(new Vector3(0, 0, 0), 3f, ct);
}
}
/// <summary>
/// 目的地に向かって一定速度で移動する
/// </summary>
private async UniTask MoveToAsync(Vector3 target, float speed, CancellationToken ct)
{
// 目的地値に到達するまで無限ループする
while (true)
{
var currentPosition = transform.position;
var delta = target - currentPosition;
var distance = delta.magnitude;
if (distance < speed * Time.deltaTime)
{
// ゴールに十分近いならゴールに移動して終了
transform.position = target;
// 終了
return;
}
else
{
// ゴールまで距離があるなら一定速度で移動
var direction = delta.normalized;
transform.position += direction * (speed * Time.deltaTime);
// !!ここ!!
await UniTask.Yield(ct);
}
}
}
private void OnGUI()
{
if (GUI.Button(new Rect(0, 0, 150, 50), "Destroy"))
{
Destroy(gameObject);
}
}
}
}
わかりにくいかもしれませんが、一番重要なのはUniTask.Yield
にCancellationToken
を渡しているところです。
await UniTask.Yield(ct);
こうすることで、GameObjectが破棄されたときにキャンセル例外が発行されて自動的にUniTaskの処理が停止します。
(なおこのときに発行される例外OperationCancelledException
はUniTaskが自動的にもみ消してくれるため、コンソールには表示されないようになっています)
この辺の仕組みはかなりややこしいので、詳しく知りたい人はこの記事をご覧ください。
B. CancellationTokenをみて手動で止める
Aの手法がasync/await
的に正しい止め方ではあるんですが、こちらの手法でも処理は停止できます。
どうするかというと、すごく単純で「CancellationToken
をみて処理を止める」というだけです。
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Sample.Coroutines
{
public class CubeMover2B : MonoBehaviour
{
private void Start()
{
var ct = this.GetCancellationTokenOnDestroy();
MoveLoopAsync(ct).Forget();
}
/// <summary>
/// 指定したポイントを周回し続ける
/// </summary>
private async UniTask MoveLoopAsync(CancellationToken ct)
{
// キャンセルされるまで実行し続ける
while (!ct.IsCancellationRequested)
{
// (0,0,5)に移動するのを待つ
await MoveToAsync(new Vector3(0, 0, 5), 3f, ct);
// キャンセルされていたら終わる
if(ct.IsCancellationRequested) return;
// (5,0,5)に移動するのを待つ
await MoveToAsync(new Vector3(5, 0, 5), 3f, ct);
// キャンセルされていたら終わる
if(ct.IsCancellationRequested) return;
// (5,0,0)に移動するのを待つ
await MoveToAsync(new Vector3(5, 0, 0), 3f, ct);
// キャンセルされていたら終わる
if(ct.IsCancellationRequested) return;
// (0,0,0)に移動するのを待つ
await MoveToAsync(new Vector3(0, 0, 0), 3f, ct);
}
}
/// <summary>
/// 目的地に向かって一定速度で移動する
/// </summary>
private async UniTask MoveToAsync(Vector3 target, float speed, CancellationToken ct)
{
// 目的地値に到達する or キャンセルされるまで実行し続ける
while (!ct.IsCancellationRequested)
{
var currentPosition = transform.position;
var delta = target - currentPosition;
var distance = delta.magnitude;
if (distance < speed * Time.deltaTime)
{
// ゴールに十分近いならゴールに移動して終了
transform.position = target;
// 終了
return;
}
else
{
// ゴールまで距離があるなら一定速度で移動
var direction = delta.normalized;
transform.position += direction * (speed * Time.deltaTime);
// 1フレーム待機する
await UniTask.Yield();
}
}
}
private void OnGUI()
{
if (GUI.Button(new Rect(0, 0, 150, 50), "Destroy"))
{
Destroy(gameObject);
}
}
}
}
注目するべきは、while
文の部分です。
// キャンセルされるまで実行し続ける
while (!ct.IsCancellationRequested)
{
// (0,0,5)に移動するのを待つ
await MoveToAsync(new Vector3(0, 0, 5), 3f, ct);
// キャンセルされていたら終わる
if(ct.IsCancellationRequested) return;
// (5,0,5)に移動するのを待つ
await MoveToAsync(new Vector3(5, 0, 5), 3f, ct);
// キャンセルされていたら終わる
if(ct.IsCancellationRequested) return;
// (5,0,0)に移動するのを待つ
await MoveToAsync(new Vector3(5, 0, 0), 3f, ct);
// キャンセルされていたら終わる
if(ct.IsCancellationRequested) return;
// (0,0,0)に移動するのを待つ
await MoveToAsync(new Vector3(0, 0, 0), 3f, ct);
}
}
// 目的地値に到達する or キャンセルされるまで実行し続ける
while (!ct.IsCancellationRequested)
{
var currentPosition = transform.position;
var delta = target - currentPosition;
var distance = delta.magnitude;
if (distance < speed * Time.deltaTime)
{
// ゴールに十分近いならゴールに移動して終了
transform.position = target;
// 終了
return;
}
else
{
// ゴールまで距離があるなら一定速度で移動
var direction = delta.normalized;
transform.position += direction * (speed * Time.deltaTime);
// 1フレーム待機する
await UniTask.Yield();
}
}
CancellationToken.IsCancellationRequested
(bool値)をみることで、処理のキャンセルが要求されているかを調べることができます。これをみて処理を早期returnしたり、ループを止めることで処理を中断するという手法です。
Aの方法と比べると単純でわかりやすいし、キャンセル時に例外が飛ばないので若干低負荷です。
AとBどっちを使えばいいのか
- 基本的にはAの手法(
await
対象にCancellationToken
を渡す)の方が安全 - Bの手法(
CancellationToken
をみて手動で止める)はパフォーマンス的に若干軽いが、即座に止まってくれるわけではない
Aの手法は処理の中断時に例外(OperationCanceledException
)が飛びます。そのため勝手に大域脱出して処理が止まります。そのためAはキャンセル要求をすると基本的にほぼ即座に処理が止まってくれます(実装による場合もあるが、UniTask.Delay
などはすぐ止まってくれる)。
// 1秒ごとに1mずつワープする
private async UniTask WarpIntervalAsync(CancellationToken token)
{
while (true)
{
// キャンセル要求時に即座に停止してくれる
await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
// キャンセル要求時はここに到達しない(はず)
transform.position += Vector3.one;
}
}
一方のBは「キャンセル時は強制的に処理を終了させる(正常終了とする)」というやり方です。
そのため中断時に例外が飛ばないためパフォーマンス的に若干軽くなります。
しかしこの手法は「CancellationToken
を手動で評価するまで処理が中断できない」「自分でメソッドから都度returnする必要がある」という欠点もあります。
たとえば、次のようなコードを書いてしまうと、MissingReferenceException
が発生してしまいます。
// 1秒ごとに1mずつワープする
private async UniTask WarpIntervalAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
// キャンセルが要求されててもこの処理はやりきってしまう
await UniTask.Delay(TimeSpan.FromSeconds(1));
// キャンセル要求時でもここを実行してしまう可能性が非常に高い
transform.position += Vector3.one;
}
}
この実装をどうしてもBの手法で止めたいのなら、「await
の順序をwhile
の最後に持ってくる」や「await
直後にCancellationToken
を評価してbreak
する」などが必要でしょう。
まとめるとこうなります。
-
Aの方が汎用的かつ間違いは起きにくい
- ただし中断時に発行される例外と付き合う必要あり
-
C#
的には「この例外をちゃんとハンドリングしよう」が正しい
-
- 例外で大域脱出できるので逆にキャンセル処理が楽になることもある
- ただし中断時に発行される例外と付き合う必要あり
-
Bの方はシンプルである意味でわかりやすい
- 中断時に例外が飛ばないので若干軽い
- 例外のハンドリングを意識する必要がない
- しかし、確実に処理が止まるかどうかは書き方次第になってしまう
- 中断時に例外が飛ばないので若干軽い
覚えることが多く煩雑だが事故りにくいのはA。シンプルでとっつきやすいが事故る可能性があるのはB。という感じでしょうか。
どっちが初心者向けとも言い難いです。
AとBの併用はよいのか
AとBを併用すること自体は問題ありません。
ですが、Bの方が終了時に例外が出ないという点で低コストではあるので、「Bのみで書いて問題ないほど処理がシンプルなら、Bで書いたほうがよい」です。メソッドの内容が複雑になるなら大域脱出を使ったほうがよいのでAも混ぜて書いてOKです。
ただ、AとBをごちゃまぜに書くと「キャンセルしたときにキャンセル例外が飛んだり飛ばなかったりする」という状況になってハンドリングがややこしくなる場合があるので注意。「基本はAを使う。すごく単純な場合のみBを使う。」みたいなルールを設けたほうがよいかも。
// 併用パターン(この例だとBだけに寄せた方がいいかも)
private async UniTask HogeAsync(CancellationToken token)
{
// while文に指定
while (!token.IsCancellationRequested)
{
transform.position += Vector3.forward * Time.deltaTime;
// awaitにも渡す
await UniTask.Yield(token);
}
}
まとめ
- コルーチンとUniTask(
async/await
)の記法は似ている- ただし機械的に置換すればよいわけではない
- コルーチンは勝手に止まるが、UniTaskは止まってくれない
-
GameObject
を破棄すればコルーチンは勝手にとまる - `UniTaskは常に手動で止める必要がある
-
- UniTask(
async/await
)を止めるにはCancellationToken
を使うのが一般的-
MonoBehaviour
上ではthis.GetCancellationTokenOnDestroy()
を使うとよい
-
-
CancellationToken
でUniTaskを止める方法は2つある- A: 「
await
対象にCancellationToken
を渡していく」- C#的にはこっちがセオリー
- 詳しくは「【C#】async/awaitのキャンセル処理まとめ」を参考
- B: 「
CancellationToken
を手動でチェックして処理を止める」- シンプルでわかりやすいが、事故る可能性も高い
- パフォーマンス的にはAより有利ではある
- AとBのどっちが初心者向けか、とかは言い難い
- 両方覚えておいて状況に応じて使い分ける、がおそらく正解
- 迷ったらとりあえず「Aの手法」で書いた方がよい
- A: 「
以下ポエム
この記事を書こうと思った発端は「初心者にいきなりUniTaskを教えるべきか、それともコルーチンから触らせるべきか」で議論が起きたからです。
コルーチンよりもUniTaskの方が圧倒的に機能が多く便利で汎用性も高いため、どっちか片方しか触らないのであれば「UniTask」を選ぶでしょう。
ですがUniTaskは学習する要素が多く、とくに「キャンセル周り」が鬼門だと思っています。一方のコルーチンは「適当に書いてもGameObjectが破棄されたり、シーン遷移したら止まる」という性質があります。そのためコルーチンは初心者が書いてもキャンセル周りで事故が起きにくく、安心感はあります。
「学習コストが若干高く挙動を理解するまでが大変なUniTask」か「とりあえず雑に書いても動くし事故も起きにくいコルーチン」のどちらかを初心者に教えるかと言われたら、自分は「コルーチン」かなぁと考えてはいました。(そもそもUniTaskを多用したゴリゴリの非同期処理を初心者が必要とするとは思えず、火力過剰になるのでまずはコルーチンからで良いのでは?とも)
とはいえど「コルーチンから触り始めたとしても、最終的にはUniTaskに乗り換えてほしい」という気持ちです。何だかんだでコルーチンって機能不足なので、凝ったことやろうとすると途端に辛くなるので。そうなったときに(コルーチンで覚えたことを活かして)UniTask(async/await
)にステップアップしてほしいなと。
その時の助けになるんじゃないかな?という意図でこの記事を書きました。
追記
Unity 2023.1からAwaitable APIが追加されるのでそっちも気になる所です。