async/await in Practice: Unity & UniTask
Unity 老牌的异步做法是 协程(Coroutine),用 C# 的 IEnumerator + yield return 实现:
IEnumerator WaitAndDo()
{
Debug.Log("开始");
yield return new WaitForSeconds(2f); // ← 等 2 秒
Debug.Log("2 秒后");
yield return new WaitUntil(() => Input.GetKeyDown(KeyCode.Space)); // ← 等条件
Debug.Log("空格按下");
yield return SceneManager.LoadSceneAsync("Level2"); // ← 等异步操作
Debug.Log("场景加载完");
}
IEnumerator,靠 yield return “等”。yield return new WaitForSeconds(2f) = 等 2 秒。yield return 一个 AsyncOperation/CustomYieldInstruction = 等它完成。yield return null = 等到下一帧。关键点:协程不阻塞主线程。每次 yield return,控制权就交还给 Unity,主线程继续跑这一帧的其它逻辑(渲染、其它协程、Update)。下一帧 Unity 再从 yield 的地方推它继续。
这和 async/await 的味道非常像——都是”暂停再恢复”。这不是巧合,见 B03。
协程必须挂在 MonoBehaviour 上启动:
void Start()
{
StartCoroutine(WaitAndDo()); // ← 启动协程
}
StartCoroutine 把协程注册到 Unity 的协程调度器,每帧推它一次。
yield return X 里的 X 不是随便的对象,Unity 检查它的类型决定”什么时候让你继续”:
| YieldInstruction | 继续条件 |
|---|---|
null | 下一帧(Update 之后) |
WaitForSeconds(t) | t 秒后(注意是缩放后的时间,受 Time.timeScale 影响) |
WaitForSecondsRealtime(t) | t 秒后(不受 timeScale 影响) |
WaitForEndOfFrame | 这一帧渲染完 |
WaitForFixedUpdate | 下一个 FixedUpdate |
WaitUntil(predicate) | predicate 为真 |
AsyncOperation | 异步操作完成(如加载场景) |
每种都对应 Unity 在 PlayerLoop 里某个特定时机的检查点。
WaitForSeconds 受 Time.timeScale 影响。游戏暂停(timeScale=0)时协程也停了——这通常是想要的,但要意识到。MonoBehaviour 上。物体被销毁或禁用,协程自动停——这是好事,但也意味着你不能用协程做”物体不存在还要继续”的逻辑。yield return 只在协程内有效。普通方法里写 yield return 是迭代器,不是协程——容易被语法混淆。C# 的 yield return(迭代器)和 async/await(异步)在编译器层面是同一套技术——都被改写成状态机。Unity 协程借用了迭代器机制,所以它和 async/await 本质同源。
// 协程写法
IEnumerator Work()
{
Log("A");
yield return WaitForSeconds(2f); // ← 切点
Log("B");
yield return null; // ← 切点
Log("C");
}
// async/await 写法(等价结构)
async Task WorkAsync()
{
Log("A");
await Task.Delay(2000); // ← 切点
Log("B");
await Task.Yield(); // ← 切点
Log("C");
}
yield return 和 await 都是:把方法切成片段、可暂停可恢复。两者都被编译器改写成”状态机 + 字段保存局部变量”。
上面 Work() 协程被编译器大致改写成(简化伪代码):
class WorkCoroutine : IEnumerator
{
private int state = 0; // 当前停在第几个切点
private WaitForSeconds wait; // 局部变量提升为字段
public bool MoveNext()
{
switch (state)
{
case 0:
Log("A");
wait = new WaitForSeconds(2f);
Current = wait;
state = 1;
return true; // ← 暂停在这里,把控制权还给 Unity
case 1:
Log("B");
Current = null; // 等下一帧
state = 2;
return true;
case 2:
Log("C");
return false; // 协程结束
}
return false;
}
public object Current { get; private set; }
}
关键点:
state 字段记着当前停在第几个 yield。wait)被提升为字段,跨暂停保留。MoveNext() 每次推进到下一个 yield,把要等的 YieldInstruction 放进 Current,然后 return true 暂停。虽然都是状态机,驱动恢复的机制不同:
| 协程 | async/await | |
|---|---|---|
| 状态机 | IEnumerator(迭代器) | IAsyncStateMachine |
| 谁推动恢复 | Unity 引擎每帧调 MoveNext | 任务完成回调触发 MoveNext |
| 触发时机 | 固定在 PlayerLoop 的某个时机 | 事件完成的瞬间 |
这是核心区别:
Update 之后),对所有协程调一次 MoveNext,问”Current 满足了吗?满足就推进”。这是轮询驱动。MoveNext,不需要等下一帧。这是事件驱动。所以协程的恢复粒度是”帧”——最快的 yield return null 也要等一帧。而 async 可以在事件完成的瞬间恢复,更精细。
理解了 sync1 B07 的 async 状态机,再看协程就不再神秘:
IEnumerator,async 用 IAsyncStateMachine——只是状态机的两个马甲。这也解释了为什么协程能做的事 async 都能做(暂停恢复),但 async 表达力更强——它能等任意 Task,不局限于 Unity 提供的 YieldInstruction。
MoveNext 在主线程跑,协程内部代码也在主线程。Current。一个 WaitForSeconds(2) 实际是每帧被问”到 2 秒了吗”,不是定时器回调。async 的恢复比协程更精细——能在事件完成瞬间恢复,不必等下一帧。Unity 有一条铁律:几乎所有 Unity API 只能在主线程调用。
碰 Transform、GameObject、MonoBehaviour、Camera、Renderer、UI(uGUI)、Input——必须在主线程。后台线程碰这些,Unity 直接抛异常或崩溃。
这条铁律的根源:Unity 引擎核心(C++ 那层)不是线程安全的,所有引擎状态都在主线程上读写,跨线程访问会破坏一致性。Unity 没有选择给整个引擎加锁(太慢),而是直接禁止跨线程访问。
Unity 的整个游戏循环叫 PlayerLoop,每帧由主线程顺序跑一遍:
每帧(约 16.6ms @60fps,主线程):
┌─────────────────────────────────────────────┐
│ 1. 输入处理(Input) │
│ 2. 物理(FixedUpdate → 物理模拟) │
│ 3. 脚本生命周期: │
│ ├─ Awake / OnEnable │
│ ├─ Start │
│ ├─ Update │
│ ├─ Coroutine 推进 │
│ └─ LateUpdate │
│ 4. 动画 / IK │
│ 5. UI 事件(uGUI) │
│ 6. 渲染(Culling → Draw Calls → GPU) │
│ 7. 等下一帧(vsync) │
└─────────────────────────────────────────────┘
输入、物理、脚本、渲染——每个环节都由同一个主线程顺序执行。Unity 2019+ 允许你用 PlayerLoop API 自己插入回调到这些阶段,但它们仍在主线程跑。
后台线程可以做计算,但不能碰 Unity 对象:
| 后台线程可以 | 后台线程不可以 |
|---|---|
| 纯数学计算(矩阵、AI、寻路) | 读 transform.position |
| 解析 JSON / XML | 创建 GameObject |
读写文件(用 C# File) | 调 GetComponent |
| 网络通信 | 触发 Debug.Log(部分版本) |
| 操作纯 C# 数组/List | 修改材质、贴图、Mesh |
典型模式:后台算结果,主线程应用结果。这正是 Task.Run + await 回主线程的用武之地(见 B08)。
后面要讲的坑几乎都源自这条铁律:
await 之后能不能碰 Unity 对象?取决于恢复线程是不是主线程。Task.Run 里碰 transform 直接崩——后台线程违反铁律。记住这条铁律,Unity 里 90% 的”为什么这行报错”都能解释。
Debug.Log 在某些版本都不可靠。日志要回主线程或用线程安全的方式。GetComponent 看起来无害但必须主线程。后台线程缓存组件引用是合法的(在主线程拿到引用,后台线程用引用读字段——但读字段碰引擎对象仍不行)。在 Unity 里直接写 async/await(B06),最担心的是:
async void Start()
{
await Task.Delay(1000);
transform.position = new Vector3(0, 1, 0); // ← 还在主线程吗?不在就崩
}
答案:默认情况下,还在主线程,可以放心碰 Unity 对象。
原因在第一集讲的 SynchronizationContext(sync1 B09)。Unity 在启动时安装了一个专属的同步上下文——UnitySynchronizationContext。
await 默认会捕获当前的 SynchronizationContext。在 Unity 里这个上下文就是 UnitySynchronizationContext,它代表主线程。所以 await 之后的后半段,会被这个上下文送回主线程执行。
[主线程] [后台/线程池]
│ │
├─ await Task.Delay │
├─ 暂停,return │
│ ├─ 计时器到
│ ├─ Task 完成
│ UnitySyncContext ◄─────────┤ 想恢复后半段
│ 把后半段排进主线程队列 │
├─ 主线程下一帧取到这个回调 │
└─ 在主线程跑后半段 ✓ │
UnitySynchronizationContext.Post(callback) 把”继续 MoveNext”这个回调排进主线程队列,主线程在 PlayerLoop 的某个时机(默认每帧 EarlyUpdate 阶段)执行它。这就是为什么 await 之后能安全碰 Unity 对象。
| 上下文 | 代表的线程 | 安装位置 |
|---|---|---|
DispatcherSynchronizationContext | UI 线程(WPF/WinForms) | 客户端桌面应用 |
UnitySynchronizationContext | Unity 主线程 | Unity 启动时(每帧驱动) |
null | 线程池(任意) | 控制台 / ASP.NET Core |
Unity 不是 .NET 客户端桌面应用,所以它有自己的上下文实现。这个上下文的特殊性在于它挂在 PlayerLoop 上——每帧由 Unity 主动驱动一次,把队列里积压的回调跑掉。
注意一个细节:UnitySynchronizationContext 是每帧驱动一次的。这意味着:
async void Start()
{
Debug.Log(Time.frameCount); // 帧 N
await Task.Delay(16); // 等 16ms
Debug.Log(Time.frameCount); // ← 不一定是 N+1,可能 N+1 也可能 N+2
transform.position = ...;
}
后半段恢复要等到主线程下一次轮到 UnitySyncContext 那个阶段,不是计时器一响就立刻跑。和原生 Task 的”事件完成即恢复”比,Unity 的 await 恢复有最多一帧的延迟。这是 UniTask 后来要解决的精度问题之一(B11)。
UnitySynchronizationContext 只对捕获了它的 await 生效。如果你 Task.Run 跳到线程池,里面再 await,恢复仍在后台线程——除非显式回主线程。Task.Delay 用的是 ThreadPool 定时器,恢复经 UnitySynchronizationContext 回主线程——这条链路有 GC 开销(每次 await 一个 Task 都是堆对象)。这正是 UniTask 要解决的(B10)。ConfigureAwait(false) 在 Unity 里会绕过这个保障。加了之后后半段不一定回主线程,碰 Unity 对象会崩。库代码才加,Unity 业务代码不要乱加。UnitySynchronizationContext(B07)只在主线程上的 await 才生效。一旦你用 Task.Run 主动跳到线程池,里面就是后台线程,主线程铁律(B05)立刻生效:
async void Start()
{
// ❌ 后台线程碰 Unity API,崩
await Task.Run(() =>
{
for (int i = 0; i < 1000000; i++) ComputeSomething(i);
transform.position = new Vector3(0, i, 0); // ← 后台线程!崩
});
}
Task.Run 把 lambda 丢到线程池执行,那个 lambda 跑在后台线程。里面碰 transform.position,违反”Unity API 必须主线程”——直接抛 get_transform can only be called from the main thread 异常。
async void Start()
{
// ✓ 后台只做纯计算,返回结果
Vector3 newPos = await Task.Run(() =>
{
for (int i = 0; i < 1000000; i++) ComputeSomething(i);
return ComputeFinalPosition(); // ← 纯数学,返回值
});
// ← await 后回主线程(UnitySyncContext 把我们送回来)
transform.position = newPos; // ← 主线程,合法
}
关键两步:
Task.Run 的 lambda 只做纯计算,不碰任何 Unity 对象。返回计算结果。await 之后回到主线程(靠 B07 的 UnitySynchronizationContext),在主线程把结果赋给 transform。一句话总结:算在后台,改在主线程。
复习 sync1 B09 + B07:Task.Run 返回的 Task,它的 await 仍然捕获 UnitySynchronizationContext(因为 Start 方法是在主线程跑的,await Task.Run(...) 那行的上下文是主线程的)。
所以:
Task.Run 的 lambda 在后台线程跑。Task 完成。await 检测到捕获的上下文是 UnitySynchronizationContext,把”继续 MoveNext”Post 回主线程。transform.position = newPos 在主线程跑——合法。Task.Run 内部碰 Unity API 必崩。哪怕只是读 transform.position 也不行。Task.Run 内部 await 不会自动回主线程。如果 Task.Run 的 lambda 里又 await,恢复仍在后台线程——除非显式 await 一个回主线程的调度。Task.Run 不是免费的。每个 Task.Run 都有线程池调度开销,高频小任务用 Task.Run 反而拖慢。Mathf、Vector3 等纯结构体可以后台用。它们不碰引擎状态,是纯数学。崩的是碰引擎对象(GameObject、Component、Transform 等)。把两者摆一起,差别就清楚了:
| 维度 | 协程(Coroutine) | async/await |
|---|---|---|
| 返回值 | 不能返回值(返回 IEnumerator) | 可以 Task<T> 返回任意类型 |
| 异常处理 | 不能用 try-catch 包 yield | 可以 try-catch 包 await |
| 取消 | 手动(StopCoroutine 或 flag) | CancellationToken,标准化 |
| 启动方式 | 必须依附 MonoBehaviour | 不依附,任意方法 |
| 等待粒度 | 帧(最快一帧) | 事件完成瞬间 |
与 Task 互操作 | 不支持 | 原生支持 |
| 组合(WhenAll/WhenAny) | 难 | 自然 |
协程返回类型必须是 IEnumerator,没法把算出的结果返回给调用方:
IEnumerator ComputeScore() // ← 不能返回 int
{
yield return WaitForSeconds(1f);
int score = ...;
// 怎么把 score 给调用方?只能用回调或字段绕
}
要拿结果,只能用回调或共享字段绕:
IEnumerator ComputeScore(Action<int> onDone)
{
yield return WaitForSeconds(1f);
int score = ...;
onDone(score); // ← 回调传结果
}
而 async/await 直接 return score,调用方 int score = await ComputeScoreAsync(),干净。
协程的 yield 不能被 try-catch 包住:
IEnumerator RiskyWork()
{
try
{
yield return DoSomethingRisky(); // ❌ 编译错误!yield 不能在 try 里(带 catch 时)
}
catch (Exception e)
{
// 异常处理很笨拙
}
}
C# 规范规定:yield 不能在 try 块内(带 catch 的那个)。异常处理只能拆成回调或外层包裹。async/await 则可以正常 try-catch。
协程取消要么 StopCoroutine(粗暴,没法清理),要么手动 flag:
bool cancelled = false;
IEnumerator Work()
{
while (!cancelled)
{
yield return null;
// 干活
}
}
async 用标准化的 CancellationToken(sync1 B14),传播到整条调用链,BCL/UniTask 内置支持。
不是说协程一无是处。它的长处是和帧循环贴得紧:
yield return null 等下一帧——这种”按帧推进的小时序”用协程很自然。WaitForEndOfFrame、WaitForFixedUpdate 这些 Unity 专属时机,协程原生支持。但论表达力和组合能力(返回值、异常、取消、并发、跨线程),async/await 全面胜出。
MonoBehaviour 自动停止。挂的 MonoBehaviour 销毁才停——如果想跨组件共享协程,要小心生命周期。async 在 Unity 里需要 UniTask 才好用(B10)。原生 Task 在 Unity 里有 GC 和精度问题。C# 的 Task 是为服务器、为多线程设计的,搬到游戏里有两处严重水土不服。
Task 是 class(引用类型),每次 async 方法都 new 一个状态机对象 + 一个 Task 对象。在服务器上一秒几百个调用无所谓,但游戏里:
Task 都是堆对象,跑完变垃圾,触发 GC(垃圾回收)。游戏对帧时间稳定极其敏感——一次 30ms 的 GC 卡顿玩家立刻能感觉到。原生 Task 的 GC 压力在游戏里是硬伤。
Task 的设计假设:
但 Unity 是帧驱动的:
原生 Task 都做不到,要自己包一层。
社区(Cysharp,日本)做的专门方案——UniTask:
struct(结构体)实现 UniTask,状态机也是 struct,跑在栈上,不制造 GC 垃圾。await。它现在已经成了 Unity 异步的事实标准,被主流项目(包括 Cysharp 自家的逻辑、很多手游)普遍采用。
注意:UniTask 和 Task 是不同类型,不能直接互换:
// 不是 Task,是 UniTask
async UniTask<string> FetchAsync()
{
await UniTask.Delay(1000);
return "done";
}
UniTask 有意不支持多线程同步(取消 Task 那套复杂的同步原语),换取零分配。它的设计哲学是”为 Unity 单线程模型量身定制”,而不是通用 Task 替代品。
Task,需要扩展方法桥接(UniTask 提供了 task.AsUniTask())。UniTask.Initialize(),否则恢复时机不对。这是 UniTask 最核心的优势。它通过两个手段实现:
Task 是 class,每次 new 在堆上。UniTask 是 struct(值类型):
// 原生 Task:堆对象
public class Task<TResult> { ... }
// UniTask:值类型,跑在栈上
public struct UniTask<TResult> { ... }
struct 在方法局部用,分配在调用栈上,方法返回就回收——不进托管堆,不制造 GC 压力。
async 方法的状态机也用 struct 实现,并通过 池化 / 内联(inline) 优化。UniTask 设计了一个机制:如果 await 的目标在 await 那一刻已经完成,状态机不暂停、不分配续体,直接同步继续——这是零分配的关键。
async UniTask Example()
{
// 如果 cache 已经有数据,下面这行不会分配任何对象
var data = await cache.GetAsync(key);
Process(data);
}
热路径上大部分 await 都命中已完成的 task,UniTask 让这些路径完全零分配。
UniTask 把自己挂进 PlayerLoop(Unity 的主循环,B05 讲过),可以选择在哪个阶段恢复:
await UniTask.Yield(); // 默认,下一帧
await UniTask.Yield(PlayerLoopTiming.PostLateUpdate); // LateUpdate 之后恢复
await UniTask.WaitForEndOfFrame(); // 这一帧渲染完
await UniTask.NextFrame(); // 下一帧
await UniTask.DelayFrame(5); // 5 帧后
PlayerLoopTiming 是个枚举,覆盖 PlayerLoop 所有时机(EarlyUpdate、Update、PostLateUpdate 等)。这让”在渲染前更新位置”这种需求可以精确表达,而不是 Task 那种”事件完成任意时刻恢复”。
原生 Task + UnitySynchronizationContext 只能恢复到”UnitySyncContext 那个阶段”(默认 EarlyUpdate),粒度粗。UniTask 让你选任意阶段。
UniTask 提供包装器,让几乎所有 Unity 异步操作都能 await:
// 场景加载(原生 AsyncOperation)
await SceneManager.LoadSceneAsync("Level2");
// 资源加载(Resources)
var prefab = await Resources.LoadAsync<GameObject>("Enemy");
// Addressables
var handle = Addressables.LoadAssetAsync<GameObject>("Enemy");
var prefab = await handle.Task;
// 等动画事件
await UniTask.WaitUntil(() => animator.GetCurrentAnimatorStateInfo(0).IsName("Attack"));
// 等按钮点击
await button.OnClickAsync();
// 等条件成立
await UniTask.WaitUntilValueChanged(this, x => x.hp, c => c <= 0);
这些操作在协程时代要各自用 yield return 配不同的 YieldInstruction,写法割裂。UniTask 把它们统一成 await,可读性大幅提升,也能组合(WhenAll/WhenAny)。
| 维度 | 原生 Task + Unity | UniTask |
|---|---|---|
| 内存 | 每个 Task 堆对象,制造 GC | 零分配 |
| 帧时机 | 只能 EarlyUpdate 那个阶段 | 可选任意阶段 |
| 可 await 范围 | Unity 原生 AsyncOperation | 几乎所有操作 |
这三点合起来,让 UniTask 成为 Unity 异步的事实标准。
PlayerLoopTiming 必须理解 Unity 生命周期顺序。选错时机(比如在 Update 前改了 LateUpdate 才该改的状态)会导致逻辑错误。Task.Wait)。它的设计是单线程为主,多线程要走 UniTask.SwitchToThreadPool。UniTask 的方法返回 UniTask 或 UniTask<T>,不是 Task:
using Cysharp.Threading.Tasks;
using UnityEngine;
public class Loader : MonoBehaviour
{
async UniTaskVoid Start() // ← UniTaskVoid(fire-and-forget,比 UniTask 更轻)
{
// 等两秒,零分配
await UniTask.Delay(2000);
Debug.Log("2 秒后");
// 等下一帧
await UniTask.NextFrame();
// 场景加载,直接 await(不需 yield return)
await SceneManager.LoadSceneAsync("Level2");
// 等条件成立
await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.Space));
// 等到某个值变化
await UniTask.WaitUntilValueChanged(this, x => x.hp);
}
}
注意几个细节:
async UniTaskVoid Start():Unity 的 Start 签名是 void,但 async void 危险(sync1 B11)。UniTask 提供 UniTaskVoid——专门给 Unity 生命周期方法用的 fire-and-forget 类型,比 UniTask 更省(不返回 Task)。UniTask.Delay 等价 Task.Delay 但零分配。await,写法统一。async UniTask<int> ComputeScoreAsync(CancellationToken ct)
{
await UniTask.Delay(500, cancellationToken: ct); // ← 传 token
int score = Random.Range(0, 100);
return score; // ← 直接返回 int,调用方 await 拿到
}
async UniTaskVoid UseScore()
{
int score = await ComputeScoreAsync(this.GetCancellationTokenOnDestroy()); // 拿到返回值
Debug.Log($"Score: {score}");
}
协程做不到的”返回值”,UniTask 自然支持。
async UniTask LoadAllAsync()
{
// 并发加载三个资源,同时进行
UniTask<GameObject> enemyTask = Addressables.LoadAssetAsync<GameObject>("Enemy");
UniTask<GameObject> npcTask = Addressables.LoadAssetAsync<GameObject>("Npc");
UniTask<AudioClip> musicTask = Addressables.LoadAssetAsync<AudioClip>("Bgm");
// 一次性等全部完成
await UniTask.WhenAll(enemyTask, npcTask, musicTask);
// 全部好了,取出结果
GameObject enemy = enemyTask.GetAwaiter().GetResult();
GameObject npc = npcTask.GetAwaiter().GetResult();
AudioClip music = musicTask.GetAwaiter().GetResult();
}
UniTask.WhenAll 等价 Task.WhenAll(sync1 B12)——并发启动多个任务,一次性等它们全部完成,总耗时只取决于最慢的那个。协程要做并发很别扭,UniTask 几行搞定。
async UniTaskVoid DoHeavyWork()
{
// 切到线程池做重计算
int result = await UniTask.RunOnThreadPool(() =>
{
// 纯计算,不碰 Unity 对象
int sum = 0;
for (int i = 0; i < 100000000; i++) sum += i;
return sum;
});
// 自动回主线程(UniTask 默认行为)
transform.position = new Vector3(result % 100, 0, 0); // 主线程,合法
}
UniTask.RunOnThreadPool 是 Task.Run 的零分配等价物。配合 UniTask 默认回主线程的特性,“算在后台、改在主线程”自然成立(B08)。
UniTask 最大的体验提升:异步代码读起来像同步——线性、可读、可 try-catch、可返回值——但全程不卡主线程。
async UniTaskVoid LoginFlow(CancellationToken ct)
{
try
{
var session = await LoginAsync(user, pass, ct); // ← 异常能正常 try-catch
var data = await FetchProfileAsync(session, ct);
RenderProfile(data); // 主线程,能更新 UI
}
catch (OperationCanceledException) { /* 取消,正常 */ }
catch (Exception e) { ShowError(e.Message); }
}
这种代码在协程里要拆成无数回调和 flag,UniTask 让它和同步代码一样直接。
UniTaskVoid 不是 async void。async void 危险(异常会崩进程),UniTaskVoid 内部兜了异常。UniTaskVoid 不能 await。它是 fire-and-forget,要 await 就用 UniTask。UniTask 只能 await 一次。它是 struct,设计上不支持多次 await(不像 Task)。要多次 await,缓存的是结果不是 UniTask。游戏和服务器最大的区别:Unity 对象随时可能被销毁。一个异步任务跑到一半,物体被 Destroy 了,任务恢复时再去碰它,就是经典崩溃。
async UniTaskVoid Work()
{
await UniTask.Delay(5000); // 等 5 秒
// ← 这 5 秒里,gameObject 可能被销毁了
transform.position = ...; // ← 访问已销毁对象,崩
}
Task / UniTask 本身不知道 Unity 的生命周期——它们只是”等条件满足后执行续体”,不会自动检查”我依附的对象还在不在”。
C# 的取消机制是 CancellationToken(sync1 B14)。在 Unity 里,最佳实践是把对象的销毁和取消绑定——物体一销毁,关联的所有异步自动取消。
UniTask 提供了一个利器:
this.GetCancellationTokenOnDestroy()
这是 MonoBehaviour 的扩展方法(UniTask 提供),返回一个 会在该对象 OnDestroy 时自动取消的 CancellationToken。把它传给每个 await:
async UniTaskVoid Work()
{
var ct = this.GetCancellationTokenOnDestroy();
try
{
await UniTask.Delay(5000, cancellationToken: ct);
transform.position = ...; // 物体还在才到这行
}
catch (OperationCanceledException)
{
// 物体销毁了,任务自动取消,走这里(或不处理)
}
}
async UniTaskVoid Start()
{
Work(this.GetCancellationTokenOnDestroy()).Forget(); // ← Forget() 忘掉 UniTaskVoid
}
发生了什么:
GetCancellationTokenOnDestroy() 拿到一个绑定本对象生命周期的 token。Destroy → Unity 调 OnDestroy → UniTask 把这个 token 触发取消。await UniTask.Delay(...) 检测到取消,立即抛 OperationCanceledException。await 那行中止,不会执行 transform.position = ...——避免了访问已销毁对象。CancellationToken 不会自动冒泡(sync1 B14)。每个 await 都要带 cancellationToken: ct 参数:
await UniTask.Delay(5000, cancellationToken: ct); // ← 不传就是无法取消
await SceneManager.LoadSceneAsync("x").ToUniTask(cancellationToken: ct);
漏传一个,那条链就断开了——即便对象销毁,那个特定的 await 仍在等。取消 token 必须一路传到底。
它内部用 CancellationTokenSource,每个调用的对象创建一个 source,挂到 OnDestroy。这是有分配的,但只在对象初始化时发生一次,热路径上 token 本身是值类型传递,几乎零开销。相比”销毁后崩”的风险,这点开销完全值得。
GetCancellationTokenOnDestroy 是 per-MonoBehaviour 的。每个挂 UniTask 逻辑的组件各自拿自己的 token,不要共享。OperationCanceledException 后不要当 fatal 错误记日志(sync1 B14)。这是 Unity 异步最经典的崩溃。B13 讲了怎么避免,这里讲清楚为什么会崩、崩成什么样。
async void Work() // ← 没传 token
{
await Task.Delay(5000); // 等 5 秒
Debug.Log(gameObject.name); // ← 5 秒后访问,但物体可能已销毁
}
5 秒的等待期间,玩家关了界面 / 切了场景 / 物体被 Destroy(gameObject)。5 秒到了,任务恢复,去访问 gameObject——但它已经不存在了。
// 物体已销毁,访问 gameObject 抛
MissingReferenceException: The object of type 'MyBehaviour' has been destroyed
but you are still trying to access it.
MissingReferenceException 是 Unity 专属异常——UnityEngine.Object 的子类(GameObject、MonoBehaviour、Component 等)在被销毁后,C++ 那层的对象没了,但 C# 引用还在(变成”假 null”)。访问它的任何成员,Unity 检测到 C++ 对象没了,抛 MissingReferenceException。
这是 Unity 一个非常反直觉的设计:
Destroy(gameObject);
// gameObject 这个 C# 引用还在,但:
Debug.Log(gameObject == null); // true!← 重载了 == 运算符
// 实际上 C# 对象本身不是 null,是"被标记销毁"
gameObject.name; // 抛 MissingReferenceException
Unity 重写了 == 运算符,让”已销毁对象”比较等于 null。但底层引用非空,访问成员仍会抛。这是为什么不能用普通 if (gameObject != null) 的 C# 习惯去判断——要用 Unity 的重载 ==。
Task / async/await 是 .NET 通用机制,对 Unity 生命周期一无所知。它们只知道”等条件满足 → 执行续体”,不知道”我依附的 Unity 对象还在不在”。
所以不显式传 CancellationToken,任务就会一直等到 Task.Delay(5000) 自然结束,然后试图执行续体——而续体里的 gameObject 早就销毁了。
async UniTaskVoid Work()
{
var ct = this.GetCancellationTokenOnDestroy(); // ← 绑定生命周期
try
{
await UniTask.Delay(5000, cancellationToken: ct);
Debug.Log(gameObject.name); // 物体还在才到这行
}
catch (OperationCanceledException)
{
// 物体销毁了,自动取消,正常退出
}
}
void Start()
{
Work().Forget(); // ← Forget() 触发 fire-and-forget
}
物体在 5 秒内销毁 → OnDestroy → token 取消 → await 抛 OperationCanceledException → catch → 任务优雅退出,不会执行 gameObject.name。
这是 Unity 异步编程最关键的一句话。在服务器上,一个对象被释放,引用没了 GC 会回收,不会有”销毁后继续跑”。但 Unity 的 C++ 对象生命周期和 C# 引用解耦,加上 Task 不知道 Unity——不绑取消,就会踩销毁坑。
新代码默认每个 async UniTaskVoid 都该拿 GetCancellationTokenOnDestroy() 并一路传下去。
gameObject == null 在 Unity 里不是 C# null 检查。它检查的是”Unity 对象是否已销毁”,依赖重载的 ==。DestroyImmediate 比 Destroy 更危险。Destroy 延迟到帧末,DestroyImmediate 立刻销毁——异步任务恢复时 100% 已销毁。if (gameObject) { ... } 当保护。Unity 的隐式 bool 转换依赖销毁状态,但在异步恢复的瞬间可能竞态——根本解法是 token 取消,不是判空。