Unity异步扩展实践(一)——以UniTask库为参考

STask GitHub仓库链接

背景

What?

异步方法诞生于C# 5时代,它基于 Task 和 Task<T> 类型,让C#在异步编程领域的思想产生了巨大转变。以从文件中读取内容为例:

static async Task Main(string[] args)
{
    string fileName = "d:/1.txt";
    string s = await File.ReadAllTextAsync(fileName);
    Console.WriteLine(s);
}

上面的代码将等待读取完文件中的内容后,将其输出到控制台窗口中。C#中对于文件操作的方法几乎都有与之对应的异步版本。在异步方法出现之前,要实现异步操作的功能,需要程序员手动开启新线程,并将任务分配给线程,然后监听线程完成工作的情况,待任务结束后还需关闭线程。如此复杂的步骤无疑给程序员增加了工作负担——为了获得更好性能,我们同时付出了复杂编码的代价。

而异步方法出现之后,其将编码简化到了仅靠两个关键字(async、await)和 Task 对象就能以同步编程的方式实现异步的效果。

异步方法的实现原理其实很简单——在程序编译前,编译器会将异步方法转换为状态机,当 await 语句后面的任务没有完成时,状态机将执行到这一句并等待,而在等待的过程中,线程将去执行其他任务,不会一直阻塞在这里。一旦任务完成,状态机被推动到下一个状态,线程继续执行 await 语句到之后的代码。

使用异步方法时,程序员对其中的线程切换几乎没有任何感知。在 .NET 实现中,执行 await 语句之前和之后代码的线程是有可能不同的。虽然有线程切换,但我们不需要关心其中的细节,.NET 已经帮我们完成了其中的脏活累活,让结果看起来和单线程一致。

可以将 Task 类型看作对多线程的包装,将异步方法看作对 Task 类型的扩展。异步方法的 async 和 await 关键字本质上是C#提供的语法糖。

Why?

异步方法的语法如此优美,运行原理也科学有效,我们为何还需要基于C#的异步机制,重新造一个轮子呢?

当某项简单易用的新(yu)技(fa)术(tang)诞生后,它很容易在项目中被“滥用”,并且用户会忽略其背后的代价。在使用异步方法时,会有隐形的申请 Task 对象内存的性能消耗——虽然我们没有直接申请它,但异步方法状态机却这么做了。

后来 .NET 推出了 ValueTask 类型,解决了当异步方法以同步的方式完成时,不必要的内存开销。但这足够好了吗?

我们知道Unity是单线程的,任何跟引擎相关的操作都必须在主线程中进行,而异步方法内部是存在线程切换的,这就是问题所在。虽然Unity的 .NET 实现库会确保 await 语句后面的代码仍然会交由主线程执行,但其背后仍然有针对“同步上下文”所进行的不必要的操作,这也是额外的性能开销。

除了性能问题,异步方法能带给我们的不只是等待资源加载,等待网络请求返回这些便捷,我们甚至可以编写自己的可等待对象,一行代码实现等待N秒、N帧的效果,彻底摆脱协程,同时做到 0 GC。

AssetBundle ab = await AssetBundle.LoadFromFileAsync(path);//等待资源加载

var txt = (await UnityWebRequest.Get("https://...").SendWebRequest()).downloadHandler.text;//等待网络请求

await UniTask.Delay(TimeSpan.FromSeconds(10));//等待十秒

await UniTask.NextFrame();//等待到下一帧

How?

好消息是,我们已经有了屌炸天的 UniTask 库,你能想到的,它已经帮你实现好了,暂时没想到的,它也提前做好了,并且它免费开源:

github地址

想要快速了解和上手的同学,可以阅读仓库中的文档,支持中文。

对于想要深入学习 UniTask 源码的小伙伴,打开项目后可能会被其中大量的接口、扩展方法、工具类给弄得一脸懵逼。但经过笔者的大致研究之后,发现 UniTask 库对于刚接触C#编程的小伙伴是非常具有研究价值的,因此这篇文章将以 UniTask 为例,从原理到代码分享笔者的 Unity 异步库实现的经验。

注意嗷:由于 UniTask 库中有许多非核心的附加代码,比如为了兼容不同版本的C#、Unity,以及编辑器相关的代码。这些代码对实现异步库的核心功能没有实质上的作用,后文的代码部分会将这部分给去除掉,方便大伙儿理解。因此为了跟 UniTask 区分,后面我们将异步库命名为 STask ,S 代表着 Simple。

原理

想要自己实现一套基于关键字 async 、 await 的高效异步机制,我们需要有自己的:

  • Task Type,即可以被 await 等待的,类似 Task 的类型。
  • Builder Type,该类型包含的成员,可以看作是状态机对外提供的接口。

那么,我们应该怎么实现这俩类型呢?其实微软已经有文档帮我们总结了步骤:文档跳转 。

下面我们简单说明下该文档的重点。

Task Type

  1. 自定义 task 可以是 class 或 struct 类型。后续实践我们将采用 struct 。
  2. 自定义 task 需要用特性 System.Runtime.CompilerServices.AsyncMethodBuilderAttribute 标记,来与异步状态机建立关系。
  3. 自定义 task 可以有泛型参数,该参数用于异步方法的返回值类型;也可以没有泛型参数。
  4. 自定义 task 需要定义有 GetAwaiter() 方法(扩展方法也可以),有了这个方法,其可以被 await 关键字“等待”。

根据上面的需求,我们的自定义 task 会如同下方的实例代码:

[AsyncMethodBuilder(typeof(MyTaskMethodBuilder<>))]
class MyTask<T>
{
    public Awaiter<T> GetAwaiter();
}

//除了自定义 task 之外,我们还需定义其使用的 Awaiter 类型
class Awaiter<T> : INotifyCompletion
{
    public bool IsCompleted { get; } //等待前,状态机将通过该变量判断 task 是否已经完成工作
    public T GetResult(); //task完成工作后,状态机通过该方法获取结果
    public void OnCompleted(Action completion); //当需要等待时,状态机调用该方法注册回调,即await关键字之后的代码
}

关于 Awaiter 类型,除了其实现接口的 OnCompleted 方法之外,IsCompleted 和 GetResult 这俩成员是不可省略的,在编译期间编译器会检查,若出现缺省的情况会导致编译不通过哦。我们会在后面讨论其工作细节。

Builder Type

接着我们来看 builder 有哪些知识点:

  1. builder 可以为 class 或 struct 类型。在后面的实践中,我们的 builder 为 struct 类型。
  2. builder 可以有至多一个泛型参数,该参数可用作异步方法的返回值类型,并且自身不能作为泛型类型。
  3. builder 需要定义以下访问级别为 public 的方法:
class MyTaskMethodBuilder<T>
{    
    public static MyTaskMethodBuilder<T> Create(); //static

    public void Start<TStateMachine>(ref TStateMachine stateMachine)
        where TStateMachine : IAsyncStateMachine;
    
    public void SetStateMachine(IAsyncStateMachine stateMachine);
    public void SetException(Exception exception);
    public void SetResult(T result); //若是无泛型状态机,该方法也没有参数

    public void AwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine;
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine;

    public MyTask<T> Task { get; } //task-like对象
}
  1. 下面我们来讨论这些方法的工作流程。

状态机的工作流程

为了让我们更直观地理解异步方法的工作机制,软件工程之神降下它的荣光,一张流程图出现在眼前:

这张流程图可以让我们对异步机制有一个大概印象,目前我们不用将里面的每一步都理解透彻。在后面的章节针对其中细节进行讨论时,可以随时回来复习。话虽如此,但我们还是得简单讲解下流程:

  1. FuncAAsync 是一个异步方法,其方法体包含至少一个 await 语句。当编译器识别到异步方法后,会在异步方法内生成一个状态机,即这里的 GeneratedStateMachine 。
  2. 状态机生成后,与之同时 MyBuilder 也被创建出来。前面说过 builder type 可以看作是状态机开放给我们的接口,我们能通过它实现状态机的部分逻辑,因此它其实也是状态机的一部分。MyBuilder 被创建后,需要调用 Start 方法来启动状态机。
  3. MyBuilder 的 Start 方法有一个接受状态机实例的参数,并且状态机内部实现了 MoveNext 方法,用于推动状态机的运行。我们就在 Start 方法中调用 MoveNext 来启动状态机。到这一步,我们的代码将会执行到第一个 await 语句之前。
  4. 状态机遇到了 await 语句,被等待的是 FuncBAsync 异步方法,该方法的返回值类型是 MyTask 。
  5. 状态机访问 builder 中,变量名为 Task 的成员,获取到 MyTask 类型。之后调用 MyTask.GetAwaiter 方法,并访问结果中的 IsCompleted 成员来判断等待任务是否完成。若已经完成,状态机将直接访问 MyTaskAwaiter.GetResult 来获取异步方法的执行结果(未在图中画出),FuncAAsync 将以同步的方式执行(假设只有一个 await 语句)。
  6. 如果 IsCompleted 的返回结果为未完成,状态机将执行 builder.AwaitUnsafeOnCompleted 方法,用于注册等待任务结束后的“回调方法(其实就是状态机的 MoveNext 方法)”,即 await 语句之后的代码。
  7. 目前为止,我们还未讨论过自定义异步机制中需要我们实现的具体的逻辑——只有 builder 中方法的定义肯定是不够的,我们要如何实现方法的功能?由此可见我们还需要一个打工仔来帮我们干活,所以我们引入了 MyMoveNextRunner 。我们会将第六步中的“回调方法”传到 MyMoveNextRunner 中进行包装。
  8. “回调方法”包装完成后,我们通过调用 UnsafeOnCompleted(action) 方法,将它传给 MyTaskAwaiter 。这样,MyTaskAwaiter 就拿到了 await 语句后面的代码,当它结束等待后,知道接下来该执行什么。
  9. 当 MyTaskAwaiter 结束等待,它将调用状态机的 MoveNext 方法来推动状态机运行。此时状态机将继续执行 await 后面的代码,直到遇到第二个 await (如果有),或者将 FuncAAsync 执行完。

第六到第九步是最重要的,而其中第六到第八步执行得十分紧密,这也是我们后续工作的重点。读者可能会有疑问,为何要在 MyMoveNextRunner 中包装回调?这是为了方便后续拓展,而将 MyMoveNextRunner 抽象成了接口,这里我们先不展开,后续会慢慢讲。

这一章我们简单介绍了为什么要定制Unity异步机制,并总结了异步状态机的工作流程。下一章我们将着手编写代码,从零开始实现异步库。

扩展阅读

ValueTask

ValueTask 于 .NET Core 2.0 推出,它本意是为了解决当异步方法以同步方式完成时,多余的内存开销问题。它的核心思想除了将原本的 Task 从“引用类型”更改为“值类型”之外,还做了一些工作让 ValueTask 能够重复使用,当然这也导致在使用 ValueTask 时会有一些限制,比如不能多次 await 同一个 ValueTask 对象,因为第一次等待之后,它可能就被回收到对象池中了;再比如在多个线程中等待同一个 ValueTask ;以及在 ValueTask 完成任务之前使用 .GetAwaiter().GetResult() 来使异步方法以同步方式运行,这些都是不支持的。

当然在日常开发中,我们基本上只会 await 一个异步方法,然后使用它返回的结果,并且该异步等待没有额外的性能开销,这恰好是 ValueTask 的优势所在。后续的异步库开发,我们也将参考 ValueTask 的实现原理(实际上 UniTask 就是这样做的)。

更多关于 ValueTask 的小知识,比如回收使用的原理、其接口 IValueTaskSource 的抽象,小伙伴们可以参考以下两篇博客:

Understanding the Whys, Whats, and Whens of ValueTask

Prefer ValueTask to Task, always; and don’t await twice

ICriticalNotifyCompletion 与 INotifyCompletion

看过 UniTask 源码,或者自己动手实现过 task-like 类型的小伙伴可能会遇到这两个接口,并对它们的行为产生疑惑——它们的作用貌似没有什么区别,那是出于什么考虑要分成两个接口?

我们可以简单记住:当实现自己的 awaiter 时,尽量实现 ICriticalNotifyCompletion 接口,状态机将优先调用该接口的方法。而 INotifyCompletion 接口,则更像是一个“历史遗留问题”,但同时实现两个接口也是没有问题的。至于什么时候只实现 INotifyCompletion ,论坛中有关于此的讨论:

What is ICriticalNotifyCompletion for?

上一篇
下一篇