Unity開発において、非同期処理(ファイルのロード、サーバー通信、時間経過による演出など)は欠かせない要素です。従来のコルーチンに代わり、近代的なC#の async/await や、Unityに最適化された高速な非同期ライブラリである UniTask を用いる開発者が増えています。
しかし、非同期処理を導入したプロジェクトで非常に頻繁に発生するのが、「オブジェクト破棄後の MissingReferenceException や ObjectDisposedException」です。シーン遷移や Destroy() によってスクリプトがアタッチされたGameObjectが消滅したにもかかわらず、バックグラウンドの非同期処理が走り続け、破棄済みのUIやTransformへアクセスしようとすることが原因です。
本記事では、このエラーが発生する仕組みを解説し、CancellationToken を用いて安全に非同期処理のライフサイクルを管理・中断するための実装手順とベストプラクティスを詳しく解説します。
1. 根本原因:C#の非同期スレッド/ループとUnityオブジェクトの「寿命のズレ」
Unityの MonoBehaviour は、ゲームオブジェクトが破棄されるとライフサイクルが終了し、C++側のネイティブメモリから削除されます。しかし、C#で実行された Task や UniTask は、Unityのオブジェクトの寿命(GameObject.Destroy)とは関係なく、C#ランタイム上で稼働し続けます。
図:GameObject破棄後も非同期処理が走り続け、ヌル参照エラーをスローするメカニズム
非同期メソッド内で await している間にGameObjectが破棄されると、待機完了後に以下のような問題が発生します:
- MissingReferenceExceptionの発生:
awaitが明けると、メソッドは元のコンテキストで処理を再開します。ここでthis.gameObjectやアタッチされたコンポーネント(TextやImageなど)を参照しようとすると、Unityエンジンは「すでにネイティブ側が破棄されている」と判定し、エラーをスローします。 - 非同期タスクのリーク: 処理がエラーで中断されるまで、あるいは終了するまでタスクがメモリ上に残り続けます。シーン遷移を繰り返すたびに古いシーンの通信タスクやループ処理が裏で何重にも走り続け、メモリやCPUリソースを無駄に消費します。
- バグの原因となる意図しないデータ更新: 破棄されたはずのクラスが、裏で完了した通信結果を元にグローバルなデータマネージャー(セーブデータ、インベントリなど)を書き換えてしまい、ゲームデータの不整合(グリッチ)を引き起こします。
2. 解決策①:GetCancellationTokenOnDestroy() を用いた基本対策
Unityで非同期処理の漏れを防ぐ最も標準的かつ確実なアプローチは、CancellationToken(キャンセル用トークン) を非同期処理に引き渡し、オブジェクトが破棄された瞬間に処理を打ち切ることです。
UniTask は、MonoBehaviour に GetCancellationTokenOnDestroy() という拡張メソッドを提供しており、オブジェクトの OnDestroy イベントに連動したトークンを簡単に取得できます。
最もシンプルな実装例(UniTask)
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
public class AsyncLoader : MonoBehaviour
{
[SerializeField] private Text statusText;
private void Start()
{
// ライフサイクルに紐づくCancellationTokenを渡して非同期処理を開始
LoadDataAsync(this.GetCancellationTokenOnDestroy()).Forget();
}
private async UniTaskVoid LoadDataAsync(CancellationToken cancellationToken)
{
try
{
statusText.text = "Loading...";
// 1. 非同期待機にCancellationTokenを渡す
await UniTask.Delay(TimeSpan.FromSeconds(3), cancellationToken: cancellationToken);
// 待機中にこのGameObjectがDestroyされた場合、これ以降のコードは実行されません
statusText.text = "Load Completed!";
}
catch (OperationCanceledException)
{
// キャンセルされた時のクリーンアップ処理(必要に応じて記述)
Debug.Log("データロードがキャンセルされました(オブジェクト破棄のため)");
}
}
}
挙動の解説: await UniTask.Delay の待機中にGameObjectが破棄されると、GetCancellationTokenOnDestroy() がキャンセル状態になります。すると、await の地点で即座に OperationCanceledException がスローされ、その後に続く statusText.text = "Load Completed!"; という破棄済みオブジェクトへのアクセスを完全にスキップして処理を終了できます。
3. 解決策②:OperationCanceledException をスマートに扱う
CancellationTokenによるキャンセルは、C#の仕様上「例外(OperationCanceledException)のスロー」によって実現されます。しかし、Unityのデバッグログにキャンセル時の例外が赤いエラーログ(Exception)として出力されるのを防ぎたいケースがあります。
UniTask には、例外をスローせずにキャンセルを検知する便利な機能が用意されています。
1. SuppressCancellationThrow を使う
await するメソッドの後ろに .SuppressCancellationThrow() を付けることで、例外をスローする代わりに bool 値(IsCanceled)を受け取ることができます。
private async UniTaskVoid LoadDataSafeAsync(CancellationToken cancellationToken)
{
statusText.text = "Loading...";
// 例外をスローせず、戻り値でキャンセルされたかを受け取る
bool isCanceled = await UniTask.Delay(TimeSpan.FromSeconds(3), cancellationToken: cancellationToken)
.SuppressCancellationThrow();
// キャンセルされていた場合は安全に処理を終了
if (isCanceled)
{
return;
}
// ここはオブジェクトが健全な場合のみ実行される
statusText.text = "Load Completed!";
}
この手法を使うと、try-catch ブロックを使わずにすむためコードがすっきりし、エディタのログを綺麗に保つことができます。
2. UniTaskVoid の Forget() の安全な例外処理
UniTaskVoid メソッドを .Forget() で呼び出した際、内部で発生した OperationCanceledException は UniTask が自動的に無視(ログ出力しない)するようになっています。しかし、その他の想定外のバグ(NullReferenceExceptionなど)は通常通りログに出力されるため、安心して使用できます。
4. ありがちなバッドプラクティスと回避策
1. 独自の CancellationTokenSource の解放漏れ
MonoBehaviourの破壊時以外(例えば「ボタンを押して手動で処理をキャンセルしたい」ときなど)は、自分で CancellationTokenSource を生成して使用します。この際、Dispose() を呼び忘れるとメモリリークの原因になります。
// BAD: Disposeが呼ばれずリソースがリークする
private CancellationTokenSource cts;
private void StartTask()
{
cts = new CancellationTokenSource();
DoSomethingAsync(cts.Token).Forget();
}
private void CancelTask()
{
cts?.Cancel();
// Dispose() が抜けている!
}
対策: キャンセル処理、あるいはクラスの破棄時に必ず Dispose() を呼び出してください。また、再利用する際は cts.Dispose(); cts = null; として古いソースを確実に破棄します。
2. 標準の Task.Delay にキャンセルを渡し忘れる
UniTask ではなく、C#標準の System.Threading.Tasks.Task を使用する場合、Task.Delay や Task.Run に cancellationToken を渡すのを忘れてしまうミスが多発します。
// BAD: トークンを引数に入れているが、肝心のawait先に入れていないためキャンセルされない
private async Task LoopTask(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(1000); // <- トークンの渡し忘れ!
Debug.Log("Tick"); // オブジェクトが破棄されても動き続ける
}
}
対策: 非同期の待機処理には漏れなく ct または cancellationToken を引数として設定してください(例:await Task.Delay(1000, ct))。
3. キャンセル確認(IsCancellationRequested)の挟み忘れ
await を使用しない長時間のループ処理(数万回の配列計算など)をおこなう場合、await の地点以外では自動的にキャンセル処理が走りません。
対策: CPU負荷の高い計算ループ内などでは、定期的に cancellationToken.ThrowIfCancellationRequested() を呼び出し、手動でキャンセル要求を検知して終了させてください。
private async UniTask HeavyCalculationAsync(CancellationToken ct)
{
for (int i = 0; i < 100000; i++)
{
// 処理の途中でキャンセル要求があれば例外を投げて抜ける
ct.ThrowIfCancellationRequested();
// 重い処理
DoHeavyWork(i);
if (i % 1000 == 0)
{
await UniTask.Yield(PlayerLoopTiming.Update, ct);
}
}
}
5. まとめ:async/await と UniTask のキャンセル手法比較
| 比較項目 | 標準 async/await (Task) | UniTask |
|---|---|---|
| 破棄トークンの取得 | 手動で OnDestroy() にて cts.Cancel() を呼ぶ必要あり |
this.GetCancellationTokenOnDestroy() で1行で取得可能 |
| アロケーション (GC Alloc) | Taskオブジェクト生成やスレッドプール切り替えにより発生 | 値型(Struct)ベースの設計によりほぼゼロ(Zero Alloc) |
| キャンセル例外の抑制 | 不可(必ず try-catch でキャッチが必要) |
.SuppressCancellationThrow() で例外なしの制御が可能 |
| Unityライフサイクル連携 | なし(完全にC#の標準仕様に基づく) | 密接(PlayerLoop、CoroutineのUniTask化、各種コライダ連携など) |
非同期処理は強力な武器ですが、「GameObjectが消えても非同期処理は自動で消えない」という鉄則を意識することが極めて重要です。GetCancellationTokenOnDestroy() を基本とし、適切にトークンを渡していく設計を標準化することで、バグのない堅牢なゲーム開発を実現してください。