关于 unity-async-patterns
unity-async-patterns 是面向 Unity 脚本开发的正确性模式技能,汇总 Awaitable、协程、线程、取消和 Addressables 的常见异步陷阱及处理方法。适合希望减少异步代码在运行时出错的 Unity 开发者。
主要功能
- 01避免 Awaitable 重复等待问题
- 02处理对象销毁时的取消
- 03在后台线程与主线程间切换
- 04改善协程异常处理与取消
- 05管理 Addressables 句柄及异步框架
适用场景
- 01编写对象销毁期间仍在等待的异步方法
- 02处理后台计算后对 Unity API 的访问
- 03排查协程异常被吞或批处理挂起
- 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 —— 异步性能分析、分配跟踪