mcpField
U
unity-async-patterns

unity-async-patterns

github.com/nice-wolf-studio開発4070 回インストール6 週間前に更新
リポジトリ

unity-async-patterns について

unity-async-patterns は Unity スクリプト向けの正確性パターンをまとめたスキルです。Awaitable、コルーチン、スレッド、キャンセル、Addressables に関する非同期処理の落とし穴と対処法を扱います。

主な機能

  1. 01Awaitable の複数回 await の回避
  2. 02オブジェクト破棄時のキャンセル処理
  3. 03バックグラウンドとメインスレッドの切り替え
  4. 04コルーチンのエラー処理とキャンセル改善
  5. 05Addressables のハンドルと非同期方式の管理

活用シーン

  1. 01オブジェクト破棄中も待機する非同期メソッドの作成
  2. 02バックグラウンド処理後の Unity API 呼び出し
  3. 03コルーチンのエラー消失やバッチモードの停止の調査
  4. 04Unity プロジェクトの非同期方式の選択

MCP 対応

README には MCP との関係は記載されていません。

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/nice-wolf-studio/unity-claude-skills --skill unity-async-patterns

インストール後、Claude Code で/unity-async-patternsを入力して読み込まれたか確認します。

同梱ファイルreferences/async-cancellation-patterns.md

詳細

非同期処理とコルーチンのパターン — 正確性を高めるパターン

前提スキル: unity-scripting(コルーチン、Awaitable API、yield の型)、unity-lifecycle(破棄のタイミング、destroyCancellationToken)

ここで取り上げるのは、テスト中は問題なく動作することが多く、本番環境で問題が発生すると特に危険な非同期処理のバグに対するパターンです。例外が黙って握りつぶされる、await の途中でオブジェクトが破棄される、スレッドコンテキストに違反するといった問題を扱います。


パターン:Awaitable の二重 await

条件:Awaitable インスタンスを保存し、複数回 await する場合

誤り(Claude のデフォルト):

Awaitable task = Awaitable.WaitForSecondsAsync(2f);
await task; // First await -- works
await task; // Second await -- UNDEFINED BEHAVIOR (may complete instantly or throw)

正しい例:

// Awaitable is POOLED -- after the first await completes, the instance is recycled
// Each Awaitable should be awaited exactly once

// If you need to await the same operation from multiple places, use .AsTask():
var task = Awaitable.WaitForSecondsAsync(2f).AsTask();
await task; // Works
await task; // Works -- Task is not pooled

// Or simply create separate Awaitables:
await Awaitable.WaitForSecondsAsync(2f);
await Awaitable.WaitForSecondsAsync(2f); // Fresh instance

注意点:Unity は割り当てを避けるため、Awaitable インスタンスをプールしています。処理が完了すると、インスタンスはプールに戻され、まったく別の処理で再利用される可能性があります。同じインスタンスを二度目に await すると、別の処理の状態を参照したり、即座に完了したり、例外が発生したりすることがあります。複数回安全に await できる Task とは異なります。複数回 await する必要がある場合は .AsTask() を使いますが、割り当てが発生する点に注意してください。


パターン:destroyCancellationToken の指定漏れ

条件:MonoBehaviour で非同期メソッドを記述する場合

誤り(Claude のデフォルト):

async Awaitable Start()
{
    await Awaitable.WaitForSecondsAsync(5f);
    // If object was destroyed during the wait:
    // - MissingReferenceException on next Unity API call
    // - Or worse: silently operates on a "fake-null" object
    transform.position = Vector3.zero;
}

正しい例:

async Awaitable Start()
{
    try
    {
        await Awaitable.WaitForSecondsAsync(5f, destroyCancellationToken);
        transform.position = Vector3.zero;
    }
    catch (OperationCanceledException)
    {
        // Object was destroyed -- this is expected, not an error
    }
}

// For methods that chain multiple awaits:
async Awaitable DoMultiStepWork()
{
    var token = destroyCancellationToken;

    await Awaitable.NextFrameAsync(token);
    ProcessStep1();

    await Awaitable.WaitForSecondsAsync(1f, token);
    ProcessStep2(); // Safe: would have thrown before reaching here if destroyed

    await LoadAssetAsync(token);
    ProcessStep3();
}

注意点:destroyCancellationToken は MonoBehaviour のプロパティで、OnDestroy の開始時にトリガーされます。すべての Awaitable の待機メソッドは、省略可能な CancellationToken を受け取ります。これを指定しないと、オブジェクトが破棄された後も await が正常に完了し、MissingReferenceException が発生します。必ずトークンを渡し、OperationCanceledException を捕捉してください。


パターン:BackgroundThreadAsync の後のスレッドコンテキスト

条件:バックグラウンドスレッドで処理を行った後、Unity API に戻る場合

誤り(Claude のデフォルト):

async Awaitable ProcessData()
{
    await Awaitable.BackgroundThreadAsync();
    var result = HeavyComputation(); // OK: runs on background thread

    // CRASH: Accessing Unity API from background thread
    transform.position = new Vector3(result, 0, 0);
}

正しい例:

async Awaitable ProcessData()
{
    await Awaitable.BackgroundThreadAsync();
    var result = HeavyComputation(); // Runs on background thread

    await Awaitable.MainThreadAsync(); // Switch BACK to main thread
    transform.position = new Vector3(result, 0, 0); // Now safe

    // Can switch back and forth:
    await Awaitable.BackgroundThreadAsync();
    var moreData = AnotherHeavyTask();

    await Awaitable.MainThreadAsync();
    ApplyResults(moreData);
}

注意点:BackgroundThreadAsync() の後は、MainThreadAsync() で明示的に切り替えるまで、その後のコードはすべてスレッドプールのスレッドで実行されます。Unity API(Transform、GameObject、Physics など)はスレッドセーフではありません。バックグラウンドスレッドから呼び出すと、例外が発生したり、状態が破損したりします。MainThreadAsync() はすぐに再開するのではなく、次のフレームのプレイヤーループ更新時に再開します。


パターン:コルーチンでのエラーの握りつぶし

条件:コルーチン内で例外が発生する場合

誤り(Claude のデフォルト):

IEnumerator LoadAndProcess()
{
    yield return LoadData(); // If this throws, coroutine silently stops
    ProcessData();           // Never reached, no error in console (or just a log, no stack)
}

// try/catch doesn't work with yield:
IEnumerator BadErrorHandling()
{
    try
    {
        yield return SomethingDangerous(); // COMPILER ERROR: cannot yield in try block with catch
    }
    catch (Exception e)
    {
        Debug.LogError(e);
    }
}

正しい例:

// Option 1: Use Awaitable instead (proper exception propagation)
async Awaitable LoadAndProcess()
{
    try
    {
        await LoadDataAsync();
        ProcessData();
    }
    catch (Exception e)
    {
        Debug.LogError($"Load failed: {e}");
    }
}

// Option 2: Error handling without yield in the try block
IEnumerator LoadAndProcessCoroutine()
{
    bool success = false;
    Exception error = null;

    // Wrap the yield outside try/catch
    yield return LoadDataCoroutine(result =>
    {
        success = true;
    });

    // Handle errors after the yield
    if (!success)
    {
        Debug.LogError("Load failed");
        yield break;
    }

    ProcessData();
}

注意点:コルーチンでは、C# 言語の制約により、catch 節を持つ try ブロック内に yield return を記述できません。yield されたコルーチン内の例外はコンソールにログ出力されますが、呼び出し元には伝播せず、処理が黙って停止します。呼び出し元のコルーチンは、ネストされたコルーチンが完了したかのように実行を続けます。エラー処理が必要な、失敗する可能性のある操作には Awaitable を使ってください。


パターン:バッチモードでの WaitForEndOfFrame

条件:ヘッドレス環境、サーバー環境、テスト環境で WaitForEndOfFrame または Awaitable.EndOfFrameAsync を使う場合

誤り(Claude のデフォルト):

IEnumerator CaptureScreenshot()
{
    yield return new WaitForEndOfFrame(); // HANGS in batch mode (no rendering)
    var tex = ScreenCapture.CaptureScreenshotAsTexture();
}

// Same issue with Awaitable:
async Awaitable WaitForRender()
{
    await Awaitable.EndOfFrameAsync(); // HANGS in batch mode
}

正しい例:

IEnumerator CaptureScreenshot()
{
    // Check if we're in batch mode
    if (Application.isBatchMode)
    {
        yield return null; // Just wait one frame instead
        Debug.LogWarning("Screenshot not available in batch mode");
        yield break;
    }

    yield return new WaitForEndOfFrame();
    var tex = ScreenCapture.CaptureScreenshotAsTexture();
}

// For tests that need frame advancement without rendering:
IEnumerator TestCoroutine()
{
    yield return null; // Advances one frame (works in all modes)
    // yield return new WaitForFixedUpdate(); // Also works in batch mode
}

注意点:WaitForEndOfFrame と EndOfFrameAsync はレンダリングフェーズを待機します。バッチモード(-batchmode フラグ)、ヘッドレスサーバー、一部のテストランナーではレンダリングが行われないため、これらの yield は完了せず、コルーチンや非同期処理が永久に停止します。どの環境でもフレームの進行を待てる yield return null(次の Update)または Awaitable.NextFrameAsync() を使ってください。


パターン:ネストされたコルーチンのキャンセル

条件:子コルーチンを開始した親コルーチンを停止する場合

誤り(Claude のデフォルト):

Coroutine _mainRoutine;

void Start()
{
    _mainRoutine = StartCoroutine(MainLoop());
}

IEnumerator MainLoop()
{
    StartCoroutine(SubTaskA()); // Launched independently
    StartCoroutine(SubTaskB()); // Launched independently
    yield return new WaitForSeconds(10f);
}

void Cancel()
{
    StopCoroutine(_mainRoutine);
    // SubTaskA and SubTaskB continue running!
}

正しい例:

private Coroutine _mainRoutine;
private Coroutine _subA;
private Coroutine _subB;

IEnumerator MainLoop()
{
    _subA = StartCoroutine(SubTaskA());
    _subB = StartCoroutine(SubTaskB());
    yield return new WaitForSeconds(10f);
}

void Cancel()
{
    // Must stop each coroutine individually
    if (_mainRoutine != null) StopCoroutine(_mainRoutine);
    if (_subA != null) StopCoroutine(_subA);
    if (_subB != null) StopCoroutine(_subB);
}

// Better: yield return child coroutines (parent owns them)
IEnumerator MainLoopBetter()
{
    yield return StartCoroutine(SubTaskA()); // Waits for A, then...
    yield return StartCoroutine(SubTaskB()); // Waits for B
    // Stopping MainLoopBetter also stops the currently-yielded child
}

注意点:StartCoroutine(SubTask()) は、親から独立したコルーチンを開始します。StopCoroutine は指定したコルーチンのみを停止します。ただし、yield return StartCoroutine(SubTask()) とすると、親は子の完了を待ち、親を停止すると、yield された子も停止します。重要な違いは、yield return を付けない StartCoroutine はファイア・アンド・フォーゲットであり、yield return を付けると子は親に所有される点です。複雑なキャンセル階層には、CancellationToken と Awaitable を使うことをおすすめします。


パターン:async void と async Awaitable

使用場面:Unity スクリプトで async メソッドを宣言するとき

誤り(Claude のデフォルト):

// async void: exceptions crash the application with no way to catch them
async void DoWork()
{
    await Awaitable.WaitForSecondsAsync(1f);
    throw new Exception("oops"); // UNHANDLED -- crashes the app
}

void Start()
{
    DoWork(); // No way to catch the exception from here
}

正しい例:

// async Awaitable: proper exception propagation
async Awaitable DoWork()
{
    await Awaitable.WaitForSecondsAsync(1f);
    throw new Exception("oops"); // Propagates to caller
}

async Awaitable Start()
{
    try
    {
        await DoWork(); // Exception caught here
    }
    catch (Exception e)
    {
        Debug.LogError($"Work failed: {e.Message}");
    }
}

// async void is ONLY acceptable for Unity event handlers that require void:
// - Button.onClick handlers
// - UnityEvent callbacks
// Even then, wrap the body in try/catch:
async void OnButtonClicked()
{
    try
    {
        await SaveGameAsync();
    }
    catch (Exception e)
    {
        Debug.LogError(e);
    }
}

注意点:async void メソッドでは例外が SynchronizationContext に伝播し、Unity ではログに記録され、クラッシュにつながる可能性があります。async Awaitable メソッドでは例外が awaiter に伝播するため、適切に try/catch できます。Unity のライフサイクルメソッド(Start、OnEnable など)は Awaitable を返せます。async を使う場合は、void よりこちらを優先してください。


パターン:Awaitable の同時実行による競合状態

使用場面:複数の async 処理が共有状態を変更するとき

誤り(Claude のデフォルト):

// Two async methods writing to the same field
async Awaitable OnClickSearch(string query)
{
    var results = await SearchAsync(query); // User types "cat"
    _displayedResults = results;            // Race: which query wins?
}
// User clicks twice quickly: "cat" then "dog"
// If "dog" returns first, "cat" results overwrite "dog" results

正しい例:

private CancellationTokenSource _searchCts;

async Awaitable OnClickSearch(string query)
{
    // Cancel the previous search
    _searchCts?.Cancel();
    _searchCts?.Dispose();
    _searchCts = new CancellationTokenSource();
    var token = _searchCts.Token;

    try
    {
        var results = await SearchAsync(query, token);
        token.ThrowIfCancellationRequested(); // Check before applying
        _displayedResults = results;          // Only the latest search applies
    }
    catch (OperationCanceledException)
    {
        // Previous search cancelled -- expected
    }
}

void OnDestroy()
{
    _searchCts?.Cancel();
    _searchCts?.Dispose();
}

注意点:単一スレッドでフレームごとに順次実行されるコルーチンとは異なり、複数の Awaitable チェーンはフレームをまたいで交互に実行されることがあります。前の処理をキャンセルするパターンを使うと、最新の処理だけが結果を反映するようになります。破棄時に自動でクリーンアップするには、CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken) を使って CancellationTokenSource のトークンを destroyCancellationToken とリンクしてください。


パターン:Addressables AsyncOperationHandle のリーク

使用場面:Addressables でアセットを読み込み、解放しないとき

誤り(Claude のデフォルト):

async Awaitable LoadEnemy()
{
    var handle = Addressables.LoadAssetAsync<GameObject>("enemy_prefab");
    var prefab = await handle.Task;
    Instantiate(prefab);
    // Handle never released -- memory leak!
}

正しい例:

private AsyncOperationHandle<GameObject> _enemyHandle;

async Awaitable LoadEnemy()
{
    _enemyHandle = Addressables.LoadAssetAsync<GameObject>("enemy_prefab");
    var prefab = await _enemyHandle.Task;
    Instantiate(prefab);
}

void OnDestroy()
{
    // Release when no longer needed
    if (_enemyHandle.IsValid())
        Addressables.Release(_enemyHandle);
}

// For instantiated objects, use Addressables.InstantiateAsync (auto-tracked):
async Awaitable SpawnEnemy()
{
    var handle = Addressables.InstantiateAsync("enemy_prefab", spawnPoint.position, Quaternion.identity);
    var instance = await handle.Task;
    // When done: Addressables.ReleaseInstance(instance) instead of Destroy
}

注意点:Addressables.LoadAssetAsync を呼び出すたびに、参照カウントが増加します。Addressables.Release を呼び出さないと、アセットはメモリに残り続けます。Addressables.InstantiateAsync はインスタンスを自動で追跡するため、Destroy ではなく Addressables.ReleaseInstance を使ってください。Addressables によるシーンの読み込み(LoadSceneAsync)では、シーンのアンロード時に自動で解放されます。インスタンスが残っている状態でハンドルを解放すると、マテリアルがピンク色になったり、表示されなくなったりすることがあります。


パターン:UniTask と Awaitable の選択

使用場面:Unity プロジェクトで async フレームワークを選ぶとき

誤り(Claude のデフォルト):

// Mixing UniTask and Awaitable in the same method
async UniTask DoWork()
{
    await Awaitable.NextFrameAsync(); // Type mismatch: Awaitable in UniTask method
}

正しい例:

// Pick ONE async framework per project:

// === Option A: Awaitable (Unity 6+ built-in) ===
// Pros: No dependencies, integrated with Unity lifecycle, pooled (zero-alloc)
// Cons: Limited utilities (no WhenAll, WhenAny, no channel/queue)
async Awaitable DoWorkAwaitable()
{
    await Awaitable.NextFrameAsync(destroyCancellationToken);
    await Awaitable.WaitForSecondsAsync(1f, destroyCancellationToken);
}

// === Option B: UniTask (third-party: com.cysharp.unitask) ===
// Pros: Rich API (WhenAll, WhenAny, channels), PlayerLoop integration, zero-alloc
// Cons: External dependency, must learn UniTask-specific patterns
async UniTask DoWorkUniTask()
{
    await UniTask.NextFrame(cancellationToken: destroyCancellationToken);
    await UniTask.Delay(1000, cancellationToken: destroyCancellationToken);
    // UniTask extras: WhenAll, WhenAny, Channel, AsyncReactiveProperty
}

// Converting between them (if mixing is unavoidable):
// Awaitable -> UniTask: not directly; use .AsTask() as bridge
// UniTask -> Awaitable: not directly; use .AsTask() as bridge

注意点:Awaitable は Unity 6 以降に組み込まれており、パッケージは不要です。UniTask(com.cysharp.unitask)は、より豊富な機能を備えた成熟したサードパーティーライブラリです。明確な境界を設けずに両方を同じコードベースで混在させないでください。キャンセルのパターン、プーリングの動作、PlayerLoop との連携が異なります。Unity 6 以降を対象にする場合、ほとんどの用途は Awaitable で対応できます。WhenAll、async LINQ、IUniTaskAsyncEnumerable などの高度なパターンが必要な場合は、UniTask を使ってください。


反パターンのクイックリファレンス

反パターン問題修正方法
Unity での await Task.Delay()TimeScale を無視し、フレームと同期しないAwaitable.WaitForSecondsAsync() を使う
Unity の処理で Task.Run() を使うスレッドプールが使われ、メインスレッドに戻らないAwaitable.BackgroundThreadAsync() + MainThreadAsync() を使う
クリーンアップに StopAllCoroutines() を使う強引な手段であり、自分が開始していないコルーチンまで停止するコルーチンを個別に追跡して停止する
StartCoroutine の戻り値を無視する後からキャンセルできないCoroutine 参照を保存する
yield return new WaitForSeconds(0)意図が不明瞭で、割り当てが発生するyield return null を使う(割り当てなし)
MonoBehaviour で async Task メソッドを使うTask の例外が失われ、destroyCancellationToken と連携できないasync Awaitable を使う

関連スキル

  • unity-scripting -- コルーチンの基礎、Awaitable API リファレンス、yield の種類
  • unity-lifecycle -- destroyCancellationToken、オブジェクトの破棄タイミング
  • unity-performance -- async のプロファイリング、割り当ての追跡

追加リソース

コメント