Loading...
Loading...
Unity async and coroutine correctness patterns. Catches common mistakes with Awaitable double-await, missing cancellation tokens, thread context after BackgroundThreadAsync, coroutine error swallowing, batch mode WaitForEndOfFrame, and Addressables handle leaks. PATTERN format: WHEN/WRONG/RIGHT/GOTCHA. Based on Unity 6.3 LTS documentation.
npx skill4agent add nice-wolf-studio/unity-claude-skills unity-async-patternsPrerequisite skills:(coroutines, Awaitable API, yield types),unity-scripting(destruction timing, destroyCancellationToken)unity-lifecycle
AwaitableAwaitable 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 instanceAwaitableawaitTask.AsTask()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();
}destroyCancellationTokenMonoBehaviourOnDestroyAwaitableCancellationTokenMissingReferenceExceptionOperationCanceledExceptionasync 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()MainThreadAsync()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 returntrycatchAwaitableWaitForEndOfFrameAwaitable.EndOfFrameAsyncIEnumerator 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
}WaitForEndOfFrameEndOfFrameAsync-batchmodeyield return nullAwaitable.NextFrameAsync()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())StopCoroutineyield return StartCoroutine(SubTask())StartCoroutineyield returnyield returnAwaitableCancellationToken// 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 voidSynchronizationContextasync AwaitableStartOnEnableAwaitablevoid// 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" resultsprivate 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();
}AwaitableCancellationTokenSourcedestroyCancellationTokenCancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken)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.LoadAssetAsyncAddressables.ReleaseAddressables.InstantiateAsyncAddressables.ReleaseInstanceDestroyLoadSceneAsync// 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 bridgeWhenAllIUniTaskAsyncEnumerable| Anti-Pattern | Problem | Fix |
|---|---|---|
| Ignores TimeScale, no frame sync | Use |
| Thread pool with no main thread return | Use |
| Nuclear option; stops coroutines you didn't start | Track and stop specific coroutines |
Ignoring return value of | Cannot cancel later | Store the |
| Unclear intent, allocates | Use |
| Task exceptions lost, no destroyCancellationToken integration | Use |