http://msdn.microsoft.com/zh-cn/magazine/hh456402.aspx
异步编程长时间以来一直都是那些技能高超、喜欢挑战自我的开发人员涉足的领域 — 这些人愿意花费时间,充满热情并拥有心理承受能力,能够在非线性的控制流程中不断地琢磨回调,之后再回调。 随着 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 中调用库代码,再次代替开发人员完成更多的工作。 要获得正确的思维模式并使用这一模式做出合适的开发决策,重要的一点是了解编译器代替您生成了哪些内容。
积极思考,不要多话
使用同步代码时,附带空白主体的方法几乎是免费的。 但对异步方法来说,情况并非如此。 以下面的异步方法为例,其主体包含一个语句(并且由于缺乏等待而最终同步运行):
C#复制
public static async Task SimpleBodyAsync() {
Console.WriteLine("Hello, Async World!");
}
中间语言 (IL) 反编译器在编译完成后将揭示此函数的本质,输出类似于图 1 所示内容。 简单的单行式命令已扩展到两个方法,并且其中一个存在于帮助程序状态机类中。 首先,有一个包含了基本签名的存根方法,其签名与开发人员编写的基本签名相同(该方法具有相同的名称,具有相同的可视性,接受相同的参数并保留了返回类型),但是此存根不包含开发人员编写的任何代码。 相反,它包含了设置样板。 设置代码对用来代表异步方法的状态机进行初始化,然后调用状态机上的辅助 MoveNext 方法来启动状态机。 此状态机类型将保留异步方法的状态,如有必要,允许在异步等待点之间保持该状态。 它也包含用户编写的方法的主体,但已经过改造,从而允许结果和异常提升到返回的 Task;并维持方法的当前位置以便完成等待后在此处恢复执行等等。
图 1 异步方法样板
C#复制
[DebuggerStepThrough]
public static Task SimpleBodyAsync() {
<SimpleBodyAsync>d__0 d__ = new <SimpleBodyAsync>d__0();
d__.<>t__builder = AsyncTaskMethodBuilder.Create();
d__.MoveNext();
return d__.<>t__builder.Task;
}
[CompilerGenerated]
[StructLayout(LayoutKind.Sequential)]
private struct <SimpleBodyAsync>d__0 : <>t__IStateMachine {
private int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
public Action <>t__MoveNextDelegate;
public void MoveNext() {
try {
if (this.<>1__state == -1) return;
Console.WriteLine("Hello, Async World!");
}
catch (Exception e) {
this.<>1__state = -1;
this.<>t__builder.SetException(e);
return;
}
this.<>1__state = -1;
this.<>t__builder.SetResult();
}
...
}
当考虑要调用的异步方法的成本时,请牢记这一样板。 MoveNext 方法中的 try/catch 块可能会阻止样板被实时 (JIT) 编译器内嵌,所以至少我们现在拥有了方法调用成本而在同步情况下则可能不会(有如此小的方法主体)。 我们有多个对 Framework 例程(如 SetResult)的调用, 并且我们在状态机类型上有多个写入字段。 当然,我们需要针对 Console.WriteLine 的成本权衡这一切,因为它可能会主宰所有其他涉及到的成本(它需要锁定,它执行 I/O 等)。 此外,请注意基础结构为您做出的优化。 例如,状态机类型就是一个结构。 如果此方法因为正在等待尚未完成的实例(在这简单的方法中,实例永远不会完成)而需要暂停其执行,该结构只会被封装到堆。 因此,异步方法的样板将不会产生任何分配。 编译器和运行时共同努力,以尽量减少基础结构中涉及的分配数量。
了解何时不使用异步
.NET Framework 应用多种优化,尝试为异步方法生成高效的异步实现。 但是,开发人员通常具备领域知识,然后产生一些优化,如果考虑所追求的通用性,这些优化如果由编译器和运行来自动应用,是有风险和不明智的。 明确这一点后,实际上会有利于开发人员,避免在一小部分用例中使用异步方法,特别是对于将以一种更精准的方式访问的库方法。 一般来说,如果已知方法实际上可以同步完成(因为方法依赖的数据是已经可用的),尤其如此。
设计异步方法时,Framework 开发人员往往花费大量的时间优化对象分配。 这是因为分配是异步方法基础结构中可能出现的最大性能成本之一。 分配一个对象的行为通常相当便宜。 分配对象类似于向购物车中放入商品,此时将商品放入车内不需要花费太多的精力;而当您实际结账时则需要拿出钱包并投入大量的资源。 虽然分配通常开销很低,但谈到应用程序的性能时,产生的垃圾收集实在令人不爽。 垃圾收集行为包括扫描当前已分配的部分对象和查找不再被引用的对象。 分配的对象越多,执行这种标记的时间就越长。 此外,分配的对象越大并且分配的数量越多,垃圾收集发生的频率就越大。 通过这种方式,分配会对系统产生全局影响:异步方法生成的垃圾越多,整个程序的运行速度就越慢,即使异步方法自身的微基准不显示明显的成本。
对于实际产生执行的异步方法(由于等待尚未完成的对象),异步方法基础结构需要分配一个 Task 对象以从方法返回,而此 Task 对象将用作这一特殊调用的唯一引用。 然而,许多异步方法调用无需产生即可完成。 在这种情况下,异步方法基础结构可能返回一个已缓存并完成的 Task,而该 Task 可以反复使用以避免分配不必要的 Task。 但是,能这么做的情况不多,例如当异步方法是一个非泛型 Task、Task<Boolean> 或 Task<TResult>(其中 TResult 是引用类型)时,异步方法的结果为空。 虽然这一组合在未来可能会扩大,但如果您具备正在实施的操作的领域知识,您可以做的更好。
考虑实现类似 MemoryStream 的类型。 MemoryStream 由 Stream 派生而来,因此可以覆盖 Stream 的新的 .NET 4.5 ReadAsync、WriteAsync 和 FlushAsync 方法,从而优化 MemoryStream 的实现。 由于读取操作与内存中的缓冲区刚好背道而驰,因此如果 ReadAsync 同步运行,仅仅复制内存就可获得更好的性能。 使用异步方法实现这一操作应该类似于如下所示:
C#复制
public override async Task<int> ReadAsync(
byte [] buffer, int offset, int count,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return this.Read(buffer, offset, count);
}
非常简单。 由于 Read 是一个同步调用,并且此方法中没有会产生控制的等待,因此 ReadAsync 的所有调用实际上会同步完成。 现在,让我们考虑流的一个标准使用模式,例如复制操作:
C#复制
byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) {
await source.WriteAsync(buffer, 0, numRead);
}
这里要注意的是,源流上针对这一特定调用系列的 ReadAsync 总是与同一计数参数(缓冲区的长度)同时调用,因此极有可能返回值(读取的字节数)也将重复。 除了某些极少数的情况外,ReadAsync 的异步方法实现不可能使用缓存的 Task 获得返回值,但是您可以。
请考虑如图 2 所示重写此方法。 利用此方法的一些特点及其常见的使用方案,我们现在能够用不寻常的方式优化分配,而不期待底层基础结构实现此优化。 因此,每次调用 ReadAsync 都会检索与之前调用时数量相同的字节,我们可以通过返回与之前调用返回的相同的 Task 来彻底避免 ReadAsync 方法产生的调用开销。 而对于像这样的低级别操作,我们期望能够快速并重复调用,这样的优化可以产生显著的变化,特别是在垃圾收集数量方面。
图 2 优化任务分配
C#复制
private Task<int> m_lastTask;
public override Task<int> ReadAsync(
byte [] buffer, int offset, int count,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested) {
var tcs = new TaskCompletionSource<int>();
tcs.SetCanceled();
return tcs.Task;
}
try {
int numRead = this.Read(buffer, offset, count);
return m_lastTask != null && numRead == m_lastTask.Result ?
m_lastTask : (m_lastTask = Task.FromResult(numRead));
}
catch(Exception e) {
var tcs = new TaskCompletionSource<int>();
tcs.SetException(e);
return tcs.Task;
}
}
当方案中规定了缓存时,可能会执行相关的优化以避免任务分配。 考虑一种旨在下载特定网页内容并缓存其成功下载的内容以备将来访问的方法。 这种功能可能使用异步方法编写,如下所示(使用 .NET 4.5 中新的 System.Net.Http.dll 库):
C#复制
private static ConcurrentDictionary<string,string> s_urlToContents;
public static async Task<string> GetContentsAsync(string url)
{
string contents;
if (!s_urlToContents.TryGetValue(url, out contents))
{
var response = await new HttpClient().GetAsync(url);
contents = response.EnsureSuccessStatusCode().Content.ReadAsString();
s_urlToContents.TryAdd(url, contents);
}
return contents;
}
这是一个简单的实现。 而对于无法从缓存得到满足的 GetContentsAsync 调用,为表示此次下载而构建的新 Task<string> 的开销与网络成本相比是可以忽略不计的。 但是,如果内容可以从缓存得到满足,它可能就是不可忽略的成本,对象分配只是包装并退回已经可用的数据。
要避免此成本(为了满足性能目标而这样做),您可以如图 3 所示重写此方法。 我们现在有两种方法:同步公共方法和公共方法委托的异步私有方法。 字典现在缓存生成的任务而不是自身的内容,所以要未来尝试下载已经成功下载的页面,可以通过访问字典以返回已存在的任务而得到实现。 在内部,我们也利用 Task 上的 ContinueWith 方法,它允许我们在任务完成后将其储存到字典中 — 但仅限下载成功的情况下。 当然,此代码较为复杂并需要花费更多的心思编写和维护,而所有的性能优化都一样,除非性能测试证明即使复杂却会产生重大且必要的影响,否则无需花费时间编写这样的代码。 这种优化是否发挥作用实际上要取决于使用方案。 您会希望引入一组代表常见使用模式的测试,并分析这些测试以判断这种复杂能否以一种有意义的方式提高代码的性能。
图 3 手动缓存任务
C#复制
private static ConcurrentDictionary<string,Task<string>> s_urlToContents;
public static Task<string> GetContentsAsync(string url) {
Task<string> contents;
if (!s_urlToContents.TryGetValue(url, out contents)) {
contents = GetContentsAsync(url);
contents.ContinueWith(delegate {
s_urlToContents.TryAdd(url, contents);
}, CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion |
TaskContinuatOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
return contents;
}
private static async Task<string> GetContentsAsync(string url) {
var response = await new HttpClient().GetAsync(url);
return response.EnsureSuccessStatusCode().Content.ReadAsString();
}
另一个需要考虑的与任务相关的优化是,您是否需要从异步方法返回的任务。 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 请求。 但是大多数的库并不受此约束。 因此,这种自动封送经常是完全不必要的成本。 再次以前文所示的将数据从一个流复制到另一个流的代码为例:
C#复制
byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) {
await source.WriteAsync(buffer, 0, numRead);
}
如果此复制操作是从 UI 线程调用,那么每一个等待的读、写操作都将强制完成回 UI 线程。 对于 1 MB 的源数据和异步完成读、写的流(大多数的流都是这样),这意味着从后台到 UI 线程有多达 500 个跃点。 为解决这一问题,Task 和 Task<TResult> 类型提供了 ConfigureAwait 方法。 ConfigureAwait 接受一个控制此封送行为的 Boolean continueOnCapturedContext 参数。 如果使用默认的 true,等待将在捕获的 SynchronizationContext 上自动完成。 但是如果使用 false,SynchronizationContext 将被忽略并且 Framework 将尝试在之前异步操作完成的位置继续执行。 将此操作合并到流复制代码会产生下列更高效的版本:
C#复制
byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await
source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) {
await source.WriteAsync(buffer, 0, numRead).ConfigureAwait(false);
}
对库开发人员来说,这种性能影响自身已足够保证一直使用 ConfigureAwait,除非在极少数情况下,库对其环境具有领域知识并且不需要访问正确的环境以执行方法的主体。
除性能之外还有一个在库代码中使用 ConfigureAwait 的原因。 假设上述的代码没有 ConfigureAwait,并处于一个从 WPF UI 线程调用的名为 CopyStreamToStreamAsync 的方法中,如下所示:
C#复制
private void button1_Click(object sender, EventArgs args) {
Stream src = …, dst = …;
Task t = CopyStreamToStreamAsync(src, dst);
t.Wait(); // deadlock!
}
在此,开发人员应该已经写好 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 编译器有时会提升一些不必要的局部变量。 以下面的代码段为例:
C#复制
public static async Task FooAsync() {
var dto = DateTimeOffset.Now;
var dt = dto.DateTime;
await Task.Yield();
Console.WriteLine(dt);
}
在等待点之后根本就不读取 dto 变量,因此在等待之前写入的值在通过等待后不需要保留。 但是,编译器生成的用来存储局部变量的状态机类型仍然包含 dto 引用,如图 4 所示。
图 4 局部变量提升
C#复制
[StructLayout(LayoutKind.Sequential), CompilerGenerated]
private struct <FooAsync>d__0 : <>t__IStateMachine {
private int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
public Action <>t__MoveNextDelegate;
public DateTimeOffset <dto>5__1;
public DateTime <dt>5__2;
private object <>t__stack;
private object <>t__awaiter;
public void MoveNext();
[DebuggerHidden]
public void <>t__SetMoveNextDelegate(Action param0);
}
这稍微增大了真正必要的堆对象。 如果您发现垃圾收集发生的频率超过了预期,请考虑您是否真的需要所有这些已经编码到异步方法的临时变量。 要避免状态机类出现过多字段,请按以下示例重写:
C#复制
public static async Task FooAsync() {
var dt = DateTimeOffset.Now.DateTime;
await Task.Yield();
Console.WriteLine(dt);
}
此外,.NET 垃圾收集器 (GC) 是分代收集器,也就是说,它将对象组分成小组,称为一代:从更高层次来说,新对象分配在第 0 代,然后在收集期间存续的所有对象则提高一代(.NET GC 目前使用第 0、1 和 2 代)。 这样一来,GC 会从已知的对象空间的子集频繁收集,从而能够加速收集过程。 它所依据的原理是新分配的对象很快也会消失,而已经出现很长时间的对象则将继续出现很长时间。 这就是说,如果一个对象在第 0 代存续,它最后可能会出现一段时间,但却因为这一额外时间而继续对系统施加压力。 这也意味着我们确实要确保不再需要的对象立即可以进行垃圾收集。
借助上文提及的提升,局部变量将提升到在异步方法执行期间仍然保留在根级的类的字段中(只要等待的对象能正确维护对委托的引用,以在等待完成后立即调用)。 在同步方法中,JIT 编译器能够跟踪局部变量何时不能再访问,并且此时可以帮助 GC 忽视这些作为根的变量,从而使得不再被引用到其他任何地方的引用对象可以进行垃圾收集。 但是,在异步方法中,这些局部变量仍然可以引用,这意味着如果它们真正成为局部变量,则这些引用的对象就能存续更长时间。 如果您发现对象使用之后仍然保持有效,请考虑在您使用以后归零引用这些对象的局部变量。 再次强调,只有在您发现它确实导致性能问题时才执行这一操作,否则将使代码不必要地复杂化。 此外,C# 和 Visual Basic 编译器可以会在最终版本中做出更新,或者未来可代替开发人员处理更多的此类方案,所以今天编写的这类代码将来很可能会被淘汰。
避免复杂性
C# 和 Visual Basic 编译器在允许您使用 await 方面特别令人印象深刻:几乎任何地方都可以使用。 Await 表达式可能用作更大表达式的一部分,从而允许您等待可能有其他任何返回值的表达式中的 Task<TResult>。 例如,以下代码将返回三个任务结果之和:
C#复制
public static async Task<int> SumAsync(
Task<int> a, Task<int> b, Task<int> c)
{
return Sum(await a, await b, await c);
}
private static int Sum(int a, int b, int c)
{
return a + b + c;
}
C# 编译器允许您将“await b”表达式用作 Sum 函数的参数。 但是,此处有多个等待结果以参数形式传递到 Sum,并且由于计算顺序规则和异步在编译器中的实现方式,这个特定的示例需要编译器“分散”前两个等待的临时结果。 正如您之前看到的那样,局部变量通过提升到状态机类的字段中而在等待点之间保持不变。 但是,在这个示例中,值是 CLR 计算堆栈上的类,这些值不会提升到状态机而是分散到单个的临时对象,然后再被状态机引用。 当您在首个任务上完成等待并转而等待第二个时,编译器会生成封送首个结果的代码并将封送的对象存储到状态机上的 <>t__stack 字段中。 当您在第二个任务上完成等待并转而等待第三个时,编译器会生成从前两个值中创建 Tuple<int,int> 的代码,并将此元祖存储到相同的 <>__stack 字段中。 这些都说明根据您编写的代码的不同,最终可能会得到非常不同的分配模式。 请考虑改用以下方式编写 SumAsync:
C#复制
public static async Task<int> SumAsync(
Task<int> a, Task<int> b, Task<int> c)
{
int ra = await a;
int rb = await b;
int rc = await c;
return Sum(ra, rb, rc);
}
这样改变之后,编译器现在会向状态机类发出另外三个字段以存储 ra、rb 和 rc,并且不会发生分散。 因此,您不得不进行权衡:选择分配较少的较大状态机,还是选择分配较多的较小状态机。 在分散情况下,分配的内存总量会更大,因为每个分配的对象都会有自己的内存开销,但是最终的性能测试可能会显示它会好得多。 通常,如前所述,除非您已经发现分配是导致麻烦的原因,否则您不应该考虑这些微优化操作,但无论如何,它有利于了解这些分配来自何处。
当然,毋庸置疑之前的示例中有一个更大的成本,您应该有所了解并积极思考。 直到三个等待都已完成之后代码才能够调用 Sum,并且在等待之间不会进行任何工作。 产生的每一个等待都需要大量的工作,因此需要处理的等待越少越好。 然后您应当立即使用 Task.WhenAll 等待所有任务而将所有这三个等待合并到一个:
C#复制
public static async Task<int> SumAsync(
Task<int> a, Task<int> b, Task<int> c)
{
int [] results = await Task.WhenAll(a, b, c);
return Sum(results[0], results[1], results[2]);
}
Task.WhenAll 方法在此返回的 Task<TResult[]> 在所有提供的任务完成之后才完成,这样做的效率也远远高于单独等待每一个任务。 同时它还收集每个任务的结果并存储到数组。 如果您想要避免使用此数组,可以强制绑定到适用于 Task 而不是 Task<TResult> 的非泛型 WhenAll 方法。 对于最终性能,您也可以采用混合方法,首先检查是否所有的任务都已成功完成,如果是,请独立获得它们的结果 — 如果没有,请等待没有完成的任务的 WhenAll。 这可以避免调用 WhenAll 时涉及不必要的分配,例如分配需要传送到方法的参数数组。 并且,如前所述,我们也希望这个库函数能抑制环境封送。 图 5 中显示了此类解决方案。
图 5 应用多项优化
C#复制
public static Task<int> SumAsync(
Task<int> a, Task<int> b, Task<int> c)
{
return (a.Status == TaskStatus.RanToCompletion &&
b.Status == TaskStatus.RanToCompletion &&
c.Status == TaskStatus.RanToCompletion) ?
Task.FromResult(Sum(a.Result, b.Result, c.Result)) :
SumAsyncInternal(a, b, c);
}
private static async Task<int> SumAsyncInternal(
Task<int> a, Task<int> b, Task<int> c)
{
await Task.WhenAll((Task)a, b, c).ConfigureAwait(false);
return Sum(a.Result, b.Result, c.Result);
}
异步和性能
异步方法是一个功能强大的高效工具,使您能够更轻松编写可伸缩和响应更快的库和应用程序。 请牢记一点,异步不是对单个操作的性能优化。 采用同步操作并使其异步化必然会降低该操作的性能,因为它仍然需要完成同步操作的所有工作,只不过现在会有额外的限制和注意事项。 您关注异步的一个原因是其总体性能:如果您采用异步方法编写所有内容,整个系统的执行效果如何。这样您可以仅消耗执行需要的有价值的资源,重叠 I/O 并实现更好的系统利用率。 .NET Framework 提供的异步方法实现已经进行了优化,并且最终常常比使用现有模式和更多代码精心编写的异步实现能够提供同样优秀甚至更好的性能。 从现在开始,无论您何时准备在 .NET Framework 中开发异步代码,异步方法都是您的首选工具。 并且,作为一个开发人员,了解 Framework 代替您在这些异步方法中所作的一切对您非常有益,这样可以确保获得尽可能好的最终结果。