这段时间学习Unity,顺便系统性地了解了下C#和Unity异步编程的各种机制和实现细节。本文是这些学习资料和个人理解的汇总。会先介绍下C#迭代器,Task,async/await,同步上下文等机制。然后聊聊其在Unity上的一些变体和应用。
C# yield
yield return
将一个函数分为多个部分,让其具有分段多次返回的能力:
1 | public static IEnumerable<int> TestYield(int a) |
迭代器本身是基于迭代器的语法糖,编译器会为TestYield函数生成一个状态机类,将函数执行体通过yield分为几个部分,内部通过一个state字段(通常是个整数)来标识当前迭代到哪一步了,并实现IEnumerator,IEnumerable等接口。因此我们可以将TestYield作为一个迭代器直接用于while和for循环。介绍关于yield语法糖实现机制的文章很多,这里就不赘述了。
这类让函数返回多次的能力,容易让人联想倒lua coroutine,但它们是有区别的,C#本质上没有协程,如果我们将C# yield对应lua yield,C# MoveNext 对应 lua resume,可以做个简单的对比:
- lua的协程yield/consume之间具备双向动态交换信息的能力,C#只能单向静态传递(yield => MoveNext)
- lua的协程本质是运行时捕获和保存堆栈上下文,而C#只是编译期的语法糖(转换为状态机类,以适配迭代器接口)
Unity Coroutine
C#没有协程,我们经常听到或看到C#协程的概念,主要来自于Unity,它对yield做了一些改造:
- 基于yield返回的对象,只能是YieldInstruction的子类(它最重要的方法是bool IsDone(),用于判断当前任务是否已经完成)
- 默认实现了部分预定义的YieldInstruction,如WaitForSeconds,null,WaitForEndOfFrame等,以实现常用的协程控制(告诉Unity协程的唤醒时机)
- Unity Runtime会根据返回的YieldInstruction对象类型,在合适(IsDone()==true)的时候唤醒协程(无需显示MoveNext)
- 支持协程嵌套
- 简单的协程生命周期管理(提供StopCoroutine接口),并将协程的生命周期与GmaeObject绑定
如此,对于Unity开发者而言,使用yield能达成协程类似的效果,yield虽然不能像await一样传递返回值,但由于本质是单线程,yield的处理结果可以放到类成员或GameObject上,因此灵活性也足够。本质上,Unity Coroutine是个桢驱动的迭代器。关于Unity协程的更多细节,可以参考Unity Coroutines: How Do They Work?,深入剖析Unity协程的实现原理
C# Task
C#中的Task本质上类似JS中的Promise,表示一个异步任务,通常运行在其他线程而非创建Task的当前线程中。Task在启动(Task.Start/Task.Run/TaskFactory.StartNew)和ContinueWith的时候,可以选择指定其对应的TaskScheduler(对于ContinueWith而言,指定的是执行异步回调的任务调度器),默认的TaskScheduler只会将任务放到线程池中去执行。
1 | static void Main(string[] args) { |
以上代码展示了Task的几个特性:
- 任务内部有个简单的状态机,其他线程可通过
Task.Status
获取任务当前状态 Task.ContinueWith
返回值是一个新的Task,可以像JS promise.then
一样,以可读性较好的方式(相比回调地狱)书写异步调用链task.ContinueWith
中的回调可以取到到task的返回值,并且可以为其附加额外的参数task.Wait
可以让当前线程同步阻塞等待该任务完成,除此之外,还可以通过Task.WaitAny
和Task.WaitAll
来等待一个任务数组- 在任务执行完成后,通过
task.Result
可以取得异步任务的返回值,注意,如果此时任务未完成,将会同步阻塞等待任务完成 - 如果没有指定TaskScheduler,默认的任务调度器只是在线程池中随机选一个线程来执行异步任务和对应回调
有时候我们在线程A中将某些耗时操作,如网络IO,磁盘IO等封装为Task放到线程B异步执行之后,希望Task的回调在A线程执行(最典型的如UI线程,因为通常UI框架的API都不是线程安全的),以实现A->B->A的线程上下文切换效果。要实现这种效果,我们需要为Task显式指定TaskScheduler,TaskScheduler本质只是接口,它的派生类主要有两个:
- thread pool task scheduler: 基于线程池的任务调度器,即任务(及其continuewith产生的新任务)会被分配到线程池中的某个工作线程,这也是默认的调度器,通过
TaskScheduler.Default
获取默认线程池调度器 - synchronization context task scheduler: 同步上下文调度器,即任务会在指定的同步上下文上执行,比如在GUI框架中,通常会将控件操作全部放到GUI线程中执行。通过
TaskScheduler.FromCurrentSynchronizationContext
获取与当前同步上下文绑定的任务调度器
那么什么是同步上下文?SynchronizationContext代表代码的执行环境,提供在各种同步模型中传播同步上下文的功能,为各个框架的线程交互提供统一的抽象。它最重要的是以下两个方法。
1 | // 获取当前线程的同步上下文 |
SynchronizationContext提供了默认的实现,对Post而言,它只会通过QueueUserWorkItem将任务丢给ThreadPool,对于Send而言,它会立即在当前线程上同步执行委托。
各个框架可以重载SynchronizationContext实现自己的同步上下文行为,如Windows Froms实现了WindowsFormsSynchronizationContext
,它的Post会通过Control.BeginInvoke
实现,而WPF的DispatcherSynchronizationContext
则通过框架的Dispatcher.BeginInvoke
实现,它们都实现了将委托异步投递给UI线程执行。正因为不同的平台,不同的线程,有不同的消息泵和交互方式,因此才需要SynchronizationContext来封装抽象这些差异性,以增强代码的可移植性。
每个线程都有自己的SynchronizationContext(通过SynchronizationContext.Current
获取,默认为null),但SynchronizationContext与线程不一定是一一对应的,比如默认的SynchronizationContext.Post
是通过线程池来执行任务。SynchronizationContext本质上想要封装的是一个执行环境以及与该环境进行任务交互的方式。
对Task,TaskScheduler,SynchronizationContext有一定了解后,我们将这些概念结合起来:
1 | static void Main(string[] args) { |
上面代码中,使用TaskScheduler.FromCurrentSynchronizationContext()
来指定task.ContinueWith
任务的调度器(注意,我们并没有为task.Start
指定调度器,因为我们希望task本身使用默认的线程池调度器,当执行完成之后,再回到主线程执行ContinueWith任务),输出结果并不如我们预期,task.ContinueWith
中的回调委托仍然在线程池中执行,而不是在主线程。
这个结果其实很容易解释,task.ContinueWith(delegate, TaskScheduler.FromCurrentSynchronizationContext())
表示: 当task执行完成后,通过SynchronizationContext.Post(delegate, task)
将任务异步投递到指定的同步上下文(在上例中,即为主线程创建的上下文)。但是一来我们创建的是默认的SynchronizationContext,它的Post本身就是投递到线程池的,二来我们并没有在主线程中集成消息泵(message pump)。
类比Actor模型,我们要实现 Actor A 向 Actor B 通信,我们需要:
- 定义一个消息通道: channel/mailbox
- 集成channel/mailbox到B消息泵
- 将channel/mailbox暴露给A
因此,上例中,我们即没有定义消息的传输方式,也没有定义消息的处理方式。SynchronizationContext本质只是提供了一层同步上下文切换交互抽象,传输方式,消息泵,甚至线程模型都需要我们自己实现。这里就不再展示SynchronizationContext的扩展细节,更多关于SynchronizationContext的文档:
C# async/await
async/await是C# .NET4.5推出的更高级的异步编程模型:
1 | public static async void AsyncTask() |
可以看到async/await进一步简化了异步编程的书写方式,达到更接近同步编程的可读性和易用性(这一点后面会再探讨下)。
实现原理
在进一步了解它的用法之前,我们先大概了解下它的实现机制(可以看看这篇文章提到了不少实现细节),async/await本质也是编译器的语法糖,编译器做了以下事情:
- 为所有带async关键字的函数,生成一个状态机类,它满足IAsyncStateMachine接口,await关键字本质生成了状态机类中的一个状态,状态机会根据内部的state字段(通常-1表示开始,-2表示结束,其他状态依次为0,1,2…),一步步执行异步委托。整个状态机由
IAsyncStateMachine.MoveNext
方法驱动,类似迭代器 - 代码中的
await xxx
,xxx返回的对象都需要实现GetAwaiter方法,该方法返回一个Awaiter对象,编译器不关心这个对象Awaiter对象类型,它只关心这个Awaiter对象需要满足三个条件: a. 实现INotifyCompletion,b. 实现IsCompleted属性,c. 实现GetResult方法,如此编译器就能知道如何与该异步操作进行交互,比如最常见的Task对象,就实现了GetAwaiter方法返回一个TaskAwaiter对象,但除了TaskAwaiter,任何满足以上三个条件的对象均可被await - 有了stateMachine和TaskAwaiter之后,还需要一个工具类将它们组合起来,以驱动状态机的推进,这个类就是
AsyncTaskMethodBuilder/AsyncTaskMethodBuilder<TResult>
,是Runtime预定义好的,每个async方法,都会创建一个Builder对象,然后通过AsyncTaskMethodBuilder.Start方法绑定对应的IAsyncStateMachine,并进行状态首次MoveNext驱动,MoveNext执行到await处(此时实际上await已经被编译器去掉了,只有TaskAwaiter),会调用TaskAwaiter.IsCompleted
判断任务是否已经立即完成(如Task.FromResult(2)
),如果已完成,则将结果设置到builder(此时仍然在当前线程上下文),并之后跳转到之后的代码(直接goto,无需MoveNext),否则,更新state状态,通过AsyncTaskMethodBuilder.AwaitUnsafeOnCompleted挂接异步回调并返回(此时当前线程已经让出控制权),当taskAwaiter完成后,buildier会再次调用stateMachine.MoveNext
驱动状态机(此时可能已经不在当前线程,state状态也不一样了,可通过TaskAwaiter.GetResult拿到异步结果),如此完成状态机的正常驱动。 - 除了驱动状态机外,AsyncTaskMethodBuilder的另一个作用是将整个async函数,封装为一个新的Task(wrapper task),该Task可通过
AsyncTaskMethodBuilder.Task
属性获取。当stateMachine通过MoveNext走完每个状态后,会将最终结果,通过builder.SetResult写入到builder中的Task,如果中途出现异常,则通过builder.SetExpection保存,如此发起方可通过try {await xxx;} catch (e Exception){...}
捕获异常,最终整个编译器改写后的async函数,返回的实际上就是这个builder.Task
。
基础用法
除了直接跟Task外,.NET
和Windows运行时也封装了部分关于网络IO,文件,图像等,这些方法通常都以Async结尾,可直接用于await。以下代码说明了跟在await后面的常见的几种函数,以便进一步理解其中的差异和原理。
1 | // 因为没有async标注,所以编译器不会为该函数生成状态机,但由于该函数返回的是Task,因此可以直接用于await |
线程切换
理解async/await基本原理后,不难发现,async/await本质上是不创建线程的,它只是一套状态机封装,以及通过回调驱动状态机的异步编程模型。await默认会捕获当前的执行上下文ExecuteContext,但是并不会捕获当前的同步上下文SynchronizationContext(关于ExcuteContext和SynchronizationContext的区别联系参考executioncontext-vs-synchronizationcontext on MSDN,强烈建议阅读),同步上下文的捕获是由TaskAwaiter实现(见TaskAwaiter.OnCompleted),它会先获取SynchronizationContext.Current
,如果没有或者是默认的,会再尝试获取Task对应的TaskScheduler上的SynchronizationContext。也就是说对TaskAwaiter而言,设置默认的SynchronizationContext和没有设置效果是一样的(为了少一次QueueWorkItem,对应源码在这里,我们可以结合前面的AsyncTask,以及下面的进一步测试来验证:
1 | class MySynchronizationContext : SynchronizationContext { |
这说明了如果当前线程没有或者设置的默认的SynchronizationContex,那么await之后的回调委托实际上是在await的Task所在的线程上执行的(这一点和ContinueWith的默认行为不大一样,后者总是会通过QueueWorkItem)跑在一个新的线程中。
如果设置了非默认的SynchronizationContex,那么回调委托将通过SynchronizationContex.Post
方法封送(由于SynchronizationContex本质也只是接口,我们这里并不能草率地说,会回到Caller线程)。如对于WPF这类UI框架而言,它实现的DispatcherSynchronizationContext
最终通过Dispatcher.BeginInvoke
将委托封送到UI线程。而如果你是在UI线程发起await,其后又在UI线程上使用task.Result
同步等待执行结果,就可能解锁前面F3Async中提到的UI线程卡死场景,这也是新手最常犯的问题。你可以通过task.ConfigureAwait(bool continueOnCapturedContext)
指定false来关闭指定Task捕获SynchronizationContex的能力,如此委托回调的执行线程就和没有SynchronizationContex类似了。
总结下,async/await本身不创建线程,aaa; await bbb; ccc;
这三行代码,可能涉及到一个线程(比如没有await,或任务立即完成,甚至await线程自己的异步操作),两个线程(比如没有自定义SynchronizationContex,或有自己实现消息泵的的SynchronizationContex),三个线程(有其他线程实现消息泵的自定义SynchronizationContex)。但具体涉及几个线程,GetAwaiter(通常返回的是TaskAwaiter,但是你也可以自定义),SynchronizationContex等外部代码和环境决定的。
常见问题
await与yield的区别
yield和await都是语法糖,最后都会被生成一个状态机,每行yield/await都对应其中一个状态。
- 状态驱动: yield状态机是手动单步驱动的(通过foreach或显示调用MoveNext),而await状态机是自动驱动的(从调用async函数起,状态机就通过异步回调不断调用MoveNext,直至走完每个状态)
- 线程切换: yield不涉及线程上下文的切换,而await通常涉及(前面说了,不是因为它会创建线程,而是依赖具体的异步操作,以及同步上下文)
- 本质用途: yield用于快速构造迭代器,await用于简化异步编程模型
async/await是Task+状态机的语法糖
从实现机制上来说,这句话没有问题,但要更细致地看,一方面,async函数在经过编译器处理后,最终返回给调用方的,是builder中的Task对象(这也是为何async方法的返回值只能是void
, Task
, Task<TResult>
)。而另一方面,await本身不关注Task,它支持所有提供异步相关接口的对象(GetAwaiter),这样的好处在于除了Task,它还可以集成更多来自框架(比如.NET
已经提供的各种Async API),甚至自定义的异步对象,已有的异步操作也可以通过适配GetAwaiter移植到新的async/await异步编程模型。
出现await的地方,当前线程就会返回
这个前面也解释过了,出现await的地方未必会涉及线程上下文切换,比如前面的F2Async,对它的整个调用都是同步的。异步编程和线程无关,线程切换取决于异步操作的实现细节,而await本身只关注与异步操作交互的接口。
Unity async/await
Unity也引入了C# async/await机制,以弥补自己多线程编程方面的短板:
- Unity本身也是UI框架,因此它实现了自己的同步上下文UnitySynchronizationContext以及主线程的消息泵,如此await的异步委托会默认会回到Unity主线程执行(可通过task.ConfigureAwait配置)
- Unity官方提供了部分Async API,如LoadAssetAsync
- Unity社区提供了针对大部分常见YieldInstruction(如WaitForSeconds),以及其他常用库(如UnityWebRequest)的GetAwaiter适配(如Unity3dAsyncAwaitUtil)
Unity3dAsyncAwaitUtil这个库及其相关Blog: Async-Await instead of coroutines in Unity 2017,非常值得了解一下,以适配大家最熟悉的YieldInstruction WaitForSeconds(3)为例,来大概了解下如何通过将它适配为可以直接await WaitForSeconds(3);
1 | // GetAwaiter |
如此我们就可以直接使用await WaitForSeconds(3);
了,深入细节可以发现,不管是WaitForSeconds本身,还是之后的回调委托,其实都是在Unity主线程中执行的,并且结合RunOnUnityScheduler的优化,整个过程既不会创建线程,也不会产生额外的消息投递,只是在yield上加了一层壳子而已。上例也再次说明了,async/await本身只是异步编程模型,具体的线程切换情况,Awaiter,SynchronizationContext,ConfigureAwait等综合控制。
这个工具库还有一些有意思的小特性,比如Task到IEnumerator的转(原理就是轮询Task完成状态),通过await new WaitForBackgroundThread();
切换到后台线程(原理其实就是对task.ConfigureAwait(false)
的封装),这些在理解整个async/await,Unity协程,SynchronizationContext等内容后,都应该不难理解了。
另外,这里有篇关于Unity中async/await与coroutine的性能对比,可以看看。
一点体会
首先我是个C#和Unity的门外汉,只是谈谈自己的体会,异步编程尤其是并发编程从来都不是一件简单的事,无论它看起来多么”简洁优雅”。C#从Thread/ThreadPool,到Task/TaskFactory/TaskScheduler,再到async/await,异步编程模型一直在演进,看起来越来越简单,可读性越来越”高”,但代价是编译器和运行时做了更多的工作(这些工作是作为开发者必须要了解的),同时理解底层也越来越难:
- async/await这一套,如C语言的goto,都打破了函数封装的约束(所谓无栈编程?),为深入理解代码行为带来了一定负担
- 同样一段代码,在不同的线程上运行,可能获得完全不一样的效果(SynchronizationContext和ExecuteContext不同)
当然,这不是语言的错,语言框架本身只提供选择,只是作为使用者的我们,在并发越来越”容易”的同时,保持对底层的理解,才能充分发挥工具的作用。就我目前的理解而言,C# async/await可能不是很适合用于复杂上下文的后端开发(比如游戏服务器),因为这类场景会非常重视执行上下文和并发安全,对于普通开发者而言,直接上手并理解async/await还是有一定门槛的。