UniTask 学习文档

基于UniTask 源码整理,版本适配 Unity 2019.3+


目录

  1. 什么是 UniTask
  2. 核心类型与基本用法
  3. 时间与帧等待
  4. PlayerLoopTiming — Unity 生命周期时序
  5. 取消机制 (CancellationToken)
  6. Unity 对象操作
  7. Unity 生命周期异步触发器 (AsyncTrigger)
  8. 条件等待
  9. 并发控制 WhenAll / WhenAny
  10. 线程切换
  11. UniTaskCompletionSource 手动控制任务
  12. 错误处理与 Forget
  13. UniTaskVoid — 即发即弃
  14. TimeoutController 超时控制
  15. AsyncLazy 懒加载异步
  16. UniTask Tracker 调试工具
  17. 最佳实践与常见陷阱

1. 什么是 UniTask

UniTask 是由 Cysharp 开源的、专为 Unity 设计的高性能异步库,命名空间为 Cysharp.Threading.Tasks

要理解 UniTask 为什么有存在的必要,得先看清楚为什么 .NET 原生的 Task 在 Unity 里不够好——它不是不能用,而是从设计目标到运行机制都和 Unity 错位。

1.1 为什么不能直接用 System.Threading.Tasks.Task

Task 是为通用 .NET 服务端、桌面端设计的,它的设计假设是多线程 ThreadPool 调度 + GC 不敏感。这两个假设在 Unity 里都站不住脚。

(1) Task 是引用类型,每次都堆分配

每写一个 async Task 方法、每 new Task(...) 一次、每 await 一个尚未完成的 Task,背后都至少分配这几个对象:

  • Task / Task<T> 对象本身(引用类型)
  • 异步状态机被装箱成的 IAsyncStateMachine 引用类型实例
  • MoveNextRunner 委托
  • 续体回调(continuation)
  • 若使用 CancellationToken,还有 CancellationTokenRegistration

在 Unity 这种主循环每帧 16ms(60帧) 必须搞定一切的环境里,高频路径上跑 Task(比如每个子弹、每个 AI 决策都 await 一下)会迅速把 GC 曲线拉成锯齿,触发不定时的卡顿。这是 Task 在 Unity 里最致命的问题。

UniTask 用 struct 实现 UniTask 类型,配合自定义的 AsyncUniTaskMethodBuilder 和对象池,同步完成路径零分配,异步路径上也远低于 Task。

(2) Task 的调度器和 Unity 主循环没关系

Task 默认通过 ThreadPool 调度续体,或者通过捕获的 SynchronizationContext 切回原线程。Unity 虽然提供了 UnitySynchronizationContext 让续体能回到主线程,但这个机制有几个问题:

  • 时序不可控:续体只能粗粒度地”回到主线程下一个能跑的时机”,你说不清是 Update、LateUpdate 还是某个具体的 PlayerLoop 节点。
  • 会走一遍 Post 队列:哪怕续体逻辑上是同步可执行的,也会被 Post 进队列绕一圈,引入额外开销和帧延迟。
  • WebGL 上行为微妙:WebGL 是单线程的,ConfigureAwait(false) 之类的玩法跟桌面端语义不一致,容易踩坑。

UniTask 把异步续体直接挂在 Unity PlayerLoop 的指定节点上跑,时序精确到 PreUpdateUpdatePreLateUpdateLateUpdatePostLateUpdate 等等,整个过程不切线程、不走 SyncContext。

(3) Task 不认识 Unity 的异步对象

Unity 自己的异步 API(AsyncOperationUnityWebRequestResources.LoadAsyncSceneManager.LoadSceneAsyncAddressables 等)返回的都不是 Task。想 await 它们,要么自己写 TaskCompletionSource 包一层(又是堆分配),要么用社区的扩展方法。

UniTask 对所有这些 Unity 异步 API 提供了原生扩展,可以直接 await someAsyncOperation,无需任何包装。

(4) Task 的生命周期跟 Unity 对象毫无关联

经典事故:

1
2
3
4
5
async Task LoadAndShowAsync()
{
await Task.Delay(2000);
text.text = "done"; // 如果这个对象已经被 Destroy,这里就 MissingReferenceException
}

Task 不知道 Unity 里”这个 GameObject 已经被销毁”是什么意思。你得自己手写 CancellationToken 并且在 OnDestroy 里 cancel。

UniTask 提供了 this.GetCancellationTokenOnDestroy() / GetCancellationTokenOnDisable() 等扩展,一行代码就把异步任务的生命周期绑到 MonoBehaviour 上,对象销毁时所有相关 await 自动取消。

(5) Task 的取消是”协作式”但不友好

Task.Delay(1000, token) 取消时会抛 TaskCanceledException,你必须 try/catch 它——否则在 async void(这本身又是另一个坑)里直接炸主线程。

UniTask 提供更友好的取消语义:可以选择OperationCanceledException,也可以选择返回一个标记取消的 UniTask(通过 SuppressCancellationThrow()),让上层代码不必到处包 try/catch。

(6) 调试和观测能力弱

Task 在 Unity 里基本是”黑盒”:当前有多少个未完成的 Task、它们卡在哪一行、由哪个对象持有——Profiler 里看不出来,调试只能靠日志。

UniTask 自带 Tracker 窗口(Window → UniTask Tracker),可以实时看到所有活跃的 UniTask、栈位置、发起者,泄漏几乎无所遁形。

1.2 Task vs UniTask 对比总览

维度 System.Threading.Tasks.Task UniTask
类型 class(引用类型,堆分配) struct(值类型,栈分配)
内存分配 每次 await 都产生多个堆对象 同步完成零分配;异步路径用对象池
调度器 ThreadPool / SynchronizationContext 直接挂载 Unity PlayerLoop,可精确到具体时序节点
线程模型 默认可能跑在线程池 默认全程主线程;显式 SwitchToThreadPool 才换线程
Unity 异步 API 无法直接 await,需要手写包装 原生支持 AsyncOperationUnityWebRequestAddressables
生命周期联动 完全独立,对象销毁也不会自动取消 GetCancellationTokenOnDestroy() 一行搞定
取消语义 强制抛 TaskCanceledException 可抛异常也可静默返回
Fire-and-forget 只能 async void(吞异常) UniTaskVoid + .Forget(),显式且安全
调试工具 基本没有 Tracker 窗口、栈追踪
WebGL 兼容性 SyncContext 行为微妙 专门适配,行为一致
设计目标 通用 .NET 异步(服务端为主) 专为 Unity 主循环设计

1.3 那 Task 是不是就完全不用了?

不是。在以下场景仍然推荐用 Task

  • 调用第三方库的 API,对方返回 Task(这种情况可以 await task.AsUniTask() 转成 UniTask 再继续)。
  • 真正需要跨线程并行的 CPU 密集计算(虽然 UniTask 也支持,但 Task + Parallel 在这块生态更完整)。
  • 编写跨平台 .NET 库,库本身不依赖 Unity。

经验法则:写 Unity 游戏逻辑层(GamePlay、UI、剧情、加载流程)→ UniTask;写跨平台底层 / 调用第三方 .NET 库 → Task,必要时用 AsUniTask() 桥接。


2. UniTask的核心类型与基本用法

2.1 UniTask 与 UniTask<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 无返回值的异步方法
public async UniTask DoSomethingAsync()
{
await UniTask.Delay(1000); // 等待 1 秒
Debug.Log("完成");
}

// 有返回值的异步方法
public async UniTask<int> LoadDataAsync()
{
await UniTask.Delay(500);
return 42;
}

// 调用方 await
private async UniTask StartAsync()
{
await DoSomethingAsync();
int result = await LoadDataAsync();
Debug.Log(result); // 42
}

2.2 快速创建已完成的任务

1
2
3
4
5
6
7
8
9
// 已成功完成
UniTask completed = UniTask.CompletedTask;
UniTask<int> withValue = UniTask.FromResult(100);

// 已取消
UniTask canceled = UniTask.FromCanceled(cancellationToken);

// 已失败
UniTask faulted = UniTask.FromException(new Exception("出错了"));

2.3 Preserve — 允许多次 await

UniTask 默认只能被 await 一次(类似消费一次的流)。若需多次 await 同一个任务,先调用 Preserve()

1
2
3
4
5
var task = SomeHeavyTask().Preserve();

// 可以被多个地方同时 await
await task;
await task; // 不会报错,复用结果

3. 时间与帧等待

3.1 DelayType — 时间类型选择

1
2
3
4
5
6
public enum DelayType
{
DeltaTime, // 使用 Time.deltaTime(受 Time.timeScale 影响)
UnscaledDeltaTime, // 使用 Time.unscaledDeltaTime(不受 timeScale 影响)
Realtime // 使用 Stopwatch,真实物理时间
}

3.2 Delay — 按时间等待

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 等待 2 秒(受 timeScale 影响)
await UniTask.Delay(2000);
await UniTask.Delay(TimeSpan.FromSeconds(2));

// 等待 2 秒(忽略 timeScale,等同于 UnscaledDeltaTime)
await UniTask.Delay(2000, ignoreTimeScale: true);

// 完整参数版本
await UniTask.Delay(
TimeSpan.FromSeconds(2),
delayType: DelayType.Realtime,
delayTiming: PlayerLoopTiming.Update,
cancellationToken: this.GetCancellationTokenOnDestroy()
);

// WaitForSeconds 等效(float 版)
await UniTask.WaitForSeconds(2.5f);
await UniTask.WaitForSeconds(2.5f, ignoreTimeScale: true);

3.3 DelayFrame — 按帧等待

1
2
3
4
5
// 等待 10 帧
await UniTask.DelayFrame(10);

// 指定在哪个 PlayerLoop 阶段计帧
await UniTask.DelayFrame(10, PlayerLoopTiming.FixedUpdate);

3.4 Yield — 让出当前帧

1
2
3
4
5
6
7
8
// 让出到下一个 Update(最轻量)
await UniTask.Yield();
await UniTask.Yield(PlayerLoopTiming.PreLateUpdate);

// 注意:Yield 在同一帧已过该时机时会在当前帧末尾执行
// 若要保证一定在「下一帧」执行,用 NextFrame
await UniTask.NextFrame();
await UniTask.NextFrame(PlayerLoopTiming.Update);

3.5 WaitForFixedUpdate / WaitForEndOfFrame

1
2
3
4
5
6
7
8
// 等待到下一个 FixedUpdate 结束
await UniTask.WaitForFixedUpdate();

// 等待帧渲染结束(需要传入 MonoBehaviour,用于内部开启协程)
await UniTask.WaitForEndOfFrame(this);

// Unity 2023.1+ 可直接使用
// await UniTask.WaitForEndOfFrame(cancellationToken);

内部实现说明WaitForFixedUpdate 等价于 UniTask.Yield(PlayerLoopTiming.LastFixedUpdate)WaitForEndOfFrame 内部通过 StartCoroutine + WaitForEndOfFrame 协程实现,因此需要传入 MonoBehaviour


4. PlayerLoopTiming — Unity 生命周期时序

UniTask 将续体(continuation)注入 Unity 的 PlayerLoop,精确控制代码在哪个阶段恢复执行。

1
2
3
4
5
6
7
8
Initialization         → LastInitialization
EarlyUpdate → LastEarlyUpdate
FixedUpdate → LastFixedUpdate
PreUpdate → LastPreUpdate
Update ★默认 → LastUpdate
PreLateUpdate → LastPreLateUpdate
PostLateUpdate → LastPostLateUpdate
TimeUpdate (2020.2+) → LastTimeUpdate

对应 Unity 生命周期

1
2
3
4
5
6
7
Initialization    → 场景初始化阶段
EarlyUpdate → Input 处理前
FixedUpdate → 物理更新(FixedUpdate)
PreUpdate → Update 前
Update → MonoBehaviour.Update()
PreLateUpdate → LateUpdate 前
PostLateUpdate → LateUpdate 后(相机、渲染)

实际使用建议

1
2
3
4
5
6
7
8
// 大多数逻辑:默认 Update 即可
await UniTask.Yield();

// 物理相关逻辑
await UniTask.Yield(PlayerLoopTiming.FixedUpdate);

// 需要在所有 Update 完成后执行(如跟随相机)
await UniTask.Yield(PlayerLoopTiming.PostLateUpdate);

5. 取消机制 (CancellationToken)

5.1 GetCancellationTokenOnDestroy — 对象销毁自动取消

这是 UniTask 与 Unity 对象生命周期结合最关键的 API:

1
2
3
4
5
6
7
8
9
10
11
12
public class MyBehaviour : MonoBehaviour
{
async UniTask Start()
{
// 当该 GameObject 被销毁时,自动取消
var token = this.GetCancellationTokenOnDestroy();

await UniTask.Delay(5000, cancellationToken: token);
// 如果 GameObject 提前销毁,上面会抛出 OperationCanceledException
Debug.Log("5秒后执行");
}
}

内部原理GetCancellationTokenOnDestroy() 会自动向 GameObject 挂载一个 AsyncDestroyTrigger 组件。该组件在 OnDestroy() 时调用 CancellationTokenSource.Cancel(),从而取消所有持有该 token 的任务。

5.2 手动创建 CancellationTokenSource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class MyBehaviour : MonoBehaviour
{
CancellationTokenSource _cts;

void Start()
{
_cts = new CancellationTokenSource();
DoLoopAsync(_cts.Token).Forget();
}

// 手动取消(如按下按钮)
public void StopTask()
{
_cts.Cancel();
_cts.Dispose();
}

async UniTaskVoid DoLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
Debug.Log("循环中...");
await UniTask.Delay(1000, cancellationToken: token);
}
}

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

5.3 链接多个 Token

1
2
3
4
5
6
// 任意一个取消,整体取消
var cts = CancellationTokenSource.CreateLinkedTokenSource(
this.GetCancellationTokenOnDestroy(),
manualCts.Token
);
var linkedToken = cts.Token;

5.4 SuppressCancellationThrow — 不抛出异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 普通写法:取消时会抛 OperationCanceledException
try
{
await UniTask.Delay(1000, cancellationToken: token);
}
catch (OperationCanceledException)
{
Debug.Log("已取消");
}

// 使用 SuppressCancellationThrow:返回 bool 而非抛异常
bool isCanceled = await UniTask.Delay(1000, cancellationToken: token)
.SuppressCancellationThrow();
if (isCanceled)
{
Debug.Log("已取消,不抛异常");
return;
}

5.5 WaitUntilCanceled — 等待直到 Token 被取消

1
2
3
4
5
6
// 挂起直到 token 被取消
await token.WaitUntilCanceled();
Debug.Log("Token 已被取消,继续执行");

// 等价写法(UniTask 静态方法)
await UniTask.WaitUntilCanceled(token);

5.6 cancelImmediately 参数

默认情况下,取消检测发生在 PlayerLoop 的下一次循环(即下一帧或下一个时机)。若需要立即响应取消,使用 cancelImmediately: true

1
2
// 立即响应取消,而非等到下一帧
await UniTask.Delay(5000, cancellationToken: token, cancelImmediately: true);

注意cancelImmediately: true 会向 CancellationToken 注册回调,有轻微额外开销,仅在需要即时响应时使用。


6. Unity 对象操作

6.1 AsyncOperation 异步加载

1
2
3
4
5
6
7
8
9
10
11
12
// 场景加载
var op = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync("GameScene");
await op;

// 带进度回调
await op.ToUniTask(
progress: Progress.Create<float>(p => Debug.Log($"加载进度: {p:P0}")),
cancellationToken: this.GetCancellationTokenOnDestroy()
);

// 带取消
await op.WithCancellation(this.GetCancellationTokenOnDestroy());

6.2 资源加载 (Resources.LoadAsync)

1
2
3
4
5
6
7
8
9
10
11
// 加载资源
var resourceRequest = Resources.LoadAsync<Texture2D>("MyTexture");
await resourceRequest;
var texture = resourceRequest.asset as Texture2D;

// 带进度和取消
var req = Resources.LoadAsync<AudioClip>("BGM");
var clip = await req.ToUniTask(
progress: Progress.Create<float>(p => progressBar.value = p),
cancellationToken: token
) as AudioClip;

6.3 UnityWebRequest 网络请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using UnityEngine.Networking;

// GET 请求
var request = UnityWebRequest.Get("https://api.example.com/data");
await request.SendWebRequest().WithCancellation(token);

if (request.result == UnityWebRequest.Result.Success)
{
Debug.Log(request.downloadHandler.text);
}

// 带错误处理
try
{
var req = UnityWebRequest.Get("https://api.example.com/data");
await req.SendWebRequest().WithCancellation(token);
Debug.Log(req.downloadHandler.text);
}
catch (UnityWebRequestException e)
{
Debug.LogError($"请求失败: {e.Error}");
}

6.4 AssetBundle 加载

1
2
3
4
5
6
7
8
9
10
11
// 加载 AssetBundle
var bundleReq = AssetBundle.LoadFromFileAsync("path/to/bundle");
var bundle = await bundleReq;

// 从 Bundle 加载资源
var assetReq = bundle.LoadAssetAsync<GameObject>("Prefab");
var prefab = await assetReq as GameObject;

// 带进度
var bundle2 = await AssetBundle.LoadFromFileAsync("path/to/bundle")
.ToUniTask(Progress.Create<float>(p => Debug.Log($"{p:P}")));

6.5 AsyncInstantiate(Unity 2022.2+)

1
2
// 异步实例化,避免主线程卡顿
var instance = await Object.InstantiateAsync(prefab, parent);

6.6 MonoBehaviour.StartAsyncCoroutine

1
2
3
4
5
6
// 将 CancellationToken 自动绑定到组件生命周期
await monoBehaviour.StartAsyncCoroutine(async (token) =>
{
await UniTask.Delay(1000, cancellationToken: token);
// 做一些事
});

7. Unity 生命周期异步触发器 (AsyncTrigger)

UniTask 提供了一套 AsyncTrigger 系统,将 MonoBehaviour 的各类消息事件(FixedUpdateOnCollisionEnter 等)转换为可 await 的异步流。

7.1 获取 Trigger

所有 Trigger 都通过扩展方法从 GameObjectComponent 获取:

1
2
3
4
// 在组件上首次调用时,会自动向 GameObject 添加对应 Trigger 组件
var fixedUpdateTrigger = this.GetAsyncFixedUpdateTrigger();
var updateTrigger = this.GetAsyncUpdateTrigger();
var lateUpdateTrigger = this.GetAsyncLateUpdateTrigger();

7.2 一次性等待(await 单次触发)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async UniTask Start()
{
var token = this.GetCancellationTokenOnDestroy();

// 等待下一次 FixedUpdate
await this.GetAsyncFixedUpdateTrigger().FixedUpdateAsync(token);
Debug.Log("FixedUpdate 触发了");

// 等待 OnTriggerEnter
var collider = await this.GetAsyncOnTriggerEnterTrigger().OnTriggerEnterAsync(token);
Debug.Log($"碰到了: {collider.name}");

// 等待 OnCollisionEnter
var collision = await this.GetAsyncOnCollisionEnterTrigger().OnCollisionEnterAsync(token);
Debug.Log($"碰撞: {collision.gameObject.name}");
}

7.3 持续监听(async foreach 异步流)

1
2
3
4
5
6
7
8
9
10
11
12
async UniTask MonitorUpdates()
{
var token = this.GetCancellationTokenOnDestroy();

// 异步 foreach:每次 LateUpdate 都执行一次循环体
await foreach (var _ in this.GetAsyncLateUpdateTrigger()
.WithCancellation(token))
{
// 相当于在 LateUpdate 里写逻辑
UpdateCamera();
}
}

7.4 常用 Trigger 列表

Trigger 获取方法 对应 Unity 消息
GetAsyncUpdateTrigger() Update
GetAsyncFixedUpdateTrigger() FixedUpdate
GetAsyncLateUpdateTrigger() LateUpdate
GetAsyncOnTriggerEnterTrigger() OnTriggerEnter
GetAsyncOnTriggerExitTrigger() OnTriggerExit
GetAsyncOnCollisionEnterTrigger() OnCollisionEnter
GetAsyncOnCollisionExitTrigger() OnCollisionExit
GetAsyncOnMouseDownTrigger() OnMouseDown
GetAsyncOnEnableTrigger() OnEnable
GetAsyncOnDisableTrigger() OnDisable
GetAsyncDestroyTrigger() OnDestroy
GetAsyncAnimatorIKTrigger() OnAnimatorIK
GetAsyncOnBecameVisibleTrigger() OnBecameVisible
GetAsyncOnBecameInvisibleTrigger() OnBecameInvisible

7.5 OnDestroyAsync — 等待对象销毁

1
2
3
4
5
6
7
8
// 等待某个其他对象被销毁
await enemy.GetAsyncDestroyTrigger().OnDestroyAsync();
Debug.Log("敌人已被销毁");

// 获取绑定到自身生命周期的 CancellationToken
var token = this.GetAsyncDestroyTrigger().CancellationToken;
// 等效于
var token2 = this.GetCancellationTokenOnDestroy();

8. 条件等待

8.1 WaitUntil — 等待条件为 true

1
2
3
4
5
6
7
8
9
10
11
bool isReady = false;

// 等待 isReady 变为 true(每帧检测)
await UniTask.WaitUntil(() => isReady);

// 带取消
await UniTask.WaitUntil(() => isReady,
cancellationToken: this.GetCancellationTokenOnDestroy());

// 指定检测时机
await UniTask.WaitUntil(() => isReady, PlayerLoopTiming.FixedUpdate);

8.2 WaitWhile — 等待条件为 false

1
2
// 等待 isLoading 变为 false
await UniTask.WaitWhile(() => isLoading);

8.3 WaitUntilValueChanged — 等待值变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 等待 transform.position 发生变化,返回新值
var newPos = await UniTask.WaitUntilValueChanged(
transform,
t => t.position,
cancellationToken: token
);
Debug.Log($"位置变化为: {newPos}");

// 等待组件属性变化
var newHp = await UniTask.WaitUntilValueChanged(
enemy,
e => e.HP,
cancellationToken: token
);

注意:对于 Unity 对象(继承自 UnityEngine.Object),WaitUntilValueChanged 内部会额外处理对象被销毁的情况(避免访问已销毁对象)。

8.4 WaitUntilCanceled — 等待取消

1
2
3
// 持续运行直到被取消
await UniTask.WaitUntilCanceled(token);
// token 被取消后继续执行(不抛异常)

9. 并发控制 WhenAll / WhenAny

9.1 WhenAll — 等待所有任务完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 并行执行多个任务,全部完成后继续
await UniTask.WhenAll(
LoadSceneAsync(),
LoadAudioAsync(),
LoadConfigAsync()
);

// 收集返回值(所有任务返回同类型)
int[] results = await UniTask.WhenAll(
FetchScoreAsync(userId1),
FetchScoreAsync(userId2),
FetchScoreAsync(userId3)
);

// 不同类型返回值(使用元组,最多支持 15 个)
var (texture, clip, config) = await UniTask.WhenAll(
LoadTextureAsync(), // UniTask<Texture2D>
LoadAudioAsync(), // UniTask<AudioClip>
LoadConfigAsync() // UniTask<Config>
);

9.2 WhenAny — 等待任意一个任务完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 返回率先完成的任务索引
int winIndex = await UniTask.WhenAny(
Task1Async(),
Task2Async(),
Task3Async()
);
Debug.Log($"第 {winIndex} 个任务率先完成");

// 与超时结合——实现"超时或完成"逻辑
var (hasResult, result) = await UniTask.WhenAny(
LoadDataAsync(), // 正常加载
UniTask.Delay(5000).AsAsyncUnitUniTask() // 5秒超时
// 注:实际超时推荐用 TimeoutController
);
if (!hasResult)
{
Debug.Log("加载超时");
}

10. 线程切换

UniTask 提供了简洁的线程切换 API,用于在 Unity 主线程和后台线程之间切换。

10.1 SwitchToThreadPool — 切换到后台线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async UniTask HeavyComputeAsync()
{
Debug.Log("开始(主线程)");

// 切换到线程池(后台线程)
await UniTask.SwitchToThreadPool();

// 以下代码在后台线程执行,不会阻塞主线程
var result = DoHeavyCompute();

// 切换回主线程(Update 时机)
await UniTask.SwitchToMainThread();

// 以下代码在主线程执行,可安全访问 Unity API
Debug.Log($"计算结果: {result}");
someText.text = result.ToString();
}

10.2 RunOnThreadPool — 封装后台任务

1
2
3
4
5
6
7
8
9
10
// 在后台线程执行,完成后自动返回主线程(configureAwait=true 默认行为)
await UniTask.RunOnThreadPool(() =>
{
// 这里是后台线程
Thread.Sleep(2000); // 模拟耗时
});
// 这里已回到主线程

// 不自动返回主线程
await UniTask.RunOnThreadPool(() => HeavyWork(), configureAwait: false);

10.3 ReturnToMainThread — using 作用域自动还原

1
2
3
4
5
6
7
8
9
10
11
12
async UniTask ProcessAsync()
{
// using 结束时自动返回主线程
await using (UniTask.ReturnToMainThread())
{
await UniTask.SwitchToThreadPool();
// 后台线程处理
var data = ProcessData();
}
// 已返回主线程
UpdateUI(data);
}

11. UniTaskCompletionSource 手动控制任务

类似 TaskCompletionSource<T>,用于将回调式 API 转换为可 await 的任务。

11.1 基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 无返回值版本
var tcs = new UniTaskCompletionSource();

// 某处完成
tcs.TrySetResult();
// 某处失败
tcs.TrySetException(new Exception("失败"));
// 某处取消
tcs.TrySetCanceled();

// 等待
await tcs.Task;

// 有返回值版本
var tcs2 = new UniTaskCompletionSource<string>();
tcs2.TrySetResult("Hello");
string result = await tcs2.Task;

11.2 封装回调 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 将 Unity 事件回调转为可 await 的 UniTask
public static UniTask<PointerEventData> WaitForClickAsync(Button button, CancellationToken token)
{
var tcs = new UniTaskCompletionSource<PointerEventData>();

button.onClick.AddListener(() =>
{
tcs.TrySetResult(null);
});

token.RegisterWithoutCaptureExecutionContext(() =>
{
tcs.TrySetCanceled(token);
});

return tcs.Task;
}

// 使用
await WaitForClickAsync(myButton, this.GetCancellationTokenOnDestroy());
Debug.Log("按钮被点击");

11.3 AutoResetUniTaskCompletionSource

适用于需要重复使用(Reset)的场景,内部会自动入池:

1
2
3
4
5
// 每次使用后自动重置,可重用
var source = AutoResetUniTaskCompletionSource.Create();
source.TrySetResult();
await source.Task;
// source 自动回池,下次 Create 可能返回同一对象

12. 错误处理与 Forget

12.1 标准 try-catch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async UniTask LoadAsync(CancellationToken token)
{
try
{
await UniTask.Delay(1000, cancellationToken: token);
await LoadDataAsync();
}
catch (OperationCanceledException)
{
// 被取消,通常静默处理
Debug.Log("加载被取消");
}
catch (Exception e)
{
Debug.LogError($"加载失败: {e}");
}
}

12.2 Forget — 即发即弃且屏蔽警告

直接调用返回 UniTask 的方法而不 await,编译器会警告。使用 .Forget() 明确表示这是有意的:

1
2
3
4
5
6
7
8
// ❌ 有编译器警告
DoSomethingAsync();

// ✅ 明确忽略结果
DoSomethingAsync().Forget();

// ✅ 带错误处理的 Forget
DoSomethingAsync().Forget(ex => Debug.LogException(ex));

12.3 UniTask.Never — 永不完成的任务

1
2
3
// 创建一个永远不完成的任务(除非 token 取消)
await UniTask.Never(token);
// 只有 token 被取消时才会抛 OperationCanceledException

13. UniTaskVoid — 即发即弃

在 C# 里,被 async 修饰的方法签名只能返回 Task / Task<T> / void(或自定义 awaitable,比如 UniTask / UniTaskVoid)。当你不打算 await 这个方法——即”即发即弃 (fire-and-forget)”——就需要在 async voidasync UniTaskVoid 之间做选择。两者看起来很像,差异很大。

13.1 async void 在 Unity 里到底有什么问题

async void 是 C# 语言为”事件处理器”开的口子(典型场景是 WinForms 的 Button_Click),它不是为普通业务代码设计的。具体毛病有四类:

(1) 异常会逃出调用栈,没人能 catch

async Task 抛出的异常会被打包到返回的 Task 上,await 方或 Task.Exception 可以拿到;
async void 没有 Task 对象,异常会被原样抛到当前的 SynchronizationContext——在 Unity 里通常表现为:要么 Console 里冒一条难以定位的红色 Error,要么直接干扰主循环。

1
2
3
4
5
6
7
8
async void Bad()
{
await UniTask.Delay(100);
throw new Exception("boom"); // 无法被外层 try/catch 捕获
}

try { Bad(); }
catch { /* 永远进不来 */ }

第一次 await 之后,控制权已经还给调用方了,try 块早已退出,异常发生时根本不在这个栈上。

(2) 堆分配多

async void / async Task 默认使用 .NET 的 AsyncVoidMethodBuilder / AsyncTaskMethodBuilder,每次调用都会分配状态机盒子、Task 对象、MoveNextRunner 等。按钮、Update 之类的高频路径里跑一堆 async void,GC 压力很显眼。
UniTaskVoid 走的是 UniTask 自家的 AsyncUniTaskVoidMethodBuilder同步完成路径零分配,异步路径上也通过对象池显著少于 Task。

(3) 无法被等待、无法被组合

async void 返回 void,调用方什么句柄都拿不到:

  • 不能再补一个 await 等它完成;
  • 不能塞进 WhenAll / WhenAny
  • 不能链式接 ContinueWith

UniTaskVoid 也不是为 await 设计的(它的存在意义就是即发即弃),但 UniTask 强制要求你在调用处加 .Forget(),让”我知道我不等它”这件事变成显式的代码意图,而不是一个看上去像普通函数调用的隐式语义。

(4) 它无视 UniTask 的 PlayerLoop 调度

UniTask 的核心优势是把异步续体(continuation)直接挂到 Unity 的 PlayerLoop 上跑,时序可控、不会切线程。但 async void 用的是默认 builder,续体走的是 SynchronizationContext.Post——在 Unity 里这条路径会绕一圈 UnitySynchronizationContext,时序不如 UniTask 精确,且 WebGL 上行为偶有差异。

13.2 UniTaskVoid 怎么解决这些问题

把上面四点逐一对回去:

维度 async void async UniTaskVoid
异常去向 抛到 SynchronizationContext,外层 catch 不住 由 UniTask 的全局未处理异常钩子接管 (UniTaskScheduler.UnobservedTaskException)
堆分配 每次调用都有 同步完成路径零分配;异步路径用对象池
调用语义 看起来像普通方法调用,意图不明确 必须 .Forget(),意图显式
调度 走 SynchronizationContext 走 UniTask 的 PlayerLoop
可被 Tracker 观测 是(Window → UniTask Tracker 能看到)

要点:UniTaskVoid 不是”能被等待的 void”,它仍然是即发即弃,只是把”丢出去之后会怎样”这件事做得更安全、更可观测了。

13.3 标准用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1) 声明为 UniTaskVoid(注意没有 <T>,本来就不返回值)
async UniTaskVoid FireAndForgetAsync()
{
await UniTask.Delay(1000);
Debug.Log("执行完毕(没有人等我)");
}

// 2) 调用处必须 .Forget(),否则编译器会给 CS4014 警告
FireAndForgetAsync().Forget();

// 3) 也可以传一个异常处理回调进 Forget,避免靠全局钩子兜底
FireAndForgetAsync().Forget(ex => Debug.LogError($"failed: {ex}"));

// 4) 匿名 lambda 场景用 UniTask.Void
UniTask.Void(async () =>
{
await UniTask.Delay(500);
Debug.Log("匿名即发即弃");
});

13.4 该用 UniTask 还是 UniTaskVoid:决策表

你的场景 推荐返回类型
方法会被 await,调用方关心完成时机 UniTask / UniTask<T>
调用方完全不关心结果、不需要等待(按钮回调、定时触发、日志上报) UniTaskVoid
实现接口/重写基类,签名要求 void(如 IPointerClickHandler.OnPointerClick 方法体里 UniTask.Void(async () => { ... })
需要把异常打包给上层 绝对UniTask,绝不用 UniTaskVoid

一条经验法则:只要你自己写的方法,永远不要返回 async voidasync void 唯一合理的使用场景是你被迫去实现一个签名为 void 的回调(比如某些第三方库的事件签名),即便如此,更推荐在里面包一层 UniTask.Void

13.5 配合 UnityAction(UI 按钮等)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ❌ 不推荐:async void 吞异常 + 堆分配 + 调度走 SyncContext
button.onClick.AddListener(async () => { await DoThingAsync(); });

// ✅ 推荐:UniTask.UnityAction 内部用 UniTaskVoid 包装
button.onClick.AddListener(UniTask.UnityAction(async () =>
{
await UniTask.Delay(500);
DoThing();
}));

// 带 CancellationToken,组件销毁时自动取消
button.onClick.AddListener(
UniTask.UnityAction(async token =>
{
await UniTask.Delay(500, cancellationToken: token);
},
this.GetCancellationTokenOnDestroy())
);

13.6 一个容易踩的坑:UniTaskVoid 不能被 await

1
2
3
4
5
6
async UniTaskVoid FooAsync() { /* ... */ }

// ❌ 编译错误:UniTaskVoid 没有 GetAwaiter
await FooAsync();

// 如果你后悔了想等它,把签名改回 UniTask 才是正解

如果你发现自己反复想 await 一个 UniTaskVoid,那说明它本来就不该是 UniTaskVoid——改成 UniTask


14. TimeoutController 超时控制

TimeoutController 可复用的超时控制器,避免每次超时都 new 一个 CancellationTokenSource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class MyBehaviour : MonoBehaviour
{
// 声明为字段,可以复用
TimeoutController _timeoutController = new TimeoutController();

// 也可以与外部 CancellationToken 联动
// TimeoutController _timeoutController;

async UniTask Start()
{
var destroyToken = this.GetCancellationTokenOnDestroy();
var timeoutController = new TimeoutController(
new CancellationTokenSource() // 外部 token 也可以,但这里用新的
);

try
{
// 给出 3 秒超时的 token
var timeoutToken = _timeoutController.Timeout(TimeSpan.FromSeconds(3));

// 使用此 token 执行操作
await LoadDataAsync(timeoutToken);
}
catch (OperationCanceledException)
{
if (_timeoutController.IsTimeout())
{
Debug.Log("加载超时!");
}
}
finally
{
// 重置超时控制器(如果没有超时,可以复用)
_timeoutController.Reset();
}
}

void OnDestroy()
{
_timeoutController.Dispose();
}
}

15. AsyncLazy 懒加载异步

用于只需要初始化一次的异步资源,多次调用只执行一次实际工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义懒加载
AsyncLazy<Config> _lazyConfig = UniTask.Lazy(async () =>
{
await UniTask.Delay(100); // 模拟加载
return new Config { Version = "1.0" };
});

// 多次调用只执行一次加载
async UniTask UseConfigAsync()
{
var config = await _lazyConfig; // 第一次:实际加载
var config2 = await _lazyConfig; // 第二次:直接返回缓存结果
}

16. UniTask Tracker 调试工具

UniTask 内置了任务追踪窗口,可以在 Editor 中查看所有正在运行的 UniTask:

  1. 菜单:Window → UniTask Tracker
  2. 可以看到所有活跃的 UniTask 及其调用栈
  3. 可以检测是否有泄漏的(永不完成的)任务
1
2
3
4
5
// 在代码中启用追踪(默认 Debug 模式下开启)
#if UNITY_EDITOR
TaskTracker.EnableTracking = true;
TaskTracker.EnableStackTrace = true;
#endif

17. 最佳实践与常见陷阱

✅ 推荐做法

1. 始终传递 CancellationToken

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 错误:没有取消支持,GameObject 销毁后可能崩溃
async UniTask BadExample()
{
await UniTask.Delay(5000);
transform.position = Vector3.zero; // GameObject 可能已销毁!
}

// ✅ 正确:绑定到对象生命周期
async UniTask GoodExample()
{
var token = this.GetCancellationTokenOnDestroy();
await UniTask.Delay(5000, cancellationToken: token);
transform.position = Vector3.zero; // 安全,因为 GameObject 仍存活
}

2. 异步方法命名加 Async 后缀

1
2
public async UniTask LoadDataAsync() { }    // ✅
public async UniTask LoadData() { } // ❌

3. 合理使用 WhenAll 并行化

1
2
3
4
5
6
7
// ❌ 串行:耗时 = A + B + C
await LoadAAsync();
await LoadBAsync();
await LoadCAsync();

// ✅ 并行:耗时 = max(A, B, C)
await UniTask.WhenAll(LoadAAsync(), LoadBAsync(), LoadCAsync());

4. Start() 返回 UniTask 代替协程

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 旧协程写法
IEnumerator Start()
{
yield return new WaitForSeconds(1f);
Debug.Log("完成");
}

// ✅ UniTask 写法,更简洁、可取消
async UniTask Start()
{
await UniTask.Delay(1000, cancellationToken: this.GetCancellationTokenOnDestroy());
Debug.Log("完成");
}

⚠️ 常见陷阱

陷阱 1:Yield 并非总是下一帧

1
2
3
4
5
6
// ⚠️ Yield 在同一帧、同一 PlayerLoop 时机触发时,
// 可能在当前帧末尾立即继续,而非严格等到"下一帧"
await UniTask.Yield();

// ✅ 明确等到下一帧
await UniTask.NextFrame();

陷阱 2:WaitForEndOfFrame 需要 MonoBehaviour

1
2
3
4
5
// ❌ Unity 2023 之前这样写不安全(行为不一致)
await UniTask.WaitForEndOfFrame();

// ✅ 传入 MonoBehaviour
await UniTask.WaitForEndOfFrame(this);

陷阱 3:UniTask 只能 await 一次

1
2
3
4
5
6
7
8
var task = SomeAsync();
await task; // ✅ 第一次 OK
await task; // ❌ 可能引发异常或未定义行为

// ✅ 需要多次 await 时使用 Preserve
var shared = SomeAsync().Preserve();
await shared;
await shared; // ✅ 安全

陷阱 4:忘记处理 OperationCanceledException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ❌ 取消异常会变成未处理异常,出现报错
async UniTask RunAsync()
{
await UniTask.Delay(1000, cancellationToken: token);
// 如果被取消,异常往上传播,若无人处理会打印错误
}

// ✅ 在入口处处理
async UniTask RunAsync()
{
try
{
await UniTask.Delay(1000, cancellationToken: token);
}
catch (OperationCanceledException)
{
// 正常取消,不报错
}
}

// 或者调用时 catch
RunAsync().Forget(ex =>
{
if (ex is not OperationCanceledException)
Debug.LogException(ex);
});

陷阱 5:在非主线程访问 Unity API

1
2
3
4
5
6
7
8
9
// ❌ 在线程池线程访问 Unity API 会抛异常
await UniTask.SwitchToThreadPool();
var pos = transform.position; // ❌ 危险!

// ✅ 切回主线程再访问
await UniTask.SwitchToThreadPool();
var data = HeavyCompute(); // ✅ 纯 C# 运算
await UniTask.SwitchToMainThread();
transform.position = data; // ✅ 主线程安全

快速参考表

API 说明
UniTask.Delay(ms) 按毫秒等待
UniTask.DelayFrame(n) 按帧等待
UniTask.Yield() 让出到当前时机的下一次触发
UniTask.NextFrame() 等待到严格的下一帧
UniTask.WaitForFixedUpdate() 等待下一个 FixedUpdate
UniTask.WaitForEndOfFrame(mono) 等待帧结束
UniTask.WaitUntil(() => cond) 等待条件成立
UniTask.WaitWhile(() => cond) 等待条件不再成立
UniTask.WaitUntilValueChanged(target, t => t.Value) 等待值变化
UniTask.WhenAll(tasks) 等待所有任务
UniTask.WhenAny(tasks) 等待任意任务完成
UniTask.SwitchToMainThread() 切换到主线程
UniTask.SwitchToThreadPool() 切换到后台线程
UniTask.RunOnThreadPool(action) 在后台线程运行并返回主线程
this.GetCancellationTokenOnDestroy() 获取绑定对象生命周期的 Token
task.Forget() 即发即弃,抑制警告
task.SuppressCancellationThrow() 取消时返回 bool 而非抛异常
task.Preserve() 允许多次 await
UniTaskCompletionSource 手动控制任务完成
TimeoutController 可复用的超时控制
UniTask.Lazy(factory) 懒加载异步
GetAsyncDestroyTrigger() 等待对象销毁
GetAsyncUpdateTrigger() 订阅 Update 事件流
GetAsyncOnTriggerEnterTrigger() 异步等待触发器进入