mcpField
U
unity-async-patterns

unity-async-patterns

github.com/nice-wolf-studio开发4070 次安装6 周前更新
代码仓库

关于 unity-async-patterns

unity-async-patterns 是面向 Unity 脚本开发的正确性模式技能,汇总 Awaitable、协程、线程、取消和 Addressables 的常见异步陷阱及处理方法。适合希望减少异步代码在运行时出错的 Unity 开发者。

主要功能

  1. 01避免 Awaitable 重复等待问题
  2. 02处理对象销毁时的取消
  3. 03在后台线程与主线程间切换
  4. 04改善协程异常处理与取消
  5. 05管理 Addressables 句柄及异步框架

适用场景

  1. 01编写对象销毁期间仍在等待的异步方法
  2. 02处理后台计算后对 Unity API 的访问
  3. 03排查协程异常被吞或批处理挂起
  4. 04选择 Unity 项目的异步方案

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();
}

注意:在协程中,yield return 不能出现在带有 catch 子句的 try 块中(C# 语言限制)。被 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 脚本中声明异步方法

错误(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 方法会将异常传播给等待方,便于正确使用 try/catch。Unity 的生命周期方法(Start、OnEnable 等)可以返回 Awaitable——使用 async 时,优先选择这种方式,而不是 void。


模式:Awaitable 并发竞态条件

何时:多个异步操作修改共享状态

错误(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 会自动跟踪实例——请使用 Addressables.ReleaseInstance,而不是 Destroy。使用 Addressables 加载场景(LoadSceneAsync)时,场景卸载会自动释放资源。释放仍有活动实例的句柄可能导致材质呈现为粉色或缺失。


模式:选择 UniTask 还是 Awaitable

何时:为 Unity 项目选择异步框架

错误(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、异步 LINQ 或 IUniTaskAsyncEnumerable 等高级模式,请使用 UniTask。


反模式速查

反模式问题修复方法
在 Unity 中使用 await Task.Delay()忽略 TimeScale,无法与帧同步使用 Awaitable.WaitForSecondsAsync()
使用 Task.Run() 执行 Unity 计算在线程池中运行,无法返回主线程使用 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 —— 异步性能分析、分配跟踪

其他资源

评论

支持 Markdown