UniTask 0GC实现原理
UniTask 为什么能做到低 GC:从 async 状态机到 PlayerLoop 的实现原理
在 Unity 里写异步逻辑,最常见的方案有三种:Coroutine、Task、UniTask。
Coroutine 写起来简单,但 IEnumerator、yield return、各种等待对象容易带来分配。标准 Task 更适合通用 .NET 异步模型,但它的线程池、同步上下文、Task 对象模型并不是为 Unity 主线程每帧调度设计的。
UniTask 的目标不是让所有代码“绝对 0GC”,而是让高频异步路径,尤其是每帧等待、Unity PlayerLoop 等场景,尽量避免持续分配。它的核心思路可以概括为:
用值类型表示 Task,用自定义 async method builder 接管编译器状态机,用可池化的 source/runner 承载挂起状态,用 Unity PlayerLoop 驱动 continuation。
下面从源码角度拆开看。
1. 先看 async/await 的两套语法糖
分析 UniTask 的 0GC 之前,最好先把 C# 的 async/await 语法糖拆开。这里其实有两套互相配合的编译器约定:
1 | await someValue |
前者决定“一个对象能不能被 await”。后者决定“一个方法能不能写成 async 并返回某种 task-like 类型”。
例如:
1 | await someUniTask; |
编译器大致会按下面的模式展开:
1 | var awaiter = someUniTask.GetAwaiter(); |
而:
1 | async UniTask FooAsync() |
编译器会根据 UniTask 上的 [AsyncMethodBuilder] 找到 AsyncUniTaskMethodBuilder,然后生成状态机,并在状态机里调用 builder 的 Start、AwaitUnsafeOnCompleted、SetResult、SetException 和 Task。
所以 UniTask 的优化也分成两部分:
- 实现并优化
await UniTask的 awaiter 语法糖。 - 实现并优化
async UniTask的 builder 语法糖。
2. UniTask 如何实现 await 语法糖
await someUniTask 能成立,是因为 UniTask 提供了 GetAwaiter():
1 | public Awaiter GetAwaiter() |
GetAwaiter() 返回的 Awaiter 是结构体:
1 | public readonly struct Awaiter : ICriticalNotifyCompletion |
它提供 awaiter pattern 需要的成员:
1 | public bool IsCompleted { get; } |
这些成员分别对应 await 的几个阶段:
1 | GetAwaiter() |
因为 Awaiter 实现了 ICriticalNotifyCompletion,编译器会优先使用 builder 的 AwaitUnsafeOnCompleted,而不是 AwaitOnCompleted。
也就是说,下面这句:
1 | await UniTask.Yield(); |
在未完成时,最终会走到 AsyncUniTaskMethodBuilder.AwaitUnsafeOnCompleted:
1 | public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>( |
这里的 runnerPromise.MoveNext 就是恢复 async 状态机的 continuation。
UnsafeOnCompleted 内部不会创建标准 Task,而是把 continuation 注册给 IUniTaskSource:
1 | task.source.OnCompleted( |
这让 UniTask 可以用自己的 source、自己的 completion core、自己的 PlayerLoop 调度控制 continuation。
3. UniTask 如何优化 await 路径
理解了 await 语法糖后,再看 UniTask 为什么低分配就更清楚了。
第一,GetAwaiter() 返回的是结构体 Awaiter,不是堆对象。
第二,Awaiter 内部只保存一个 UniTask 值:
1 | readonly UniTask task; |
第三,OnCompleted / UnsafeOnCompleted 不创建标准 Task,而是把 continuation 转交给 IUniTaskSource:
1 | task.source.OnCompleted( |
第四,AwaiterActions.InvokeContinuationDelegate 是静态缓存的委托:
1 | internal static readonly Action<object> InvokeContinuationDelegate = Continuation; |
这样可以避免每次 await 都创建一个新的包装委托。
第五,真正承载异步状态的 source/runner 通常是池化对象。例如 async 方法挂起时使用 AsyncUniTask<TStateMachine>,每帧等待时使用 YieldPromise。
所以 await UniTask 的低分配来自几个点叠加:
1 | UniTask 是结构体 |
4. UniTask 本身是值类型
UniTask 定义在 UniTask/Runtime/UniTask.cs:
1 | [] |
UniTask<T> 也是结构体:
1 | [] |
这和 Task 最大的区别是:UniTask 本身不是堆对象。它只是一个很小的值类型句柄。
如果任务已经同步完成,UniTask<T> 可以直接把结果存在 result 字段里,不需要创建额外的 promise/source 对象。
如果任务异步挂起,UniTask 才通过 source + token 指向一个真正保存异步状态的对象。
也就是说:
1 | 同步完成: |
这一步解决的是“返回一个异步结果时,不必天然创建 Task 对象”。
5. AsyncMethodBuilder 让 UniTask 接入 C# async 语法
UniTask 上的这个特性很关键:
1 | [] |
它告诉 C# 编译器:当方法返回 async UniTask 时,不要使用默认的 AsyncTaskMethodBuilder,而要使用 UniTask 自己的 AsyncUniTaskMethodBuilder。
例如:
1 | async UniTask FooAsync() |
编译器大致会生成类似这样的状态机代码:
1 | var builder = AsyncUniTaskMethodBuilder.Create(); |
遇到 await 时:
1 | builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); |
方法正常完成:
1 | builder.SetResult(); |
方法抛异常:
1 | builder.SetException(exception); |
注意,Create、Start、Task、SetResult、SetException、AwaitUnsafeOnCompleted 这些方法并没有额外标记。它们是 C# async method builder pattern 的约定方法。编译器通过固定名称和签名识别它们。
6. AsyncUniTaskMethodBuilder 的职责
AsyncUniTaskMethodBuilder 定义在 AsyncUniTaskMethodBuilder.cs:
1 | public struct AsyncUniTaskMethodBuilder |
它本身也是结构体。它的职责不是“生成状态机”。状态机是编译器生成的。
它真正做的是:
- 作为编译器 async 语法的入口。
- 启动状态机。
- 在第一次异步挂起时,创建或复用一个 runner。
- 把 runner 的
MoveNext注册给 awaiter。 - 在 async 方法完成或异常时,把结果写入 runner/promise。
- 通过
Task属性把最终的UniTask返回给调用方。
关键代码在 AwaitUnsafeOnCompleted:
1 | public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>( |
这里有两个重点:
第一,只有当真的遇到未完成的 await,需要挂起时,才会创建或复用 AsyncUniTask<TStateMachine>。
第二,awaiter 完成后要调用的 continuation,就是 runnerPromise.MoveNext,也就是继续执行编译器生成的 async 状态机。
7. AsyncUniTask 是可池化的状态机运行容器
AsyncUniTask<TStateMachine> 定义在 StateMachineRunner.cs:
1 | internal sealed class AsyncUniTask<TStateMachine> : |
它的职责非常集中:
- 保存编译器生成的状态机副本。
- 提供
MoveNext给 awaiter 作为 continuation。 - 实现
IUniTaskSource,让外部UniTask可以 await 它。 - 保存完成、异常、取消状态。
- 完成后清空并回收到对象池。
第一次挂起时,builder 调用:
1 | AsyncUniTask<TStateMachine>.SetStateMachine(ref stateMachine, ref runnerPromise); |
内部会先从池里取:
1 | if (!pool.TryPop(out var result)) |
这里池化的是 AsyncUniTask<TStateMachine> 这个 runner 对象,不是状态机本身。状态机是编译器生成的 struct,runner 保存的是它的一份拷贝。
awaiter 完成后,会调用:
1 | runnerPromise.MoveNext |
实际进入:
1 | void Run() |
这就恢复了 async 方法后续逻辑。
8. IUniTaskSource 是 UniTask 的真正结果源
UniTask 只是一个值类型句柄,真正的异步状态保存在 IUniTaskSource 里。
AsyncUniTask<TStateMachine> 实现了 IUniTaskSource:
1 | public UniTask Task |
调用方拿到的 UniTask 内部指向这个 runner:
1 | UniTask |
core 是 UniTaskCompletionSourceCore<AsyncUnit>,它负责保存:
- 当前状态。
- continuation。
- 异常或取消信息。
- version token。
正常完成时:
1 | public void SetResult() |
异常时:
1 | public void SetException(Exception exception) |
调用方 await 完成后会进入:
1 | public void GetResult(short token) |
TryReturn() 会清理状态并回池:
1 | core.Reset(); |
这就是 UniTask 降低 GC 的核心之一:异步状态承载对象可以复用。
9. TaskPool 用链表池复用 runner/source
UniTask 的很多内部 promise/source 都实现了 ITaskPoolNode<T>,然后通过 TaskPool<T> 复用。
典型结构是:
1 | static TaskPool<AsyncUniTask<TStateMachine>> pool; |
使用时:
1 | pool.TryPop(out var result) |
因此第一次运行可能会分配对象,但后续同类型 async 状态机再次挂起时,就可以复用池里的 runner。
这也是为什么说 UniTask 更准确的描述是“低 GC”或“稳定运行阶段 0GC”,而不是所有场景绝对 0GC。
10. PlayerLoop 替代 Coroutine 调度
以常见的每帧等待为例:
1 | await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken); |
UniTask 不需要 IEnumerator 和 yield return null。它会创建或复用一个 YieldPromise,然后把它加入 Unity PlayerLoop:
1 | PlayerLoopHelper.AddAction(timing, result); |
YieldPromise 本身也是可池化的:
1 | sealed class YieldPromise : |
在 PlayerLoop 某个阶段,runner 会调用它的 MoveNext():
1 | public bool MoveNext() |
完成后,await continuation 恢复 async 状态机,YieldPromise 自己也会 reset 并回到池里。
所以在类似 UI 填充动画这样的循环中:
1 | while (elapsed < duration) |
预热之后,每帧等待可以复用 YieldPromise,状态机 runner 也可以复用,从而避免每帧稳定产生 GC。
11. UniTaskVoid 和 UniTask 的差别
async UniTask 使用的是:
1 | AsyncUniTaskMethodBuilder |
因为它需要返回一个可被 await 的 UniTask,所以 runner 必须同时是 promise/source。
async UniTaskVoid 使用的是:
1 | AsyncUniTaskVoidMethodBuilder |
UniTaskVoid 是 fire-and-forget,不需要给调用方返回可 await 的结果,因此只需要保存状态机、提供 MoveNext、结束后回池即可。
简单说:
1 | UniTask: |
12. 哪些地方仍然可能产生 GC
UniTask 不是魔法。以下场景仍然可能分配:
- 第一次使用某种 runner/source,池里没有对象,需要
new。 CancellationTokenSource、CreateLinkedTokenSource会分配。- 取消时可能创建
OperationCanceledException。 - 异常路径会捕获异常信息。
- 闭包、lambda 捕获、LINQ、装箱仍然会分配。
Debug.Log、字符串拼接、Unity 日志系统可能分配。Preserve、SuppressCancellationThrow等包装操作在某些 pending 场景可能创建包装 source。
因此更严谨的说法是:
UniTask 通过值类型 task、池化 promise/runner、PlayerLoop 调度和不捕获上下文,避免高频异步路径持续分配;在预热后的稳定路径上可以做到接近或达到 0GC。
13. 整体流程图
1 | async UniTask FooAsync() |
总结
UniTask 实现低 GC 的关键不是某一个技巧,而是一整套配合:
UniTask/UniTask<T>是结构体,避免默认Task对象。[AsyncMethodBuilder]让async UniTask使用自定义 builder。AsyncUniTaskMethodBuilder在第一次挂起时才创建或复用 runner。AsyncUniTask<TStateMachine>保存编译器状态机副本,并实现IUniTaskSource。UniTaskCompletionSourceCore<T>保存状态、结果、异常和 continuation。TaskPool<T>复用 runner、promise、yield source 等对象。PlayerLoopHelper把异步等待接入 Unity PlayerLoop,避免 Coroutine 等待对象。- awaiter 和 builder 尽量不捕获上下文,减少 continuation 注册成本。
最终效果是:UniTask 把标准 async/await 的语法保留下来,但绕开了标准 Task 的大量通用运行时成本,并把 Unity 高频异步等待改造成了值类型句柄 + 池化 source + PlayerLoop 调度的模型。
