这段时间学习Unity,顺便系统性地了解了下C#和Unity异步编程的各种机制和实现细节。本文是这些学习资料和个人理解的汇总。会先介绍下C# yield,Task,async/await,同步上下文等机制。然后聊聊其在Unity上的一些变体和应用。
C# yield
yield
是C#提供的快速创建枚举器的机制:
1 | public static IEnumerable<int> TestYield(int a) |
实现上来说,C#编译器会为TestYield函数生成一个状态机类,将函数执行体通过yield分为几个部分,内部通过一个state字段(通常是个整数)来标识当前迭代到哪一步了,并实现了IEnumerable、IEnumerator枚举器接口。因此可以将TestYield返回值作为一个可枚举对象。介绍关于yield语法糖实现机制的文章很多,这里就不赘述了。
C# 枚举器和JS Generator机制上非常类似,不过只具备单向传值的能力(yield->MoveNext)。我在JS异步编程和Lua协程中有介绍关于协程和生成器的区别,在我的理解中,C#枚举器和JS生成器一样,都不能算作协程。
Unity Coroutine
C#没有协程,而Unity C#中则经常看到协程的概念(Unity Coroutine),本质上来说,Unity Coroutine是和JS Generator类似的通过生成器/枚举器实现异步的编程模型。Unity基于C# yield进行了进一步完善:
- Unity协程通过Unity Engine提供的
StartCoroutine(myEnumerableFunc)
启动,Unity Engine会驱动枚举器的迭代,无需开发者关心 - Unity协程基于yield返回的对象,只能是YieldInstruction的子类(它最重要的方法是bool IsDone(),用于判断异步操作是否已经完成),如此Unity Engine会在YieldInstruction完成后,通过MoveNext迭代枚举器
- Unity Engine预实现了部分YieldInstruction,如WaitForSeconds,WaitForEndOfFrame等,以实现常用的协程控制
- Unity Engine完善了协程(枚举器)生命周期管理(Start/Stop)和嵌套机制(如一个协程yield另一个协程),并将协程的生命周期与GameObject绑定
关于Unity Coroutine的更深入实现原理推荐这篇博客。如此,对于Unity开发者而言,使用yield就能完成简单的异步控制。当然,还达不到JS Generator异步那样的灵活度(如C# yield不能像JS yield一样双向传值)。我们可以从JS 异步编程中提到的Generator异步编程的四要素,来对比看看Unity Coroutine是如何工作的:
- Generator: C#的yield相当于JS Generator的阉割版,支持执行权转移,单向传值
- Thunk: Thunk的本质目的是让Iterator能以一种标准化的方式挂接回调(如此才能回到yield语句),而Unity YieldInstruction本身就是一种标准,Unity会在YieldInstruction完成(IsDone()==true)后,调用对应协程的的MoveNext回到yield语句,这也就相当于完成了Thunk的职责
- AsyncOp: Unity Engine和它的标准库提供了大量适配了YieldInstruction的异步操作,包括帧控制、定时、网络IO等,并且支持开发者扩展
- Iterator: Unity Engine统一管理所有通过StartCoroutine启动的协程,并基于帧驱动检查它们的状态,在YieldInstruction异步操作完成后继续驱动协程(MoveNext),直至协程生命周期结束。
由于C# yield是单向传值,Unity协程自然也就不支持yield语句返回值。如此看来,Unity C#确实具备部分的异步编程能力,不过如前面所说,基于个人对狭义的协程概念的理解,我认为程JS、C#、Unity支持协程是不合适的。类似的还有Golang的抢占式轻量级线程goroutine也被翻译为协程。
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(只有一个OnCompleted(Action continuation)
方法,用以异步框架挂载回调),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(最终调到Awaiter.OnCompleted
)挂接(对TaskAwaiter.OnCompleted
而言,是挂接到Continuation上)异步回调(此回调包含整个状态机的后续驱动方式,通过GetCompletionAction生成)并返回(此时当前函数堆栈已结束),当taskAwaiter完成(不同的Awaiter完成方式也不同,对Task而言,即Task执行完成)后,buildier会通过GetCompletionAction生成的回调再次调用到stateMachine.MoveNext
驱动状态机(此时可能已经不在当前线程,state状态也不一样了,可通过TaskAwaiter.GetResult拿到异步结果),如此完成状态机的正常驱动。整个stateMachine只需要MoveNext一次,即可完全跑起来。 - 除了驱动状态机外,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用于快速构造枚举器,而await用于简化异步编程模型,两者都会生成状态机,但前对外表现为可枚举类,用于手动迭代,后者主要用于AsyncTaskMethodBuilder自动迭代(从调用async函数起,Builder就通过异步回调不断调用MoveNext,直至走完每个await状态)
- 线程切换: yield不涉及线程上下文的切换,而await通常涉及(前面说了,不是因为它会创建线程,而是依赖具体的异步操作,以及同步上下文)
C# async/await vs JS Generator异步
既然async/await也是异步编程模型,同样的,我们也将C# async/await用Generator异步编程四要素来分析下:
- Generator: C# yield是由编译器生成状态机类并实现IEnumerable,类似的,C# async/await也是编译器生成的可迭代状态机IAsyncStateMachine,不过它只有MoveNext()方法,看起来甚至不能单向传值。不过事实上,它的双向传值机制都封装在状态机类内部了
- Thunk: await也有Awaitable标准,它需要实现INotifyCompletion的
OnCompleted(Action continuation)
方法,这也就提供了统一的挂载回调标准。C# Task和JS Promise一样,都实现了异步执行和回调挂接分离 - AsyncOp: C#的Task原生适配了Awaitable,并且Awaitable也非常易于开发者扩展(后面讲UniTask还会详述)
- Iterator: 整个状态机的驱动,由前面提到的AsyncTaskMethodBuilder来完成,它负责将await之后的执行路径通过OnCompleted挂载到异步操作上
强行将C# async/await映射到四要素可能不是很合适,因为C# async/await的Generator和Iterator是一体生成的,严格上不涉及所谓的执行权转移。C# async/await 是在async function外部直接生成一个状态机Wrapper类,对函数执行入口、返回值等进行了”魔改”。而JS Generator异步,是通过自定义的run函数或第三方co库驱动迭代。因此C#的async/await可以进行任意函数层级嵌套,而无需像JS一样每一个Generator都要单独驱动,另外C# async/await 可能涉及到线程切换,而JS则通常都是在单线程。
async/await是Task+状态机的语法糖
这个要从两方面看,一方面,async函数在经过编译器处理后,最终返回给调用方的,是builder中的Task对象(这也是为何async方法的返回值只能是void
, Task
, Task<TResult>
)。而另一方面,await本身不关注Task,它支持所有提供异步相关接口的对象(GetAwaiter),这样的好处在于除了Task,它还可以集成更多来自框架(比如.NET
已经提供的各种Async API),甚至自定义的异步对象,已有的异步操作也可以通过适配GetAwaiter移植到新的async/await异步编程模型。
出现await的地方,当前线程就会返回,或发生线程上下文切换
这个前面也解释过了,出现await的地方未必会涉及线程上下文切换,比如前面的await F2Async()
,对它的整个调用都是同步的。异步编程和线程无关,线程切换取决于异步操作的实现细节,而await本身只关注与异步操作交互的接口。
Unity async/await
Unity也引入了C# async/await机制,并对其进行了适配:
- Unity本身也是UI框架,因此它实现了自己的同步上下文UnitySynchronizationContext以及主线程的消息泵,如此await的异步委托会默认会回到Unity主线程执行(可通过task.ConfigureAwait配置)
- Unity社区提供了针对大部分常见YieldInstruction(如WaitForSeconds),以及其他常用库(如UnityWebRequest、ResourceRequest)的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的性能对比,可以看看。
Unity UniTask
通过前面的了解,可以发现,在Unity中,Coroutine可以用来实现单线程内的异步操作,Task可用来实现多线程的并发、异步和协同操作。而async/await是一种比Coroutine和Task更抽象易用的异步编程模型,C#完成了Task和async/await的适配,Unity3dAsyncAwaitUtil完成了Coroutine和async/await的适配,但对Unity开发者而言,还是不够方便,开发者面临过多方案选择: yield Coroutine or await Coroutine or await Task,因此,Unity社区又有大神出手,出了一套新方案: UniTask,它的目的是整合Coroutine的轻量、Task的并发、async/await的易用于一体,为开发者提供高性能、可并发、易使用的接口。
它的主要特性包括:
- 基于值类型的 UniTask
和自定义的 AsyncMethodBuilder 来实现0GC - 使所有 Unity 的 AsyncOperations 和 Coroutines 可等待 (类似Unity3dAsyncAwaitUtil的适配)
- 基于 PlayerLoop 的任务(UniTask.Yield, UniTask.Delay, UniTask.DelayFrame…)可以替代所有协程操作
- 对 MonoBehaviour 消息事件和 uGUI 事件进行 可等待/异步枚举 拓展
- 与C#原生 Task/ValueTask/IValueTaskSource 行为高度兼容
- …
更详细UniTask功能介绍,推荐这篇博客,UniTask一方面保留和适配 Unity Coroutine的轻量单线程异步模型,另一方面,将Coroutine的惯用场景(如WaitForSeconds)全部移植到性能更优的UniTask上实现了一遍(受益于async/await异步模型的抽象性),并且保持UniTask与Task语义兼容,保留大部分的Task并发和交互模型能力。
从实现上来说,以UniTask.Delay
为例,它的功能类似于WaitForSeconds,它会返回一个UniTask
对象,UniTask对象本身只是一层可await的壳子,真正起作用的对象是其持有的DelayPromise
对象(IUniTaskSource source
字段),DelayPromise的有两个核心方法:
OnCompleted
: AsyncUniTaskMethodBuilder挂接异步回调会通过UniTask Awaiter调到这里,它只是简单转调用core.OnCompleted
,UniTaskCompletionSourceCore core
是UniTask Promise都有的字段,做一些核心代码复用MoveNext() bool
: 它会检查时间是否到期,未到期返回true,到期则通过core.TrySetResult(null)
设置完成状态,并返回true。注意,core.TrySetResult
中,会调用并执行continuation
异步挂接和回调机制有了,谁来驱动IUniTaskSource.MoveNext
,注意,这个MoveNext和C#中的IAsyncStateMachine.MoveNext
是不同的:
IAsyncStateMachine.MoveNext
: 表示开始驱动整个状态机,Awaitable在异步任务完成时,会通过continuation(由AsyncTaskMethodBuilder生成并通过OnCompleted注入)调用到stateMachine.MoveNext
跳转到下一个await语句。IUniTaskSource.MoveNext
: 仅用于确定状态机的该异步任务是否已经完成,将continuation的调用放到了外部驱动器。比如对UniTask.Delay
而言,它是不切换线程的,如Unity Coroutine一样,通常由额外的Ticker/Event/Poll这类机制,来检查状态变更(如Delay到期),设置Awaiter Result,并回调continuation(通过core.TrySetResult(null)
)。
驱动IUniTaskSource.MoveNext
的工作是由PlayerLoopRunner来完成的,DelayPromise创建之后,就会被立即添加到PlayerLoop Action中,PlayerLoopRunner穿插在Unity的各个执行Timing,驱动/检查所有IPLayerLoopItem任务的MoveNext。
对于UniTask.Yield(PlayerLoopTiming.FixedUpdate);
这类场景,UniTask的实现更为简单,直接在YieldAwaitable.OnCompleted(continuation)
挂接异步回调时,将continuation挂在PlayerLoop上即可,PlayerLoop会在对应timing(如FixedUpdate)触发时,调用continuation。
另外,UniTask.Run/RunOnThreadPool
不使用默认的UnitySynchronizationContext和ExecutionContext,而是自己做同步上下文切换,这一点可能会容易和原生Task行为混淆,虽然它也提供UniTask.SwitchToMainThread
、UniTask.SwitchToThreadPool
、UniTask.ReturnToCurrentSynchronizationContext
等API进行精确的同步上下文控制。
UniTask将Unity单线程异步编程诸多实践与async/await异步编程模型有机整合,并对Unity Coroutine与C# Task的诸多痛点进行优化和升级,看起来确实有一统Unity异步编程模型的潜力,应该离整合进Unity官方包也不远了。
一点体会
首先我是个C#和Unity的门外汉,只是谈谈自己的体会,异步编程尤其是并发编程从来都不是一件简单的事,无论它看起来多么”简洁优雅”。学习各语言/框架的异步演进史,是一件非常有意思的事情:
- C#: Thread(关注实现) -> Task(关注任务) -> async/await(关注可读性和扩展性)
- Unity: Coroutine(Engine做大量支持,算半个异步编程模型) -> Unity3DAsyncAwaitUtil(将Coroutine适配到async/await) -> 到UniTask(整合Coroutine和Task,兼并性能更高、可读性更高、更适合Unity)
- JS: Callback(最原始) -> Generator+Thunk+AsyncOp+Iterator异步(初步四件套) -> Promise(统一规范异步操作) -> Generator+Promise+co(标准三件套) -> async/await+Promise(终极两件套)
异步编程模型一直在演进,看起来写越来越”简单”,可读性越来越”高”,代价是编译器和运行时做了更多的工作,并且这些工作和原理是作为开发者必须要了解的,以C# async/await为例,如果不能充分了解底层原理,就容易引发:
- 异步回调闭包引用可变上下文的问题
- async “无栈编程”本身带来的理解负担和调试难度
- 代码的线程上下文难以分析,容易引发并发安全访问的问题
- 同一段代码在不同的线程执行可能具有完全不同的行为(SynchronizationContext和ExecuteContext不同)
等问题。语言和框架本身只提供选择,作为使用者的我们,在并发越来越”容易”的同时,保持对原理的理解,才能充分发挥工具的作用(享受上限高的好处,避免下限低的问题)。