背景
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
库,你能想到的,它已经帮你实现好了,暂时没想到的,它也提前做好了,并且它免费开源:
想要快速了解和上手的同学,可以阅读仓库中的文档,支持中文。
对于想要深入学习 UniTask
源码的小伙伴,打开项目后可能会被其中大量的接口、扩展方法、工具类给弄得一脸懵逼。但经过笔者的大致研究之后,发现 UniTask
库对于刚接触C#编程的小伙伴是非常具有研究价值的,因此这篇文章将以 UniTask
为例,从原理到代码分享笔者的 Unity 异步库实现的经验。
注意嗷:由于 UniTask
库中有许多非核心的附加代码,比如为了兼容不同版本的C#、Unity,以及编辑器相关的代码。这些代码对实现异步库的核心功能没有实质上的作用,后文的代码部分会将这部分给去除掉,方便大伙儿理解。因此为了跟 UniTask
区分,后面我们将异步库命名为 STask
,S 代表着 Simple。
原理
想要自己实现一套基于关键字 async
、 await
的高效异步机制,我们需要有自己的:
- Task Type,即可以被
await
等待的,类似Task
的类型。 - Builder Type,该类型包含的成员,可以看作是状态机对外提供的接口。
那么,我们应该怎么实现这俩类型呢?其实微软已经有文档帮我们总结了步骤:文档跳转 。
下面我们简单说明下该文档的重点。
Task Type
- 自定义
task
可以是class
或struct
类型。后续实践我们将采用struct
。 - 自定义
task
需要用特性System.Runtime.CompilerServices.AsyncMethodBuilderAttribute
标记,来与异步状态机建立关系。 - 自定义
task
可以有泛型参数,该参数用于异步方法的返回值类型;也可以没有泛型参数。 - 自定义
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
有哪些知识点:
builder
可以为class
或struct
类型。在后面的实践中,我们的builder
为struct
类型。builder
可以有至多一个泛型参数,该参数可用作异步方法的返回值类型,并且自身不能作为泛型类型。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对象
}
- 下面我们来讨论这些方法的工作流程。
状态机的工作流程
为了让我们更直观地理解异步方法的工作机制,软件工程之神降下它的荣光,一张流程图出现在眼前:
这张流程图可以让我们对异步机制有一个大概印象,目前我们不用将里面的每一步都理解透彻。在后面的章节针对其中细节进行讨论时,可以随时回来复习。话虽如此,但我们还是得简单讲解下流程:
FuncAAsync
是一个异步方法,其方法体包含至少一个await
语句。当编译器识别到异步方法后,会在异步方法内生成一个状态机,即这里的GeneratedStateMachine
。- 状态机生成后,与之同时
MyBuilder
也被创建出来。前面说过builder type
可以看作是状态机开放给我们的接口,我们能通过它实现状态机的部分逻辑,因此它其实也是状态机的一部分。MyBuilder
被创建后,需要调用Start
方法来启动状态机。 MyBuilder
的Start
方法有一个接受状态机实例的参数,并且状态机内部实现了MoveNext
方法,用于推动状态机的运行。我们就在Start
方法中调用MoveNext
来启动状态机。到这一步,我们的代码将会执行到第一个await
语句之前。- 状态机遇到了
await
语句,被等待的是FuncBAsync
异步方法,该方法的返回值类型是MyTask
。 - 状态机访问
builder
中,变量名为Task
的成员,获取到MyTask
类型。之后调用MyTask.GetAwaiter
方法,并访问结果中的IsCompleted
成员来判断等待任务是否完成。若已经完成,状态机将直接访问MyTaskAwaiter.GetResult
来获取异步方法的执行结果(未在图中画出),FuncAAsync
将以同步的方式执行(假设只有一个await
语句)。 - 如果
IsCompleted
的返回结果为未完成,状态机将执行builder.AwaitUnsafeOnCompleted
方法,用于注册等待任务结束后的“回调方法(其实就是状态机的MoveNext
方法)”,即await
语句之后的代码。 - 目前为止,我们还未讨论过自定义异步机制中需要我们实现的具体的逻辑——只有
builder
中方法的定义肯定是不够的,我们要如何实现方法的功能?由此可见我们还需要一个打工仔来帮我们干活,所以我们引入了MyMoveNextRunner
。我们会将第六步中的“回调方法”传到MyMoveNextRunner
中进行包装。 - “回调方法”包装完成后,我们通过调用
UnsafeOnCompleted(action)
方法,将它传给MyTaskAwaiter
。这样,MyTaskAwaiter
就拿到了await
语句后面的代码,当它结束等待后,知道接下来该执行什么。 - 当
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
,论坛中有关于此的讨论: