UniTask 为什么能做到低 GC:从 async 状态机到 PlayerLoop 的实现原理

在 Unity 里写异步逻辑,最常见的方案有三种:Coroutine、Task、UniTask。

Coroutine 写起来简单,但 IEnumeratoryield 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
2
3
4
5
6
7
await someValue
依赖 awaiter pattern:
GetAwaiter / IsCompleted / GetResult / OnCompleted

async SomeTaskLike SomeMethod()
依赖 async method builder pattern:
[AsyncMethodBuilder] / Create / Start / Task / SetResult / SetException / AwaitUnsafeOnCompleted

前者决定“一个对象能不能被 await”。后者决定“一个方法能不能写成 async 并返回某种 task-like 类型”。

例如:

1
await someUniTask;

编译器大致会按下面的模式展开:

1
2
3
4
5
6
7
8
9
10
11
var awaiter = someUniTask.GetAwaiter();

if (!awaiter.IsCompleted)
{
// 保存当前状态机状态
// 把状态机 MoveNext 注册给 awaiter
builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
return;
}

awaiter.GetResult();

而:

1
2
3
4
async UniTask FooAsync()
{
await UniTask.Yield();
}

编译器会根据 UniTask 上的 [AsyncMethodBuilder] 找到 AsyncUniTaskMethodBuilder,然后生成状态机,并在状态机里调用 builder 的 StartAwaitUnsafeOnCompletedSetResultSetExceptionTask

所以 UniTask 的优化也分成两部分:

  1. 实现并优化 await UniTask 的 awaiter 语法糖。
  2. 实现并优化 async UniTask 的 builder 语法糖。

2. UniTask 如何实现 await 语法糖

await someUniTask 能成立,是因为 UniTask 提供了 GetAwaiter()

1
2
3
4
public Awaiter GetAwaiter()
{
return new Awaiter(this);
}

GetAwaiter() 返回的 Awaiter 是结构体:

1
2
3
4
public readonly struct Awaiter : ICriticalNotifyCompletion
{
readonly UniTask task;
}

它提供 awaiter pattern 需要的成员:

1
2
3
4
public bool IsCompleted { get; }
public void GetResult()
public void OnCompleted(Action continuation)
public void UnsafeOnCompleted(Action continuation)

这些成员分别对应 await 的几个阶段:

1
2
3
4
5
6
7
8
9
10
11
GetAwaiter()
获取 awaiter

IsCompleted
判断是否已经完成;如果完成,可以同步继续执行

OnCompleted / UnsafeOnCompleted
未完成时注册 continuation

GetResult()
await 完成后取结果;如果有异常,也在这里抛出

因为 Awaiter 实现了 ICriticalNotifyCompletion,编译器会优先使用 builder 的 AwaitUnsafeOnCompleted,而不是 AwaitOnCompleted

也就是说,下面这句:

1
await UniTask.Yield();

在未完成时,最终会走到 AsyncUniTaskMethodBuilder.AwaitUnsafeOnCompleted

1
2
3
4
5
6
7
8
9
10
11
12
13
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
ref TAwaiter awaiter,
ref TStateMachine stateMachine)
where TAwaiter : ICriticalNotifyCompletion
where TStateMachine : IAsyncStateMachine
{
if (runnerPromise == null)
{
AsyncUniTask<TStateMachine>.SetStateMachine(ref stateMachine, ref runnerPromise);
}

awaiter.UnsafeOnCompleted(runnerPromise.MoveNext);
}

这里的 runnerPromise.MoveNext 就是恢复 async 状态机的 continuation。

UnsafeOnCompleted 内部不会创建标准 Task,而是把 continuation 注册给 IUniTaskSource

1
2
3
4
task.source.OnCompleted(
AwaiterActions.InvokeContinuationDelegate,
continuation,
task.token);

这让 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
2
3
4
task.source.OnCompleted(
AwaiterActions.InvokeContinuationDelegate,
continuation,
task.token);

第四,AwaiterActions.InvokeContinuationDelegate 是静态缓存的委托:

1
internal static readonly Action<object> InvokeContinuationDelegate = Continuation;

这样可以避免每次 await 都创建一个新的包装委托。

第五,真正承载异步状态的 source/runner 通常是池化对象。例如 async 方法挂起时使用 AsyncUniTask<TStateMachine>,每帧等待时使用 YieldPromise

所以 await UniTask 的低分配来自几个点叠加:

1
2
3
4
5
UniTask 是结构体
Awaiter 是结构体
continuation 走 IUniTaskSource
静态委托减少包装分配
source/runner 池化复用

4. UniTask 本身是值类型

UniTask 定义在 UniTask/Runtime/UniTask.cs

1
2
3
4
5
6
7
[AsyncMethodBuilder(typeof(AsyncUniTaskMethodBuilder))]
[StructLayout(LayoutKind.Auto)]
public readonly partial struct UniTask
{
readonly IUniTaskSource source;
readonly short token;
}

UniTask<T> 也是结构体:

1
2
3
4
5
6
7
8
[AsyncMethodBuilder(typeof(AsyncUniTaskMethodBuilder<>))]
[StructLayout(LayoutKind.Auto)]
public readonly struct UniTask<T>
{
readonly IUniTaskSource<T> source;
readonly T result;
readonly short token;
}

这和 Task 最大的区别是:UniTask 本身不是堆对象。它只是一个很小的值类型句柄。

如果任务已经同步完成,UniTask<T> 可以直接把结果存在 result 字段里,不需要创建额外的 promise/source 对象。

如果任务异步挂起,UniTask 才通过 source + token 指向一个真正保存异步状态的对象。

也就是说:

1
2
3
4
5
同步完成:
UniTask<T> 直接保存 result

异步挂起:
UniTask<T> 保存 IUniTaskSource<T> + token

这一步解决的是“返回一个异步结果时,不必天然创建 Task 对象”。

5. AsyncMethodBuilder 让 UniTask 接入 C# async 语法

UniTask 上的这个特性很关键:

1
[AsyncMethodBuilder(typeof(AsyncUniTaskMethodBuilder))]

它告诉 C# 编译器:当方法返回 async UniTask 时,不要使用默认的 AsyncTaskMethodBuilder,而要使用 UniTask 自己的 AsyncUniTaskMethodBuilder

例如:

1
2
3
4
async UniTask FooAsync()
{
await UniTask.Yield();
}

编译器大致会生成类似这样的状态机代码:

1
2
3
var builder = AsyncUniTaskMethodBuilder.Create();
builder.Start(ref stateMachine);
return builder.Task;

遇到 await 时:

1
builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);

方法正常完成:

1
builder.SetResult();

方法抛异常:

1
builder.SetException(exception);

注意,CreateStartTaskSetResultSetExceptionAwaitUnsafeOnCompleted 这些方法并没有额外标记。它们是 C# async method builder pattern 的约定方法。编译器通过固定名称和签名识别它们。

6. AsyncUniTaskMethodBuilder 的职责

AsyncUniTaskMethodBuilder 定义在 AsyncUniTaskMethodBuilder.cs

1
2
3
4
5
public struct AsyncUniTaskMethodBuilder
{
IStateMachineRunnerPromise runnerPromise;
Exception ex;
}

它本身也是结构体。它的职责不是“生成状态机”。状态机是编译器生成的。

它真正做的是:

  1. 作为编译器 async 语法的入口。
  2. 启动状态机。
  3. 在第一次异步挂起时,创建或复用一个 runner。
  4. 把 runner 的 MoveNext 注册给 awaiter。
  5. 在 async 方法完成或异常时,把结果写入 runner/promise。
  6. 通过 Task 属性把最终的 UniTask 返回给调用方。

关键代码在 AwaitUnsafeOnCompleted

1
2
3
4
5
6
7
8
9
10
11
12
13
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
ref TAwaiter awaiter,
ref TStateMachine stateMachine)
where TAwaiter : ICriticalNotifyCompletion
where TStateMachine : IAsyncStateMachine
{
if (runnerPromise == null)
{
AsyncUniTask<TStateMachine>.SetStateMachine(ref stateMachine, ref runnerPromise);
}

awaiter.UnsafeOnCompleted(runnerPromise.MoveNext);
}

这里有两个重点:

第一,只有当真的遇到未完成的 await,需要挂起时,才会创建或复用 AsyncUniTask<TStateMachine>

第二,awaiter 完成后要调用的 continuation,就是 runnerPromise.MoveNext,也就是继续执行编译器生成的 async 状态机。

7. AsyncUniTask 是可池化的状态机运行容器

AsyncUniTask<TStateMachine> 定义在 StateMachineRunner.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
internal sealed class AsyncUniTask<TStateMachine> :
IStateMachineRunnerPromise,
IUniTaskSource,
ITaskPoolNode<AsyncUniTask<TStateMachine>>
where TStateMachine : IAsyncStateMachine
{
static TaskPool<AsyncUniTask<TStateMachine>> pool;

public Action MoveNext { get; }

TStateMachine stateMachine;
UniTaskCompletionSourceCore<AsyncUnit> core;
}

它的职责非常集中:

  1. 保存编译器生成的状态机副本。
  2. 提供 MoveNext 给 awaiter 作为 continuation。
  3. 实现 IUniTaskSource,让外部 UniTask 可以 await 它。
  4. 保存完成、异常、取消状态。
  5. 完成后清空并回收到对象池。

第一次挂起时,builder 调用:

1
AsyncUniTask<TStateMachine>.SetStateMachine(ref stateMachine, ref runnerPromise);

内部会先从池里取:

1
2
3
4
5
6
7
if (!pool.TryPop(out var result))
{
result = new AsyncUniTask<TStateMachine>();
}

runnerPromiseFieldRef = result;
result.stateMachine = stateMachine;

这里池化的是 AsyncUniTask<TStateMachine> 这个 runner 对象,不是状态机本身。状态机是编译器生成的 struct,runner 保存的是它的一份拷贝。

awaiter 完成后,会调用:

1
runnerPromise.MoveNext

实际进入:

1
2
3
4
void Run()
{
stateMachine.MoveNext();
}

这就恢复了 async 方法后续逻辑。

8. IUniTaskSource 是 UniTask 的真正结果源

UniTask 只是一个值类型句柄,真正的异步状态保存在 IUniTaskSource 里。

AsyncUniTask<TStateMachine> 实现了 IUniTaskSource

1
2
3
4
5
6
7
public UniTask Task
{
get
{
return new UniTask(this, core.Version);
}
}

调用方拿到的 UniTask 内部指向这个 runner:

1
2
3
UniTask
source -> AsyncUniTask<TStateMachine>
token -> core.Version

coreUniTaskCompletionSourceCore<AsyncUnit>,它负责保存:

  1. 当前状态。
  2. continuation。
  3. 异常或取消信息。
  4. version token。

正常完成时:

1
2
3
4
public void SetResult()
{
core.TrySetResult(AsyncUnit.Default);
}

异常时:

1
2
3
4
public void SetException(Exception exception)
{
core.TrySetException(exception);
}

调用方 await 完成后会进入:

1
2
3
4
5
6
7
8
9
10
11
public void GetResult(short token)
{
try
{
core.GetResult(token);
}
finally
{
TryReturn();
}
}

TryReturn() 会清理状态并回池:

1
2
3
core.Reset();
stateMachine = default;
pool.TryPush(this);

这就是 UniTask 降低 GC 的核心之一:异步状态承载对象可以复用。

9. TaskPool 用链表池复用 runner/source

UniTask 的很多内部 promise/source 都实现了 ITaskPoolNode<T>,然后通过 TaskPool<T> 复用。

典型结构是:

1
2
3
static TaskPool<AsyncUniTask<TStateMachine>> pool;
AsyncUniTask<TStateMachine> nextNode;
public ref AsyncUniTask<TStateMachine> NextNode => ref nextNode;

使用时:

1
2
pool.TryPop(out var result)
pool.TryPush(this)

因此第一次运行可能会分配对象,但后续同类型 async 状态机再次挂起时,就可以复用池里的 runner。

这也是为什么说 UniTask 更准确的描述是“低 GC”或“稳定运行阶段 0GC”,而不是所有场景绝对 0GC。

10. PlayerLoop 替代 Coroutine 调度

以常见的每帧等待为例:

1
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);

UniTask 不需要 IEnumeratoryield return null。它会创建或复用一个 YieldPromise,然后把它加入 Unity PlayerLoop:

1
PlayerLoopHelper.AddAction(timing, result);

YieldPromise 本身也是可池化的:

1
2
3
4
sealed class YieldPromise :
IUniTaskSource,
IPlayerLoopItem,
ITaskPoolNode<YieldPromise>

在 PlayerLoop 某个阶段,runner 会调用它的 MoveNext()

1
2
3
4
5
6
7
8
9
10
11
public bool MoveNext()
{
if (cancellationToken.IsCancellationRequested)
{
core.TrySetCanceled(cancellationToken);
return false;
}

core.TrySetResult(null);
return false;
}

完成后,await continuation 恢复 async 状态机,YieldPromise 自己也会 reset 并回到池里。

所以在类似 UI 填充动画这样的循环中:

1
2
3
4
5
while (elapsed < duration)
{
await UniTask.Yield(PlayerLoopTiming.Update, ct);
elapsed += Time.deltaTime;
}

预热之后,每帧等待可以复用 YieldPromise,状态机 runner 也可以复用,从而避免每帧稳定产生 GC。

11. UniTaskVoid 和 UniTask 的差别

async UniTask 使用的是:

1
2
3
AsyncUniTaskMethodBuilder
IStateMachineRunnerPromise
AsyncUniTask<TStateMachine>

因为它需要返回一个可被 await 的 UniTask,所以 runner 必须同时是 promise/source。

async UniTaskVoid 使用的是:

1
2
3
AsyncUniTaskVoidMethodBuilder
IStateMachineRunner
AsyncUniTaskVoid<TStateMachine>

UniTaskVoid 是 fire-and-forget,不需要给调用方返回可 await 的结果,因此只需要保存状态机、提供 MoveNext、结束后回池即可。

简单说:

1
2
3
4
5
UniTask:
需要 IUniTaskSource,调用方可以 await

UniTaskVoid:
不返回结果,主要用于 fire-and-forget

12. 哪些地方仍然可能产生 GC

UniTask 不是魔法。以下场景仍然可能分配:

  1. 第一次使用某种 runner/source,池里没有对象,需要 new
  2. CancellationTokenSourceCreateLinkedTokenSource 会分配。
  3. 取消时可能创建 OperationCanceledException
  4. 异常路径会捕获异常信息。
  5. 闭包、lambda 捕获、LINQ、装箱仍然会分配。
  6. Debug.Log、字符串拼接、Unity 日志系统可能分配。
  7. PreserveSuppressCancellationThrow 等包装操作在某些 pending 场景可能创建包装 source。

因此更严谨的说法是:

UniTask 通过值类型 task、池化 promise/runner、PlayerLoop 调度和不捕获上下文,避免高频异步路径持续分配;在预热后的稳定路径上可以做到接近或达到 0GC。

13. 整体流程图

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
async UniTask FooAsync()
|
| 编译器看到 [AsyncMethodBuilder]
v
AsyncUniTaskMethodBuilder
|
| Start(ref stateMachine)
v
编译器生成的状态机 MoveNext()
|
| 遇到未完成 await
v
AwaitUnsafeOnCompleted(...)
|
| 从 TaskPool 取或 new
v
AsyncUniTask<TStateMachine>
|
| 保存 stateMachine 副本
| 暴露 MoveNext
| 实现 IUniTaskSource
v
返回 UniTask(source=this, token=core.Version)
|
| awaiter 完成
v
runnerPromise.MoveNext()
|
| async 方法继续执行
v
SetResult / SetException
|
| 调用方 GetResult
v
core.Reset()
stateMachine = default
runner 回到 TaskPool

总结

UniTask 实现低 GC 的关键不是某一个技巧,而是一整套配合:

  1. UniTask / UniTask<T> 是结构体,避免默认 Task 对象。
  2. [AsyncMethodBuilder]async UniTask 使用自定义 builder。
  3. AsyncUniTaskMethodBuilder 在第一次挂起时才创建或复用 runner。
  4. AsyncUniTask<TStateMachine> 保存编译器状态机副本,并实现 IUniTaskSource
  5. UniTaskCompletionSourceCore<T> 保存状态、结果、异常和 continuation。
  6. TaskPool<T> 复用 runner、promise、yield source 等对象。
  7. PlayerLoopHelper 把异步等待接入 Unity PlayerLoop,避免 Coroutine 等待对象。
  8. awaiter 和 builder 尽量不捕获上下文,减少 continuation 注册成本。

最终效果是:UniTask 把标准 async/await 的语法保留下来,但绕开了标准 Task 的大量通用运行时成本,并把 Unity 高频异步等待改造成了值类型句柄 + 池化 source + PlayerLoop 调度的模型。