异步编程长时间以来一直都是那些技能高超、喜欢挑战自个人开发人员涉足的领域 — 这些人愿意花费时间,充满热情并拥有心理承受能力,可以在非线性的控制流程中不断地琢磨回调,以后再回调。 随着 Microsoft .NET Framework 4.5 的推出,C# 和 Visual Basic 让咱们其余人也能处理异步工做,普通程序员也能够像编写同步方法同样轻松编写异步方法。 再也不使用回调。 再也不须要将代码从一个同步环境显式封送到另外一个同步环境。 再也不须要担忧结果或异常的流动。 再也不须要千方百计地改造现有语言功能而简化异步开发。 简言之,没有麻烦了。程序员
固然,如今很容易开始编写异步方法(请参阅本期 MSDN 杂志中 Eric Lippert 和 Mads Torgersen 的文章),可是想要真正作好,仍然须要了解后台的工做原理。 不管什么时候,当某种语言或框架提升了开发人员能够编程的抽象级别时,也老是隐含了不可见的性能成本。 在许多状况下,这些成本是微不足道,要实现大量方案的广大开发人员能够也应该忽略它们。 然而,对于更高级的开发人员来讲,仍是有必要去真正了解都有哪些成本,若是这些成本最终成为阻碍,咱们可以采起必要的步骤予以免。 对于 C# 和 Visual Basic 中的异步方法功能,就存在这种状况。编程
在本文中,我将探讨异步方法的各类细节,使您可以全面了解异步方法的内在实现方式,并讨论其中涉及的其余一些略有区别的成本。 请注意,我要传达的信息并非要鼓励您以追求微优化和性能的名义,将可读代码改形成不可维护的代码。 它只是为提供您一些信息,帮助您诊断可能遇到的问题,并提供一套帮助您解决此类潜在问题的工具。 另请注意,本文以 .NET Framework 4.5 的预览版本为基础,具体实施细节可能在最终版本发布以前有所变更。数组
几十年来,开发人员一直使用 C#、Visual Basic、F# 和 C++ 等高级语言开发高效的应用程序。 这方面的经验使得开发人员了解了各类操做的相关成本,这种了解也推进了最佳开发实践。 例如,对于大多数用例,调用同步方法相对比较便宜,而当编译器可以将被调用方内嵌到调用点时则更加便宜。 所以,开发人员学会了将代码重构为小的、可维护的方法,通常无需考虑因为方法调用数量增长而带来的负面影响。 这些开发人员对调用方法的意义有着特定的思惟模式。缓存
在引入异步方法后,他们须要有新的思惟模式。 C# 和 Visual Basic 语言以及编译器会让人产生异步方法就是其同步方法的对应版本的错觉,但实际状况并非如此。 编译器最终会代替开发人员生成大量代码,这些代码与过去实现异步时必须由开发人员手动编写并维护的样板代码的数量相似。 此外,编译器生成的代码会在 .NET Framework 中调用库代码,再次代替开发人员完成更多的工做。 要得到正确的思惟模式并使用这一模式作出合适的开发决策,重要的一点是了解编译器代替您生成了哪些内容。性能优化
使用同步代码时,附带空白主体的方法几乎是免费的。 但对异步方法来讲,状况并不是如此。 如下面的异步方法为例,其主体包含一个语句(而且因为缺少等待而最终同步运行):网络
中间语言 (IL) 反编译器在编译完成后将揭示此函数的本质,输出相似于图 1 所示内容。 简单的单行式命令已扩展到两个方法,而且其中一个存在于帮助程序状态机类中。 首先,有一个包含了基本签名的存根方法,其签名与开发人员编写的基本签名相同(该方法具备相同的名称,具备相同的可视性,接受相同的参数并保留了返回类型),可是此存根不包含开发人员编写的任何代码。 相反,它包含了设置样板。 设置代码对用来表明异步方法的状态机进行初始化,而后调用状态机上的辅助 MoveNext 方法来启动状态机。 此状态机类型将保留异步方法的状态,若有必要,容许在异步等待点之间保持该状态。 它也包含用户编写的方法的主体,但已通过改造,从而容许结果和异常提高到返回的 Task;并维持方法的当前位置以便完成等待后在此处恢复执行等等。框架
图 1 异步方法样板异步
当考虑要调用的异步方法的成本时,请牢记这同样板。 MoveNext 方法中的 try/catch 块可能会阻止样板被实时 (JIT) 编译器内嵌,因此至少咱们如今拥有了方法调用成本而在同步状况下则可能不会(有如此小的方法主体)。 咱们有多个对 Framework 例程(如 SetResult)的调用, 而且咱们在状态机类型上有多个写入字段。 固然,咱们须要针对 Console.WriteLine 的成本权衡这一切,由于它可能会主宰全部其余涉及到的成本(它须要锁定,它执行 I/O 等)。 此外,请注意基础结构为您作出的优化。 例如,状态机类型就是一个结构。 若是此方法由于正在等待还没有完成的实例(在这简单的方法中,实例永远不会完成)而须要暂停其执行,该结构只会被封装到堆。 所以,异步方法的样板将不会产生任何分配。 编译器和运行时共同努力,以尽可能减小基础结构中涉及的分配数量。async
.NET Framework 应用多种优化,尝试为异步方法生成高效的异步实现。 可是,开发人员一般具有领域知识,而后产生一些优化,若是考虑所追求的通用性,这些优化若是由编译器和运行来自动应用,是有风险和不明智的。 明确这一点后,实际上会有利于开发人员,避免在一小部分用例中使用异步方法,特别是对于将以一种更精准的方式访问的库方法。 通常来讲,若是已知方法实际上能够同步完成(由于方法依赖的数据是已经可用的),尤为如此。ide
设计异步方法时,Framework 开发人员每每花费大量的时间优化对象分配。 这是由于分配是异步方法基础结构中可能出现的最大性能成本之一。 分配一个对象的行为一般至关便宜。 分配对象相似于向购物车中放入商品,此时将商品放入车内不须要花费太多的精力;而当您实际结帐时则须要拿出钱包并投入大量的资源。虽然分配一般开销很低,但谈到应用程序的性能时,产生的垃圾收集实在使人不爽。 垃圾收集行为包括扫描当前已分配的部分对象和查找再也不被引用的对象。 分配的对象越多,执行这种标记的时间就越长。 此外,分配的对象越大而且分配的数量越多,垃圾收集发生的频率就越大。 经过这种方式,分配会对系统产生全局影响:异步方法生成的垃圾越多,整个程序的运行速度就越慢,即便异步方法自身的微基准不显示明显的成本。
对于实际产生执行的异步方法(因为等待还没有完成的对象),异步方法基础结构须要分配一个 Task 对象以从方法返回,而此 Task 对象将用做这一特殊调用的惟一引用。 然而,许多异步方法调用无需产生便可完成。 在这种状况下,异步方法基础结构可能返回一个已缓存并完成的 Task,而该 Task 能够反复使用以免分配没必要要的 Task。 可是,能这么作的状况很少,例如当异步方法是一个非泛型 Task、Task<Boolean> 或 Task<TResult>(其中 TResult 是引用类型)时,异步方法的结果为空。 虽然这一组合在将来可能会扩大,但若是您具有正在实施的操做的领域知识,您能够作的更好。
考虑实现相似 MemoryStream 的类型。 MemoryStream 由 Stream 派生而来,所以能够覆盖 Stream 的新的 .NET 4.5 ReadAsync、WriteAsync 和 FlushAsync 方法,从而优化 MemoryStream 的实现。 因为读取操做与内存中的缓冲区恰好背道而驰,所以若是 ReadAsync 同步运行,仅仅复制内存就可得到更好的性能。使用异步方法实现这一操做应该相似于以下所示:
很是简单。 因为 Read 是一个同步调用,而且此方法中没有会产生控制的等待,所以 ReadAsync 的全部调用实际上会同步完成。 如今,让咱们考虑流的一个标准使用模式,例如复制操做:
这里要注意的是,源流上针对这一特定调用系列的 ReadAsync 老是与同一计数参数(缓冲区的长度)同时调用,所以极有可能返回值(读取的字节数)也将重复。 除了某些极少数的状况外,ReadAsync 的异步方法实现不可能使用缓存的 Task 得到返回值,可是您能够。
请考虑如图 2 所示重写此方法。 利用此方法的一些特色及其常见的使用方案,咱们如今可以用不寻常的方式优化分配,而不期待底层基础结构实现此优化。 所以,每次调用 ReadAsync 都会检索与以前调用时数量相同的字节,咱们能够经过返回与以前调用返回的相同的 Task 来完全避免 ReadAsync 方法产生的调用开销。而对于像这样的低级别操做,咱们指望可以快速并重复调用,这样的优化能够产生显著的变化,特别是在垃圾收集数量方面。
图 2 优化任务分配
当方案中规定了缓存时,可能会执行相关的优化以免任务分配。 考虑一种旨在下载特定网页内容并缓存其成功下载的内容以备未来访问的方法。 这种功能可能使用异步方法编写,以下所示(使用 .NET 4.5 中新的 System.Net.Http.dll 库):
这是一个简单的实现。 而对于没法从缓存获得知足的 GetContentsAsync 调用,为表示这次下载而构建的新 Task<string> 的开销与网络成本相比是能够忽略不计的。 可是,若是内容能够从缓存获得知足,它可能就是不可忽略的成本,对象分配只是包装并退回已经可用的数据。
要避免此成本(为了知足性能目标而这样作),您能够如图 3 所示重写此方法。 咱们如今有两种方法:同步公共方法和公共方法委托的异步私有方法。 字典如今缓存生成的任务而不是自身的内容,因此要将来尝试下载已经成功下载的页面,能够经过访问字典以返回已存在的任务而获得实现。 在内部,咱们也利用 Task 上的 ContinueWith 方法,它容许咱们在任务完成后将其储存到字典中 — 但仅限下载成功的状况下。 固然,此代码较为复杂并须要花费更多的心思编写和维护,而全部的性能优化都同样,除非性能测试证实即便复杂却会产生重大且必要的影响,不然无需花费时间编写这样的代码。 这种优化是否发挥做用实际上要取决于使用方案。 您会但愿引入一组表明常见使用模式的测试,并分析这些测试以判断这种复杂可否以一种有意义的方式提升代码的性能。
图 3 手动缓存任务
另外一个须要考虑的与任务相关的优化是,您是否须要从异步方法返回的任务。 C# 和 Visual Basic 都支持建立返回 void 的异步方法,在这种状况下,永远都不须要为方法分配任务。 您始终应该编写从库中公开的异步方法以返回 Task 或 Task<TResult>,由于做为库的开发人员,您不知道使用者是否愿意等待方法完成。可是,对于某些内部使用方案,返回 void 的异步方法仍然占有一席之地。 返回 void 的异步方法存在的主要缘由是支持现有的事件驱动环境,如 ASP.NET 和 Windows Presentation Foundation (WPF)。 它们经过使用 async 和 await,使得实施按钮处理程序、页面加载事件等变得很容易。 若是您确实考虑使用异步 void 方法,请注意异常的处理:转义异步 void 方法的异常会在异步 void 方法被调用时冒出并进入 SynchronizationContext 当时的状态。
.NET Framework 中有不少类型的“环境”:LogicalCallContext、SynchronizationContext、HostExecutionContext、SecurityContext、ExecutionContext 等(单纯从数量上看,您可能会认为 Framework 的开发人员是受到金钱的激励而推出这么多新环境,可是我能够向您保证咱们不是)。 这些环境中的一部分与异步方法关系很是密切,不只仅是在功能上,它们对异步方法的性能也有很大的影响。
SynchronizationContext SynchronizationContext 在异步方法中扮演着重要角色。 “同步环境”是对封送能力的抽象,即以给定库或框架规定的方式封送委托调用的能力。 例如,WPF 提供一个 DispatcherSynchronizationContext 用来表示调度程序的 UI 线程:向此同步环境发布委托会致使该委托排队等待被其线程上的调度程序执行。 ASP.NET 提供一个 AspNetSynchronizationContext,用于确保在处理 ASP.NET 请求过程当中出现的异步操做按顺序执行并与正确的 HttpContext 状态相关联。 其余在此就不一一列举了。 总之,在 NET Framework 中 SynchronizationContext 约有 10 种具体的实现,一些是公共实现而一些则是内部实现。
当等待 .NET Framework 提供的任务和其余可等待类型时,这些类型的“等待程序”(如 TaskAwaiter)在等待发出时将捕获当前的 SynchronizationContext。 可等待类型完成时,若是已经捕获一个当前的 SynchronizationContext,则表明异步方法其他部分的延续将发布到 SynchronizationContext。 这样,正在编写从 UI 线程调用的异步方法的开发人员不须要为了修改 UI 控件手动将调用封送回 UI 线程:这样的封送由 Framework 基础结构自动处理。
遗憾的是,这样的封送也涉及到成本。 对于使用 await 实现其控制流的应用程序开发人员而言,这种自动封送一般都是正确的解决方案。 可是,库每每不太同样。 应用程序开发人员一般须要这样的封送是由于他们的代码会关注自身正在运行的环境,如可以访问 UI 控件或可以访问 HttpContext 以得到正确的 ASP.NET 请求。 可是大多数的库并不受此约束。 所以,这种自动封送常常是彻底没必要要的成本。 再次之前文所示的将数据从一个流复制到另外一个流的代码为例:
若是此复制操做是从 UI 线程调用,那么每个等待的读、写操做都将强制完成回 UI 线程。 对于 1 MB 的源数据和异步完成读、写的流(大多数的流都是这样),这意味着从后台到 UI 线程有多达 500 个跃点。 为解决这一问题,Task 和 Task<TResult> 类型提供了 ConfigureAwait 方法。 ConfigureAwait 接受一个控制此封送行为的 Boolean continueOnCapturedContext 参数。 若是使用默认的 true,等待将在捕获的 SynchronizationContext 上自动完成。 可是若是使用 false,SynchronizationContext 将被忽略而且 Framework 将尝试在以前异步操做完成的位置继续执行。 将此操做合并到流复制代码会产生下列更高效的版本:
对库开发人员来讲,这种性能影响自身已足够保证一直使用 ConfigureAwait,除非在极少数状况下,库对其环境具备领域知识而且不须要访问正确的环境以执行方法的主体。
除性能以外还有一个在库代码中使用 ConfigureAwait 的缘由。 假设上述的代码没有 ConfigureAwait,并处于一个从 WPF UI 线程调用的名为 CopyStreamToStreamAsync 的方法中,以下所示:
在此,开发人员应该已经写好 button1_Click 做为异步方法而后等待 Task,而不是使用它的同步 Wait 方法。Wait 方法有很是重要的用途,可是将其用在像这样的 UI 线程中等待老是出错。 直到 Task 完成以后 Wait 方法才会返回。 若是是 CopyStreamToStreamAsync,包含的等待尝试发布回到捕获的 SynchronizationContext,而且当这些发布完成后方法才会完成(由于发布会用于处理方法的其他部分)。可是这些发布没法完成,由于处理它们的 UI 线程在调用 Wait 时中断。 这是一个循环的依赖关系,会致使死锁。 若是 CopyStreamToStreamAsync 改成使用 ConfigureAwait(false) 编写,将不会产生循环依赖关系和死锁。
ExecutionContext ExecutionContext 是 .NET Framework 不可或缺的部分,可是大多数开发人员都没有意识到它的存在。 ExecutionContext 是环境的鼻祖,它封装了其余多个环境如 SecurityContext 和 LogicalCallContext,并表明代码中应该自动跨异步点流动的一切。 不管您什么时候在 Framework 中使用 ThreadPool.QueueUserWorkItem、Task.Run、Delegate.BeginInvoke、Stream.BeginRead、WebClient.DownloadStringAsync 或其余异步操做,若是可能,其实是捕获了 ExecutionContext(经过 ExecutionContext.Capture),而后捕获的环境将被用于处理提供的委托(经过 ExecutionContext.Run)。 例如,若是调用 ThreadPool.QueueUserWorkItem 的代码当时正在模拟 Windows 身份标识,则将模拟相同的 Windows 身份标识来运行提供的 WaitCallback 委托。 若是调用 Task.Run 的代码首先将数据存储到 LogicalCallContext,则相同的数据可经过提供的 Action 委托中的 LogicalCallContext 访问。ExecutionContext 也在任务的等待间流动。
Framework 中已有多个优化,以免在没必要要时在捕获的 ExecutionContext 中捕获和运行,由于这样作会很是昂贵。 可是,像模拟 Windows 身份标识或将数据存储到 LogicalCallContext 等操做会阻碍这些优化。 避免执行 ExecutionContext 的操做(如 WindowsIdentity.Impersonate 和 CallContext.LogicalSetData)将在使用异步方法以及使用通常异步功能时带来更好的性能。
当涉及到局部变量时,异步方法提供一个很好的假象。 在同步方法中,C# 和 Visual Basic 中的局部变量都基于堆栈,所以在存储这些局部变量时是无需堆分配的。 但在异步方法中,当异步方法在等待点暂停时,方法的堆栈将消失。 等待回复后,方法要使用的数据则必须存储在某处。 所以,C# 和 Visual Basic 编译器将局部变量“提高”到状态机结构中,而后会在首次暂停的等待以后被封装到堆,这样局部变量就能够在等待点之间继续存续。
在前文中,我介绍了分配的对象数量如何影响垃圾收集的成本和频率,同时分配的对象的大小也会影响垃圾收集的频率。 分配的对象越大,垃圾收集运行的次数就越多。 所以,在异步方法中,须要提高到堆的局部变量越多,垃圾收集发生的频率就越多。
在撰写此文时,C# 和 Visual Basic 编译器有时会提高一些没必要要的局部变量。 如下面的代码段为例:
在等待点以后根本就不读取 dto 变量,所以在等待以前写入的值在经过等待后不须要保留。 可是,编译器生成的用来存储局部变量的状态机类型仍然包含 dto 引用,如图 4 所示。
图 4 局部变量提高
这稍微增大了真正必要的堆对象。 若是您发现垃圾收集发生的频率超过了预期,请考虑您是否真的须要全部这些已经编码到异步方法的临时变量。 要避免状态机类出现过多字段,请按如下示例重写:
此外,.NET 垃圾收集器 (GC) 是分代收集器,也就是说,它将对象组分红小组,称为一代:从更高层次来讲,新对象分配在第 0 代,而后在收集期间存续的全部对象则提升一代(.NET GC 目前使用第 0、1 和 2 代)。 这样一来,GC 会从已知的对象空间的子集频繁收集,从而可以加速收集过程。 它所依据的原理是新分配的对象很快也会消失,而已经出现很长时间的对象则将继续出现很长时间。 这就是说,若是一个对象在第 0 代存续,它最后可能会出现一段时间,但却由于这一额外时间而继续对系统施加压力。 这也意味着咱们确实要确保再也不须要的对象当即能够进行垃圾收集。
借助上文说起的提高,局部变量将提高到在异步方法执行期间仍然保留在根级的类的字段中(只要等待的对象能正确维护对委托的引用,以在等待完成后当即调用)。 在同步方法中,JIT 编译器可以跟踪局部变量什么时候不能再访问,而且此时能够帮助 GC 忽视这些做为根的变量,从而使得再也不被引用到其余任何地方的引用对象能够进行垃圾收集。 可是,在异步方法中,这些局部变量仍然能够引用,这意味着若是它们真正成为局部变量,则这些引用的对象就能存续更长时间。 若是您发现对象使用以后仍然保持有效,请考虑在您使用之后归零引用这些对象的局部变量。 再次强调,只有在您发现它确实致使性能问题时才执行这一操做,不然将使代码没必要要地复杂化。 此外,C# 和 Visual Basic 编译器能够会在最终版本中作出更新,或者将来可代替开发人员处理更多的此类方案,因此今天编写的这类代码未来极可能会被淘汰。
C# 和 Visual Basic 编译器在容许您使用 await 方面特别使人印象深入:几乎任何地方均可以使用。 Await 表达式可能用做更大表达式的一部分,从而容许您等待可能有其余任何返回值的表达式中的 Task<TResult>。例如,如下代码将返回三个任务结果之和:
C# 编译器容许您将“await b”表达式用做 Sum 函数的参数。 可是,此处有多个等待结果以参数形式传递到 Sum,而且因为计算顺序规则和异步在编译器中的实现方式,这个特定的示例须要编译器“分散”前两个等待的临时结果。 正如您以前看到的那样,局部变量经过提高到状态机类的字段中而在等待点之间保持不变。可是,在这个示例中,值是 CLR 计算堆栈上的类,这些值不会提高到状态机而是分散到单个的临时对象,而后再被状态机引用。 当您在首个任务上完成等待并转而等待第二个时,编译器会生成封送首个结果的代码并将封送的对象存储到状态机上的 <>t__stack 字段中。 当您在第二个任务上完成等待并转而等待第三个时,编译器会生成从前两个值中建立 Tuple<int,int> 的代码,并将此元祖存储到相同的 <>__stack 字段中。 这些都说明根据您编写的代码的不一样,最终可能会获得很是不一样的分配模式。 请考虑改用如下方式编写 SumAsync:
这样改变以后,编译器如今会向状态机类发出另外三个字段以存储 ra、rb 和 rc,而且不会发生分散。 所以,您不得不进行权衡:选择分配较少的较大状态机,仍是选择分配较多的较小状态机。 在分散状况下,分配的内存总量会更大,由于每一个分配的对象都会有本身的内存开销,可是最终的性能测试可能会显示它会好得多。 一般,如前所述,除非您已经发现分配是致使麻烦的缘由,不然您不该该考虑这些微优化操做,但不管如何,它有利于了解这些分配来自何处。
固然,毋庸置疑以前的示例中有一个更大的成本,您应该有所了解并积极思考。 直到三个等待都已完成以后代码才可以调用 Sum,而且在等待之间不会进行任何工做。 产生的每个等待都须要大量的工做,所以须要处理的等待越少越好。 而后您应当当即使用 Task.WhenAll 等待全部任务而将全部这三个等待合并到一个:
Task.WhenAll 方法在此返回的 Task<TResult[]> 在全部提供的任务完成以后才完成,这样作的效率也远远高于单独等待每个任务。 同时它还收集每一个任务的结果并存储到数组。 若是您想要避免使用此数组,能够强制绑定到适用于 Task 而不是 Task<TResult> 的非泛型 WhenAll 方法。 对于最终性能,您也能够采用混合方法,首先检查是否全部的任务都已成功完成,若是是,请独立得到它们的结果 — 若是没有,请等待没有完成的任务的 WhenAll。 这能够避免调用 WhenAll 时涉及没必要要的分配,例如分配须要传送到方法的参数数组。 而且,如前所述,咱们也但愿这个库函数能抑制环境封送。 图 5 中显示了此类解决方案。
图 5 应用多项优化
异步方法是一个功能强大的高效工具,使您可以更轻松编写可伸缩和响应更快的库和应用程序。 请牢记一点,异步不是对单个操做的性能优化。 采用同步操做并使其异步化必然会下降该操做的性能,由于它仍然须要完成同步操做的全部工做,只不过如今会有额外的限制和注意事项。 您关注异步的一个缘由是其整体性能:若是您采用异步方法编写全部内容,整个系统的执行效果如何。这样您能够仅消耗执行须要的有价值的资源,重叠 I/O 并实现更好的系统利用率。 .NET Framework 提供的异步方法实现已经进行了优化,而且最终经常比使用现有模式和更多代码精心编写的异步实现可以提供一样优秀甚至更好的性能。 从如今开始,不管您什么时候准备在 .NET Framework 中开发异步代码,异步方法都是您的首选工具。 而且,做为一个开发人员,了解 Framework 代替您在这些异步方法中所做的一切对您很是有益,这样能够确保得到尽量好的最终结果。