async/await 在 C# 语言中是若何工做的?(中)
存眷我们
(本文阅读时间:25分钟)
接《async/await 在 C# 语言中是若何工做的?(上) 》,今天我们陆续介绍 C# 迭代器和 async/await under the covers。
C# 迭代器
那个处理计划的伏笔现实上是在 Task 呈现的几年前,即 C# 2.0,其时它增加了对迭代器的撑持。
迭代器容许你编写一个办法,然后由编译器用来实现 IEnumerableT 和/或 IEnumeratorT。例如,假设我想创建一个产生斐波那契数列的列举数,我能够如许写:
while( true) {intsum = prev + next; yieldreturnsum; prev = next;next = sum;}}
然后我能够用 foreach 列举它:
我能够通过像 System.Linq.Enumerable 上的组合器将它与其他 IEnumerableT 停止组合:
或者我能够间接通过 IEnumeratorT 来手动列举它:
以上所有的成果是如许的输出:
实正有趣的是,为了实现上述目标,我们需要可以屡次进进和退出 Fib 办法。我们挪用 MoveNext,它进进办法,然后该办法施行,曲到它碰着 yield return,此时对 MoveNext 的挪用需要返回 true,随后对 Current 的拜候需要返回 yield value。然后我们再次挪用 MoveNext,我们需要可以在 Fib 中从我们前次停行的处所起头,而且连结前次挪用的所有形态稳定。迭代器现实上是由 C# 语言/编译器供给的协程,编译器将 Fib 迭代器扩展为一个成熟的形态机。
所有关于 Fib 的逻辑如今都在 MoveNext 办法中,但是做为跳转表的一部门,它容许实现分收到它前次分开的位置,那在列举器类型上生成的形态字段中被跟踪。而我写的部分变量,如 prev、next 和 sum,已经被 "提拔 "为列举器上的字段,如许它们就能够在挪用 MoveNext 时继续存在。
在我之前的例子中,我展现的最初一种列举形式涉及手动利用 IEnumeratorT。在阿谁层面上,我们手动挪用 MoveNext,决定何时是从头进进轮回法式的恰当时机。但是,假设不如许挪用它,而是让 MoveNext 的下一次挪用现实成为异步操做完成时施行的延续工做的一部门呢?假设我能够 yield 返回一些代表异步操做的工具,并让消耗代码将 continuation 毗连到该 yield 对象,然后在该 continuation 施行 MoveNext 时会怎么样?利用那种办法,我能够编写一个辅助办法:
IEnumeratorTask e = tasks.GetEnumerator;
展开全文
voidProcess {try{if(e.MoveNext) {e.Current.ContinueWith( t= Process); return; }}catch(Exception e) {tcs.SetException(e);return; }tcs.SetResult;};Process;
returntcs.Task;}
如今变得有趣了。我们得到了一个可迭代的使命列表。每次我们 MoveNext 到下一个 Task 并获得一个时,我们将该使命的 continuation 毗连起来;当那个 Task 完成时,它只会回过甚来挪用施行 MoveNext、获取下一个 Task 的不异逻辑,以此类推。那是成立在将 Task 做为任何异步操做的单一表达的思惟之上的,所以我们输进的列举表能够是一个任何异步操做的序列。如许的序列是从哪里来的呢?当然是通过迭代器。还记得我们之前的 CopyStreamToStream 例子吗?考虑一下那个:
staticIEnumerableTask Impl( Stream source, Stream destination) {varbuffer = newbyte[ 0x1000]; while( true) {Task int read = source.ReadAsync(buffer, 0, buffer.Length); yieldreturnread; intnumRead = read.Result; if(numRead = 0) {break; }
Task write = destination.WriteAsync(buffer, 0, numRead); yieldreturnwrite; write.Wait;}}}
我们正在挪用阿谁 IterateAsync 助手,而我们提赐与它的列举表是由一个处置所有掌握流的迭代器产生的。它挪用 Stream.ReadAsync 然后 yield 返回 Task;yield task 在挪用 MoveNext 之后会被传递给 IterateAsync,而 IterateAsync 会将一个 continuation 挂接到阿谁 task 上,当它完成时,它会回调 MoveNext 并在 yield 之后回到那个迭代器。此时,Impl 逻辑获得办法的成果,挪用 WriteAsync,并再次生成它生成的 Task。以此类推。
那就是 C# 和 .NET 中 async/await 的起头。在 C# 编译器中撑持迭代器和 async/await 的逻辑中,大约有95%摆布的逻辑是共享的。差别的语法,差别的类型,但素质上是不异的转换。
事实上,在 async/await 呈现之前,一些开发人员就以那种体例利用迭代器停止异步编程。在尝试性的 Axum 编程语言中也有类似的转换原型,那是 C# 撑持异步的关键灵感来源。Axum 供给了一个能够放在办法上的 async 关键字,就像 C# 中的 async 一样。
Task 还不普及,所以在异步办法中,Axum 编译器启发式地将同步办法挪用与 APM 对应的办法相婚配,例如,假设它看到你挪用 stream.Read,它会找到并操纵响应的 stream.BeginRead 和 stream.EndRead 办法,合成恰当的拜托传递给 Begin 办法,同时还为定义为可组合的 async 办法生成完全的 APM 实现。它以至还集成了 SynchronizationContext!固然 Axum 最末被弃捐,但它为 C# 中的 async/await 供给了一个很棒的原型。
async/await under the covers
如今我们晓得了我们是若何做到那一点的,让我们深进研究它现实上是若何工做的。做为参考,下面是我们的同步办法示例:
下面是 async/await 对应的办法:
签名从 void 酿成了 async Task,我们别离挪用了 ReadAsync 和 WriteAsync,而不是 Read 和 Write,那两个操做都带 await 前缀。编译器和核心库接收了其余部门,从底子上改动了代码现实施行的体例。让我们深进领会一下是若何做到的。
▌编译器转换
我们已经看到,和迭代器一样,编译器基于形态机重写了 async 办法。我们仍然有一个与开发人员写的签名不异的办法(public Task CopyStreamToStreamAsync(Stream source, Stream destination)),但该办法的主体完全差别:
重视,与开发人员所写的签名的独一区别是贫乏 async 关键字自己。Async 现实上不是办法签名的一部门;就像 unsafe 一样,当你把它放在办法签名中,你是在表达办法的实现细节,而不是做为契约的一部门现实公开出来的工具。利用 async/await 实现 task -return 办法是实现细节。
编译器已经生成了一个名为 CopyStreamToStreamAsyncd__0 的构造体,而且它在仓库上对该构造体的实例停止了零初始化。重要的是,假设异步办法同步完成,该形态机将永久不会分开仓库。那意味着没有与形态机相关的分配,除非该办法需要异步完成,也就是说它需要期待一些尚未完成的使命。稍后会有更多关于那方面的内容。
该构造体是办法的形态机,不只包罗开发人员编写的所有转换逻辑,还包罗用于跟踪该办法中当前位置的字段,以及编译器从办法中提取的所有“当地”形态,那些形态需要在 MoveNext 挪用之间保存。它在逻辑上等价于迭代器中的 IEnumerableT/IEnumeratorT 实现。(请重视,我展现的代码来自觉布版本;在调试构建中,C# 编译器将现实生成那些形态机类型做为类,因为如许做能够搀扶帮助某些调试工做)。
在初始化形态机之后,我们看到对 AsyncTaskMethodBuilder.Create 的挪用。固然我们目前存眷的是 Tasks,但 C# 语言和编译器容许从异步办法返回肆意类型(“task-like”类型),例如,我能够编写一个办法 public async MyTask CopyStreamToStreamAsync,只要我们以恰当的体例扩展我们前面定义的 MyTask,它就能顺利编译。那种恰当性包罗声明一个相关的“builder”类型,并通过 AsyncMethodBuilder 属性将其与该类型联系关系起来:
publicvoidStartTStateMachine( refTStateMachine stateMachine) whereTStateMachine : IAsyncStateMachine { ... } publicvoidSetStateMachine( IAsyncStateMachine stateMachine) { ... }
publicvoidSetResult( ) { ... } publicvoidSetException( Exception exception) { ... }
publicvoidAwaitOnCompletedTAwaiter, TStateMachine( refTAwaiter awaiter, refTStateMachine stateMachine) whereTAwaiter : INotifyCompletion whereTStateMachine : IAsyncStateMachine { ... } publicvoidAwaitUnsafeOnCompletedTAwaiter, TStateMachine( refTAwaiter awaiter, refTStateMachine stateMachine) whereTAwaiter : ICriticalNotifyCompletion whereTStateMachine : IAsyncStateMachine { ... }
publicMyTask Task { get{ ... } }}
在那种情状下,如许的“builder”晓得若何创建该类型的实例(Task 属性),若何胜利完成并在恰当的情状下有成果(SetResult)或有反常(SetException),以及若何处置毗连期待尚未完成的事务的延续(AwaitOnCompleted/AwaitUnsafeOnCompleted)。在 System.Threading.Tasks.Task 的情状下,它默认与 AsyncTaskMethodBuilder 相联系关系。凡是情状下,那种联系关系是通过利用在类型上的 [AsyncMethodBuilder(…)] 属性供给的,但在 C# 中,Task 是已知的,因而现实上没有该属性。因而,编译器已经让构建器利用那个异步办法,并利用形式中的 Create 办法构建它的实例。请重视,与形态机一样,AsyncTaskMethodBuilder 也是一个构造体,因而那里也没有内存分配。
然后用那个进口点办法的参数填充形态机。那些参数需要可以被挪动到 MoveNext 中的办法体拜候,因而那些参数需要存储在形态机中,以便后续挪用 MoveNext 时代码能够引用它们。该形态机也被初始化为初始-1形态。假设 MoveNext 被挪用且形态为-1,那么逻辑上我们将从办法的起头处起头。
如今是最不显眼但最重要的一行:挪用构建器的 Start 办法。那是形式的另一部门,必需在 async 办法的返回位置所利用的类型上公开,它用于在形态机上施行初始的 MoveNext。构建器的 Start 办法现实上是如许的:
例如,挪用 stateMachine.t__builder.Start(ref stateMachine); 现实上只是挪用 stateMachine.MoveNext。在那种情状下,为什么编译器不间接发出那个信号呢?为什么还要有 Start 呢?谜底是,Start 的内容比我所说的要多一点。但为此,我们需要简单地领会一下 ExecutionContext。
❖ExecutionContext
我们都熟悉在办法之间传递形态。挪用一个办法,假设该办法指定了形参,就利用实参挪用该办法,以便将该数据传递给被挪用方。那是显式传递数据。但还有其他更隐蔽的办法。例如,办法能够是无参数的,但能够指定在挪用办法之前填充某些特定的静态字段,然后从那里获取形态。那个办法的签名中没有任何工具表白它领受参数,因为它确实没有:只是挪用者和被挪用者之间有一个隐含的约定,即挪用者可能填充某些内存位置,而被挪用者可能读取那些内存位置。被挪用者和挪用者以至可能没有意识到它的发作,假设他们是中介,办法 A 可能填充静态信息,然后挪用 B, B 挪用 C, C 挪用 D,最末挪用 E,读取那些静态信息的值。那凡是被称为“情况”数据:它不是通过参数传递给你的,而是挂在那里,假设需要的话,你能够利用。
我们能够更进一步,利用线程部分形态。线程部分形态,在 .NET 中是通过属性为 [ThreadStatic] 的静态字段或通过 ThreadLocalT 类型实现的,能够以不异的体例利用,但数据仅限于当前施行的线程,每个线程都可以拥有那些字段的本身的隔离副本。如许,您就能够填充线程静态,停止办法挪用,然后在办法完成后将更改复原到线程静态,从而启用那种隐式传递数据的完全隔离形式。
假设我们停止异步办法挪用,而异步办法中的逻辑想要拜候情况数据,它会怎么做?假设数据存储在常规静态中,异步办法将可以拜候它,但一次只能有一个如许的办法在运行,因为多个挪用者在写进那些共享静态字段时可能会笼盖相互的形态。假设数据存储在线程静态信息中,异步办法将可以拜候它,但只要在挪用线程停行同步运行之前;假设它将 continuation 毗连到它倡议的某个操做,而且该 continuation 最末在某个其他线程上运行,那么它将不再可以拜候线程静态信息。即便它恰巧运行在统一个线程上,无论是偶尔的仍是因为调度器的强逼,在它如许做的时候,数据可能已经被该线程倡议的其他操做删除和/或笼盖。关于异步,我们需要一种机造,容许肆意情况数据在那些异步点上活动,如许在 async 办法的整个逻辑中,无论何时何地运行,它都能够拜候不异的数据。
输进 ExecutionContext。ExecutionContext 类型是异步操做和异步操做之间传递情况数据的前言。它存在于一个 [ThreadStatic] 中,但是当某些异步操做启动时,它被“捕获”(从该线程静态中读取副本的一种奇异的体例),存储,然后当该异步操做的延续被运行时,ExecutionContext 起首被恢复到即将运行该操做的线程中的 [ThreadStatic] 中。ExecutionContext 是实现 AsyncLocalT 的机造(事实上,在 .NET Core 中,ExecutionContext 完满是关于 AsyncLocalT 的,仅此罢了),例如,假设你将一个值存储到 AsyncLocalT 中,然后例如队列一个工做项在 ThreadPool 上运行,该值将在该 AsyncLocalT 中可见,在该工做项上运行:
number.Value = 42;ThreadPool.QueueUserWorkItem(_ = Console.WriteLine( number.Value)); number.Value = 0; Console.ReadLine;
那段代码每次运行时城市打印42。在我们对拜托停止列队之后,我们将 AsyncLocalint 的值重置为0,那无关紧要,因为 ExecutionContext 是做为 QueueUserWorkItem 挪用的一部门被捕获的,而该捕获包罗了其时 AsyncLocalint 的形态。
❖Back To Start
当我在写 AsyncTaskMethodBuilder.Start 的实现时,我们绕道讨论了 ExecutionContext,我说那是有效的:
然后定见我简化一下。那种简化漠视了一个事实,即该办法现实上需要将 ExecutionContext 考虑在内,因而更像是如许:
那里不像我之前定见的那样只挪用 statemmachine .MoveNext,而是在那里做了一个动做:获取当前的 ExecutionContext,再挪用 MoveNext,然后在它完成时将当前上下文重置为挪用 MoveNext 之前的形态。
如许做的原因是为了避免异步办法将情况数据泄露给挪用者。一个示例办法阐了然为什么那很重要:
“冒充”是将当前用户的情况信息改为其别人的;那让代码能够代表其别人,利用他们的特权和拜候权限。在 .NET 中,那种模仿跨异步操做活动,那意味着它是 ExecutionContext 的一部门。如今想象一下,假设 Start 没有恢复之前的上下文,考虑下面的代码:
那段代码能够发现,ElevateAsAdminAndRunAsync 中修改的 ExecutionContext 在 ElevateAsAdminAndRunAsync 返回到它的同步伐用者之后仍然存在(那发作在该办法第一次期待尚未完成的内容时)。那是因为在挪用 Impersonate 之后,我们挪用了 DoSensitiveWorkAsync 并期待它返回的使命。假设使命没有完成,它将招致对 ElevateAsAdminAndRunAsync 的挪用 yield 并返回到挪用者,模仿仍然在当前线程上有效。那不是我们想要的。因而,Start 设置了那个庇护机造,以确保对 ExecutionContext 的任何修改都不会从同步办法挪用中流出,而只会跟着办法施行的任何后续工做一路流出。
❖MoveNext
因而,挪用了进口点办法,初始化了形态机构造体,挪用了 Start,然后挪用了 MoveNext。什么是 MoveNext?那个办法包罗了开发者办法中所有的原始逻辑,但做了一大堆修改。让我们先看看那个办法的脚手架。下面是编译器为我们的办法生成的反编译版本,但删除了生成的 try 块中的所有内容:
1__state = -2; buffer 5__2 = null; t__builder.SetResult;}
无论 MoveNext 施行什么其他工做,当所有工做完成后,它都有责任完成 async Task 办法返回的使命。假设 try 代码块的主体抛出了未处置的反常,那么使命就会抛出该反常。假设 async 办法胜利抵达它的起点(相当于同步办法返回),它将胜利完成返回的使命。在任何一种情状下,它都将设置形态机的形态以表达完成。(我有时听到开发人员从理论上说,当涉及到反常时,在第一个 await 之前抛出的反常和在第一个 await 之后抛出的反常是有区此外……基于上述,应该清晰情状并不是如斯。任何未在 async 办法中处置的反常,不管它在办法的什么位置,也不管办法能否产生告终果,城市在上面的 catch 块中完毕,然后被捕获的反常会存储在 async 办法返回的使命中。)
还要重视,那个完成过程是通过构建器完成的,利用它的 SetException 和 SetResult 办法,那是编译器预期的构建器形式的一部门。假设 async 办法之前已经挂起了,那么构建器将不能不再挂起处置中创建一个 Task (稍后我们会看到若何以及在哪里施行),在那种情状下,挪用 SetException/SetResult 将完成该使命。然而,假设 async 办法之前没有挂起,那么我们还没有创建使命或向挪用者返回任何工具,因而构建器在生成使命时有更大的乖巧性。假设你还记得之前在进口点办法中,它做的最初一件事是将使命返回给挪用者,它通过拜候构建器的 Task 属性返回成果:
构建器晓得该办法能否挂起过,假设挂起了,它就会返回已经创建的使命。假设办法从未挂起,并且构建器还没有使命,那么它能够在那里创建一个完成的使命。在胜利完成的情状下,它能够间接利用 Task.CompletedTask 而不是分配一个新的使命,制止任何分配。假设是一般的使命 TResult,构建者能够间接利用 Task.FromResultTResult(TResult result)。
构建器还能够对它创建的对象停止任何它认为适宜的转换。例如,Task 现实上有三种可能的最末形态:胜利、失败和取缔。AsyncTaskMethodBuilder 的 SetException 办法处置特殊情状 OperationCanceledException,将使命转换为 TaskStatus。假设供给的反常是 OperationCanceledException 或源自 OperationCanceledException,则将使命转换为 TaskStatus.Canceled 最末形态;不然,使命以 TaskStatus.Faulted 完毕;那种区别在利用代码时往往不明显;因为无论反常被标识表记标帜为取缔仍是毛病,城市被存储到 Task 中,期待该使命的代码将无法看察到形态之间的区别(无论哪种情状,原始反常城市被传布)...... 它只影响与使命间接交互的代码,例如通过 ContinueWith,它具有重载,容许仅为完成形态的子集挪用 continuation。
如今我们领会了生命周期方面的内容,下面是在 MoveNext 的 try 块内填写的所有内容:
TaskAwaiter int awaiter; if(num != 0) {if(num != 1) {buffer 5__2 = newbyte[ 4096]; gotoIL_008b; }
awaiter = u__2;u__2 = default(TaskAwaiter int); num = ( 1__state = -1); gotoIL_00f0; }
TaskAwaiter awaiter2 = u__1;u__1 = default(TaskAwaiter); num = ( 1__state = -1); IL_0084:awaiter2.GetResult;
IL_008b:awaiter = source.ReadAsync(buffer 5__2, 0, buffer 5__2.Length).GetAwaiter; if(!awaiter.IsCompleted) {num = ( 1__state = 1); u__2 = awaiter;t__builder.AwaitUnsafeOnCompleted( refawaiter, refthis); return; }IL_00f0:intresult; if((result = awaiter.GetResult) != 0) {awaiter2 = destination.WriteAsync(buffer 5__2, 0, result).GetAwaiter; if(!awaiter2.IsCompleted) {num = ( 1__state = 0); u__1 = awaiter2;t__builder.AwaitUnsafeOnCompleted( refawaiter2, refthis); return; }gotoIL_0084; }}catch(Exception exception) { 1__state = -2; buffer 5__2 = null; t__builder.SetException(exception);return; }
1__state = -2; buffer 5__2 = null; t__builder.SetResult;}
那种复杂的情状可能觉得有点熟悉。还记得我们基于 APM 手动实现的 BeginCopyStreamToStream 有多复杂吗?那没有那么复杂,但也更好,因为编译器为我们做了那些工做,以延续传递的形式重写了办法,同时确保为那些延续保留了所有需要的形态。即使如斯,我们也能够眯着眼睛跟着走。请记住,形态在进口点被初始化为-1。然后我们进进 MoveNext,发现那个形态(如今存储在当地 num 中)既不是0也不是1,因而施行创建暂时缓冲区的代码,然后跳转到标签 IL_008b,在那里挪用 stream.ReadAsync。重视,在那一点上,我们仍然从挪用 MoveNext 同步运行,因而从起头到进口点都同步运行,那意味着开发者的代码挪用了 CopyStreamToStreamAsync,它仍然在同步施行,还没有返回一个 Task 来表达那个办法的最末完成。
我们挪用 Stream.ReadAsync,从中得到一个 Taskint。读取可能是同步完成的,也可能是异步完成的,但速度快到如今已经完成,也可能还没有完成。不管怎么说,我们有一个表达最末完成的 Taskint,编译器发出的代码会查抄该 Taskint 以决定若何陆续:假设该 Taskint 确实已经完成(不管它是同步完成仍是只是在我们查抄时完成),那么那个办法的代码就能够陆续同步运行......当我们能够在那里陆续运行时,没有需要花没必要要的开销列队处置该办法施行的剩余部门。但是为了处置 Taskint 还没有完成的情状,编译器需要发出代码来为 Task 挂上一个延续。因而,它需要发出代码,询问使命 "你完成了吗?" 它能否是间接与使命对话来问那个问题?
假设你在 C# 中独一能够期待的工具是 System.Threading.Tasks.Task,那将是一种限造。同样地,假设 C# 编译器必需晓得每一种可能被期待的类型,那也是一种限造。相反,C# 在那种情状下凡是会做的是:它摘用了一种 api 形式。代码能够期待任何公开恰当形式(“awaiter”形式)的工具(就像您能够期待任何供给恰当的“可列举”形式的工具一样)。例如,我们能够加强前面写的 MyTask 类型来实现 awaiter 形式:
publicstructMyTaskAwaiter : ICriticalNotifyCompletion {internalMyTask _task;
publicboolIsCompleted = _task._completed; publicvoidOnCompleted( Action continuation) = _task.ContinueWith(_ = continuation); publicvoidUnsafeOnCompleted( Action continuation) = _task.ContinueWith(_ = continuation); publicvoidGetResult( ) = _task.Wait; }}
假设一个类型公开了 getwaiter 办法,就能够期待它,Task 就是如许做的。那个办法需要返回一些内容,而那些内容又公开了几个成员,包罗一个 IsCompleted 属性,用于在挪用 IsCompleted 时查抄操做能否已经完成。你能够看到正在发作的工作:在 IL_008b,从 ReadAsync 返回的使命已经挪用了 getwaiter,然后在 struct awaiter 实例上完成拜候。假设 IsCompleted 返回 true,那么最末会施行到 IL_00f0,在那里代码会挪用 awaiter 的另一个成员:GetResult。假设操做失败,GetResult 负责抛出反常,以便将其传布到 async 办法中的 await 之外;不然,GetResult 负责返回操做的成果。在 ReadAsync 的例子中,假设成果为0,那么我们跳出读写轮回,到办法的末尾挪用 SetResult,就完成了。
不外,回过甚来看一下,实正有趣的部门是,假设 IsCompleted 查抄现实上返回 false,会发作什么。假设它返回 true,我们就陆续处置轮回,类似于在 APM 形式中 completedsynchronized 返回 true,Begin 办法的挪用者负责陆续施行,而不是回调函数。但是假设 IsCompleted 返回 false,我们需要暂停 async 办法的施行,曲到 await 操做完成。那意味着从 MoveNext 中返回,因为那是 Start 的一部门,我们仍然在进口点办法中,那意味着将使命返回给挪用者。但在发作任何工作之前,我们需要将 continuation 毗连到正在期待的使命(重视,为了制止像在 APM 情状中那样的 stack dives,假设异步操做在 IsCompleted 返回 false 后完成,但在我们毗连 continuation 之前,continuation 仍然需要从挪用线程异步伐用,因而它将进进队列)。因为我们能够期待任何工具,我们不克不及间接与使命实例对话;相反,我们需要通过一些基于形式的办法来施行此操做。
Awaiter 公开了一个办法来毗连 continuation。编译器能够间接利用它,除了一个十分关键的问题:continuation 到底应该是什么?更重要的是,它应该与什么对象相联系关系?请记住,形态机构造体在栈上,我们当前运行的 MoveNext 挪用是对该实例的办法挪用。我们需要保留形态机,以便在恢复时我们拥有所有准确的形态,那意味着形态机不克不及不断存在于栈中;它需要被复造到堆上的某个处所,因为栈最末将被用于该线程施行的其他后续的、无关的工做。然后,延续需要在堆上的形态机副本上挪用 MoveNext 办法。
此外,ExecutionContext 也与此相关。形态机需要确保留储在 ExecutionContext 中的任何情况数据在暂停时被捕获,然后在恢复时被利用,那意味着延续也需要合并该 ExecutionContext。因而,仅仅在形态机上创建一个指向 MoveNext 的拜托是不敷的。那也是我们不想要的开销。假设当我们挂起时,我们在形态机上创建了一个指向 MoveNext 的拜托,那么每次如许做我们都要对形态机构造停止拆箱(即便它已经做为其他对象的一部门在堆上)并分配一个额外的拜托(拜托的那个对象引用将是该构造体的一个新拆箱的副本)。因而,我们需要做一个复杂的动做,即确保我们只在办法第一次暂停施行时将该构造从仓库中提拔到堆中,而在其他时候都利用不异的堆对象做为 MoveNext 的目标,并在那个过程中确保我们捕获了准确的上下文,并在恢复时确保我们利用捕获的上下文来挪用该操做。
你能够在 C# 编译器生成的代码中看到,当我们需要挂起时就会发作:
我们将形态 id 存储到 state 字段中,该 id 表达当办法恢复时应该跳转到的位置。然后,我们将 awaiter 自己耐久化到一个字段中,以便在恢复后能够利用它来挪用 GetResult。然后在返回 MoveNext 挪用之前,我们要做的最初一件事是挪用 t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this),要求构建器为那个形态机毗连一个 continuation 到 awaiter。(重视,它挪用构建器的 AwaitUnsafeOnCompleted 而不是构建器的 AwaitOnCompleted,因为 awaiter 实现了 iccriticalnotifycompletion;形态机处置活动的 ExecutionContext,所以我们不需要 awaiter,正如前面提到的,如许做只会带来反复和没必要要的开销。)
AwaitUnsafeOnCompleted 办法的实现太复杂了,不克不及在那里详述,所以我将总结它在 .NET Framework 上的感化:
1.它利用 ExecutionContext.Capture 来获取当前上下文。
2.然后它分配一个 MoveNextRunner 对象来包拆捕获的上下文和拆箱的形态机(假设那是该办法第一次挂起,我们还没有形态机,所以我们只利用 null 做为占位符)。
3.然后,它创建一个操做拜托给该 MoveNextRunner 上的 Run 办法;那就是它若何可以获得一个拜托,该拜托将在捕获的 ExecutionContext 的上下文中挪用形态机的 MoveNext。
4.假设那是该办法第一次挂起,我们还没有拆箱的形态机,所以此时它会将其拆箱,通过将实例存储到当地类型的 IAsyncStateMachine 接口中,在堆上创建一个副本。然后,那个盒子会被存储到已分配的 MoveNextRunner 中。
5.如今到了一个有些令人费解的步调。假设您查看形态机构造体的定义,它包罗构建器,public AsyncTaskMethodBuilder t__builder;,假设你查看构建器的定义,它包罗内部的 IAsyncStateMachine m_stateMachine;。构建器需要引用拆箱的形态机,以便在后续的挂起中它能够看到它已经拆箱了形态机,而且不需要再次如许做。但是我们只是拆箱了形态机,而且该形态机包罗一个 m_stateMachine 字段为 null 的构建器。我们需要改动拆箱形态机的构建器的 m_stateMachine 指向它的父容器。为了实现那一点,编译器生成的形态机构造体实现了 IAsyncStateMachine 接口,此中包罗一个 void SetStateMachine(IAsyncStateMachine stateMachine) ;办法,该形态机构造体包罗了该接口办法的实现:
因而,构建器对形态机停止拆箱,然后将拆箱传递给拆箱的 SetStateMachine 办法,该办法会挪用构建器的 SetStateMachine 办法,将拆箱存储到字段中。
6.最初,我们有一个表达 continuation 的 Action,它被传递给 awaiter 的 UnsafeOnCompleted 办法。在 TaskAwaiter 的情状下,使命将将该操做存储到使命的 continuation 列表中,如许当使命完成时,它将挪用该操做,通过 MoveNextRunner.Run 回调,通过 ExecutionContext.Run 回调,最初挪用形态机的 MoveNext 办法从头进进形态机,并从它停行的处所陆续运行。
那就是在 .NET Framework 中发作的工作,你能够在阐发器中看到成果,例如通过运行分配阐发器来查看每个 await 上的分配情状。让我们看看那个愚笨的法式,我写那个法式只是为了强调此中涉及的分配成本:
classProgram{staticasyncTask Main( ) {varal = newAsyncLocal int { Value = 42}; for( inti = 0; i 1000; i++) {awaitSomeMethodAsync; }}
staticasyncTask SomeMethodAsync( ) {for( inti = 0; i 1000; i++) {awaitTask.Yield; }}}
那个法式创建了一个 AsyncLocalint,让值42通过所有后续的异步操做。然后它挪用 SomeMethodAsync 1000次,每次暂停/恢复1000次。在 Visual Studio 中,我利用 .NET Object Allocation Tracking profiler 运行它,成果如下:
那是良多的分配!让我们来研究一下它们的来源。
ExecutionContext。有超越一百万个如许的内容被分配。为什么?因为在 .NET Framework 中,ExecutionContext 是一个可变的数据构造。因为我们期看流转一个异步操做被 fork 时的数据,而且我们不期看它在 fork 之后看到施行的变动,我们需要复造 ExecutionContext。每个零丁的 fork 操做都需要如许的副本,因而有1000次对 SomeMethodAsync 的挪用,每个挪用城市暂停/恢复1000次,我们有100万个 ExecutionContext 实例。
Action。类似地,每次我们期待尚未完成的使命时(我们的百万个 await Task.Yields就是那种情状),我们最末分配一个新的操做拜托来传递给 awaiter 的 UnsafeOnCompleted 办法。
MoveNextRunner。同样的,有一百万个如许的例子,因为在前面的步调纲领中,每次我们暂停时,我们都要分配一个新的 MoveNextRunner 来存储 Action和 ExecutionContext,以便利用后者来施行前者。
LogicalCallContext。那些是 .NET Framework 上 AsyncLocalT 的实现细节;AsyncLocalT 将其数据存储到 ExecutionContext 的“逻辑挪用上下文”中,那是表达与 ExecutionContext 一路活动的一般形态的一种奇异体例。假设我们要复造一百万个 ExecutionContext,我们也会复造一百万个 LogicalCallContext。
QueueUserWorkItemCallback。每个 Task.Yield 都将一个工做项列队到线程池中,招致分配了100万个工做项对象用于表达那100万个操做。
Task VoidResult 。那里有一千个如许的,所以致少我们离开了"百万"俱乐部。每个异步完成的异步使命挪用都需要分配一个新的 Task 实例来表达该挪用的最末完成。
SomeMethodAsync d__1。那是编译器生成的形态机构造的盒子。1000个办法挂起,1000个盒子呈现。
QueueSegment / IThreadPoolWorkItem[]。有几千个如许的办法,从手艺上讲,它们与详细的异步办法无关,而是与线程池中的队列工做有关。在 .NET 框架中,线程池的队列是一个非轮回段的链表。那些段不会被重用;关于长度为 N 的段,一旦 N 个工做项被加进到该段的队列中并从该段中退出,该段就会被丢弃并当做垃圾收受接管。
那就是 .NET Framework。 那是 .NET Core:
关于 .NET Framework 上的那个示例,有超越500万次分配,总共分配了大约145MB的内存。关于 .NET Core 上的不异示例,只要大约1000个内存分配,总共只要大约109KB。为什么那么少?
ExecutionContext。在 .NET Core 中,ExecutionContext 如今是不成变的。如许做的缺点是,对上下文的每次更改,例如将值设置为 AsyncLocalT,都需要分配一个新的 ExecutionContext。然而,益处是,活动的上下文比改动它更常见,并且因为 ExecutionContext 如今是不成变的,我们不再需要做为活动的一部门停止克隆。“捕获”上下文现实上就是从字段中读取它,而不是读取它并复造其内容。因而,活动不只比改变更常见,并且更廉价。
LogicalCallContext。那在 .NET Core 中已经不存在了。在 .NET Core 中,ExecutionContext 独一存在的工具是 AsyncLocalT 的存储。其他在 ExecutionContext 中有本身特殊位置的工具都是以 AsyncLocalT 为模子的。例如,在 .NET Framework 中,模仿将做为 SecurityContext 的一部门活动,而SecurityContext 是 ExecutionContext 的一部门;在 .NET Core 中,模仿通过 AsyncLocalSafeAccessTokenHandle 活动,它利用 valueChangedHandler 来对当前线程停止恰当的更改。
QueueSegment / IThreadPoolWorkItem[]。在 .NET Core 中,ThreadPool 的全局队列如今被实现为 ConcurrentQueueT,而 ConcurrentQueueT 已经被重写为一个由非固定大小的轮回段构成的链表。一旦段的长度大到永久不会被填满因为稳态的出队列可以跟上稳态的进队列,就不需要再分配额外的段,不异的足够大的段就会被无休行地利用。
那么其他的分配呢,好比 Action、MoveNextRunner 和 SomeMethodAsyncd__1? 要理解剩余的分配是若何被移除的,需要深进领会它在 .NET Core 上是若何工做的。
让我们回到讨论挂起时发作的工作:
不管目标是哪个平台,那里发出的代码都是不异的,所以不论是 .NET Framework 仍是,为那个挂起生成的 IL 都是不异的。但是,改动的是 AwaitUnsafeOnCompleted 办法的实现,在 .NET Core 中有很大的差别:
1.工作的起头是一样的:该办法挪用 ExecutionContext.Capture 来获取当前施行上下文。
2.然后,工作偏离了 .NET Framework。.NET Core 中的 builder 只要一个字段:
在捕获 ExecutionContext 之后,它查抄 m_task 字段能否包罗一个 AsyncStateMachineBoxTStateMachine 的实例,此中 TStateMachine 是编译器生成的形态机构造体的类型。AsyncStateMachineBoxTStateMachine 类型定义如下:
与其说那是一个零丁的 Task,不如说那是一个使命(重视其根本类型)。该构造并没有对形态机停止拆箱,而是做为该使命的强类型字段存在。我们不需要用零丁的 MoveNextRunner 来存储 Action 和 ExecutionContext,它们只是那个类型的字段,并且因为那是存储在构建器的 m_task 字段中的实例,我们能够间接拜候它,不需要在每次暂停时从头分配。假设 ExecutionContext 发作改变,我们能够用新的上下文笼盖该字段,而不需要分配其他工具;我们的任何 Action 仍然指向准确的处所。所以,在捕获了 ExecutionContext 之后,假设我们已经有了那个 AsyncStateMachineBoxTStateMachine 的实例,那就不是那个办法第一次挂起了,我们能够间接把新捕获的 ExecutionContext 存储到此中。假设我们还没有一个AsyncStateMachineBoxTStateMachine 的实例,那么我们需要分配它:
请重视源正文为“重要”的那一行。那代替了 .NET Framework 中复杂的 SetStateMachine,使得 SetStateMachine 在 .NET Core 中底子没有利用。你看到的 taskField 有一个指向 AsyncTaskMethodBuilder 的 m_task 字段的 ref。我们分配 AsyncStateMachineBox tstatemachinebox ,然后通过 taskField 将对象存储到构建器的 m_task 中(那是在栈上的形态机构造中的构建器),然后将基于仓库的形态机(如今已经包罗对盒子的引用)复造到基于堆的 AsyncStateMachineBox tstatemachinebox 中,如许 AsyncStateMachineBoxTStateMachine 恰当地并递回地完毕引用本身。那仍然是令人费解的,但却是一种更有效的费解。
3.然后,我们能够对那个 Action 上的一个办法停止操做,该办法将挪用其 MoveNext 办法,该办法将在挪用 StateMachine 的 MoveNext 之前施行恰当的 ExecutionContext 恢复。该 Action 能够缓存到 _moveNextAction 字段中,以便任何后续利用都能够重用不异的 Action。然后,该 Action 被传递给 awaiter 的 UnsafeOnCompleted 来毗连 continuation。
它阐了然为什么剩下的大部门分配都没有了:SomeMethodAsyncd__1 没有被拆箱,而是做为使命自己的一个字段存在,MoveNextRunner 不再需要,因为它的存在只是为了存储 Action 和 ExecutionContext。但是,根据那个阐明,我们仍然应该看到1000个操做分配,每个办法挪用一个,但我们没有。为什么?还有那些 QueueUserWorkItemCallback 对象呢?我们仍然在 Task.Yield 中停止列队,为什么它们没有呈现呢?
正如我所提到的,将实现细节推进核心库的益处之一是,它能够跟着时间的推移改进实现,我们已经看到了它是若何从 .NETFramework 开展到 .NETCore 的。它在最后为 .NET Core 重写的根底长进一步开展,增加了额外的优化,那得益于对系统关键组件的内部拜候。特殊是,异步根底设备晓得 Task 和 TaskAwaiter 等核心类型。并且因为它晓得它们并具有内部拜候权限,所以它没必要遵照公开定义的规则。C# 语言遵照的 awaiter 形式要求 awaiter 具有 AwaitOnCompleted 或 AwaitUnsafeOnCompleted 办法,那两个办法都将 continuation 做为一个操做,那意味着根底构造需要可以创建一个操做来表达 continuation,以便与根底构造不晓得的肆意 awaiter 一路工做。但是,假设根底设备碰着它晓得的 awaiter,它没有义务摘取不异的代码途径。关于 System.Private 中定义的所有核心 awaiter。因而,CoreLib 的根底设备能够遵照更简洁的途径,完全不需要操做。那些 awaiter 都晓得 IAsyncStateMachineBoxes,而且可以将 box 对象自己做为 continuation。例如,Task 返回的 YieldAwaitable.Yield 可以将 IAsyncStateMachineBox 自己做为工做项间接放进 ThreadPool 中,而期待使命时利用的 TaskAwaiter 可以将 IAsyncStateMachineBox 自己间接存储到使命的延续列表中。不需要操做,也不需要 QueueUserWorkItemCallback。
因而,在非经常见的情状下,async 办法只期待 System.Private.CoreLib (Task, TaskTResult, ValueTask, ValueTaskTResult,YieldAwaitable,以及它们的ConfigureAwait 变体),最坏的情状下,只要一次开销分配与 async 办法的整个生命周期相关:假设那个办法挂起了,它会分配一个单一的 Task-derived 类型来存储所有其他需要的形态,假设那个办法历来没有挂起,就不会产生额外的分配。
假设情愿,我们也能够往掉最初一个分配,至少以平摊的体例。如所示,有一个默认构建器与 Task(AsyncTaskMethodBuilder) 相联系关系,类似地,有一个默认构建器与使命 TResult (AsyncTaskMethodBuilderTResult) 和 ValueTask 和ValueTaskTResult (AsyncValueTaskMethodBuilder 和 AsyncValueTaskMethodBuilderTResult,别离)相联系关系。关于 ValueTask/ValueTaskTResult,构造器现实上相当简单,因为它们自己只处置同步且胜利完成的情状,在那种情状下,异步办法完成而不挂起,构建器能够只返回一个 ValueTask.Completed 或者一个包罗成果值的 ValueTaskTResult。关于其他所有工作,它们只是拜托给 AsyncTaskMethodBuilder/AsyncTaskMethodBuilderTResult,因为 ValueTask/ValueTaskTResult 会被返回包拆一个 Task,它能够共享所有不异的逻辑。但是 .NET 6 and C# 10 引进了一个办法能够笼盖逐个办法利用的构建器的才能,并为 ValueTask/ValueTaskTResult 引进了几个专门的构建器,它们可以池化 IValueTaskSource/IValueTaskSourceTResult 对象来表达最末的完成,而不是利用 Tasks。
我们能够在我们的样本中看到那一点的影响。略微调整一下之前阐发的 SomeMethodAsync 函数,让它返回 ValueTask 而不是 Task:
那将生成以下进口点:
如今,我们添加 [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] 到 SomeMethodAsync 的声明中:
编译器输出如下:
整个实现的现实 C# 代码生成,包罗整个形态机(没有展现),几乎是不异的;独一的区别是创建和存储的构建器的类型,因而在我们之前看到的任何引用构建器的处所都能够利用。假设你看一下 PoolingAsyncValueTaskMethodBuilder 的代码,你会看到它的构造几乎与 AsyncTaskMethodBuilder 不异,包罗利用一些完全不异的共享例程来做一些工作,如特殊套管已知的 awaiter 类型。关键的区别是,当办法第一次挂起时,它不是施行新的 AsyncStateMachineBoxTStateMachine,而是施行 StateMachineBoxTStateMachine. rentfromcache,而且在 async 办法 (SomeMethodAsync) 完成并期待返回的 ValueTask 完成时,租用的盒子会被返回到缓存中。那意味着(平摊)零分配:
那个缓存自己有点意思。对象池可能是一个好主意,也可能是一个坏主意。创建一个对象的成本越高,共享它们的价值就越大;因而,例如,对十分大的数组停止池化比对十分小的数组停止池化更有价值,因为更大的数组不只需要更多的 CPU 周期和内存拜候为零,它们还会给垃圾搜集器带来更大的压力,使其更频繁地搜集垃圾。然而,关于十分小的对象,将它们池化可能会带来负面影响。池只是内存分配器,GC 也是,所以当您利用池时,您是在权衡与一个分配器相关的成本与另一个分配器相关的成本,而且 GC 在处置大量细小的、保存期短的对象方面十分高效。假设你在对象的构造函数中做了良多工做,制止那些工做能够使分配器自己的开销相形见绌,从而使池变得有价值。但是,假设您在对象的构造函数中几乎没有做任何工做,而且将其停止池化,则您将打赌您的分配器(您的池)就所摘用的拜候形式而言比 GC 更有效,而那凡是是一个蹩脚的赌注。还涉及其他成本,在某些情状下,您可能最末会有效地匹敌 GC 的启发式办法;例如,垃圾收受接管是基于一个前提停止优化的,即从较高代(如gen2)对象到较低代(如gen0)对象的引用相对较少,但池化对象能够使那些前提失效。
我们今天为各人介绍了 C# 迭代器和 async/await under the covers, 下期文章,我们将陆续介绍 SynchronizationContext 和 ConfigureAwait,欢送继续存眷。