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

STask GitHub仓库链接

背景

What?

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

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

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

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

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

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

可以将 Task 类型看作对多线程的包装,将异步方法看作对 Task 类型的扩展。异步方法的 asyncawait 关键字本质上是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。

原理

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

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

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

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

Task Type

  1. 自定义 task 可以是 classstruct 类型。后续实践我们将采用 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 方法之外,IsCompletedGetResult 这俩成员是不可省略的,在编译期间编译器会检查,若出现缺省的情况会导致编译不通过哦。我们会在后面讨论其工作细节。

Builder Type

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

  1. builder 可以为 classstruct 类型。在后面的实践中,我们的 builderstruct 类型。
  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. MyBuilderStart 方法有一个接受状态机实例的参数,并且状态机内部实现了 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?

上一篇
下一篇