微软async开发者文摘:异步编程 - 异步性能:了解 Async 和 Await 的成本

异步编程长期以来一直是只有最熟练和最受虐的开发人员的领域——那些有时间、倾向和心理能力来推理非线性控制流的一个又一个回调的回调。借助 Microsoft .NET Framework 4.5,C# 和 Visual Basic 为我们其他人提供了异步性,因此普通人几乎可以像编写同步方法一样轻松地编写异步方法。没有更多的回调。不再将代码从一个同步上下文显式编组到另一个同步上下文。不再担心结果或异常的流动。不再有扭曲现有语言功能以简化异步开发的技巧。简而言之,没有更多的麻烦。

当然,虽然现在开始编写异步方法很容易(请参阅本期MSDN 杂志中Eric LippertMads Torgersen的文章),要真正做到这一点仍然需要了解幕后发生的事情。每当一种语言或框架提高了开发人员可以编程的抽象级别时,它总会封装隐藏的性能成本。在很多情况下,这样的成本可以忽略不计,并且可以而且应该被大量实施大量场景的开发人员忽略。但是,更高级的开发人员仍然应该真正了解存在哪些成本,以便他们可以采取任何必要的步骤来避免这些成本最终变得可见。C# 和 Visual Basic 中的异步方法功能就是这种情况。

在本文中,我将探讨异步方法的来龙去脉,让您深入了解异步方法是如何在幕后实现的,并讨论所涉及的一些更细微的成本。请注意,此信息并不旨在鼓励您将可读代码扭曲成无法维护的东西,所有这些都是以微优化和性能的名义。它只是为您提供可帮助您诊断可能遇到的任何问题的信息,并提供一组工具来帮助您克服此类潜在问题。另请注意,本文基于 .NET Framework 4.5 的预览版本,在最终版本发布之前,具体的实现细节很可能会发生变化。

获得正确的心智模型

几十年来,开发人员一直使用 C#、Visual Basic、F# 和 C++ 等高级语言来开发高效的应用程序。这种经验使这些开发人员了解了各种操作的相关成本,并且这些知识为最佳开发实践提供了信息。例如,对于大多数用例,调用同步方法相对便宜,当编译器能够将被调用者内联到调用站点时更是如此。因此,开发人员学习将代码重构为小的、可维护的方法,通常不需要考虑增加的方法调用数量带来的任何负面影响。这些开发人员对调用方法的含义有一个心智模型。

随着异步方法的引入,需要一种新的心智模型。尽管 C# 和 Visual Basic 语言和编译器能够提供异步方法就像其同步对应物一样的错觉,但实际上并非如此。编译器最终会代表开发人员生成大量代码,这些代码类似于过去开发人员实现异步性必须手动编写和维护的大量样板代码。此外,编译器生成的代码调用 .NET Framework 中的库代码,再次增加了代表开发人员完成的工作。要获得正确的心智模型,然后使用该心智模型做出适当的开发决策,了解编译器为您生成的内容非常重要。

认为矮胖,而不是健谈

使用同步代码时,具有空主体的方法实际上是免费的。这不是异步方法的情况。考虑以下异步方法,它的主体中有一个语句(并且由于缺少等待将最终同步运行):

XML
public static async Task SimpleBodyAsync() {
  Console.WriteLine("Hello, Async World!");
}

中间语言 (IL) 反编译器将在编译后揭示此函数的真实性质,其输出类似于图 1所示的内容一个简单的单行已经扩展为两种方法,其中一种存在于辅助状态机类中。首先,有一个存根方法具有与开发人员编写的相同的基本签名(该方法的名称相同,具有相同的可见性,它接受相同的参数并保留其返回类型),但该存根不t 包含开发人员编写的任何代码。相反,它包含设置样板。设置代码初始化用于表示异步方法的状态机,然后使用对状态机上的辅助 MoveNext 方法的调用来启动它。此状态机类型保存异步方法的状态,如有必要,允许该状态跨异步等待点持久化。它还包含用户编写的方法主体,但以允许将结果和异常提升到返回的任务中的方式进行扭曲;保持方法中的当前位置,以便在等待后可以在该位置恢复执行;等等。

图 1 异步方法样板

XML
[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) 编译器内联,因此至少我们现在有方法调用的成本,在同步情况下我们可能不会(使用这么小的方法体)。我们多次调用框架例程(如 SetResult)。我们对状态机类型的字段进行了多次写入。当然,我们需要权衡所有这些与 Console.WriteLine 的成本,后者可能会主导所有其他涉及的成本(它需要锁,它进行 I/O 等等)。此外,请注意基础设施为您做了一些优化。例如,状态机类型是一个结构体。只有当该方法需要暂停其执行时,该结构才会被装箱到堆中,因为它正在等待尚未完成的实例,并且在这个简单的方法中,它永远不会完成。因此,这种异步方法的样板不会产生任何分配。编译器和运行时共同努力,以最大限度地减少基础设施中涉及的分配数量。

知道何时不使用异步

.NET Framework 尝试为异步方法生成高效的异步实现,应用多项优化。然而,开发人员通常拥有领域知识,无法进行优化,而这些优化对于编译器和运行时自动应用来说是有风险和不明智的,考虑到他们所针对的通用性。考虑到这一点,它实际上可以使开发人员避免在特定的小用例集中使用异步方法,特别是对于将以更细粒度的方式访问的库方法。通常,当知道该方法实际上可能能够同步完成时,就会出现这种情况,因为它所依赖的数据已经可用。

在设计异步方法时,框架开发人员花费了大量时间优化对象分配。这是因为分配代表了异步方法基础结构中可能的最大性能成本之一。分配对象的行为通常非常便宜。分配对象类似于将商品装满您的购物车,因为您无需花费太多精力将商品放入购物车;当您真正结账时,您需要掏出钱包并投入大量资源。虽然分配通常很便宜,但是当涉及到应用程序的性能时,由此产生的垃圾收集可能是一个阻碍。垃圾收集行为涉及扫描当前分配的对象的某些部分并找到不再被引用的对象。分配的对象越多,执行此标记所需的时间就越长。此外,分配的对象越大,分配的对象越多,垃圾收集需要发生的频率就越高。以这种方式,分配对系统具有全局影响:异步方法产生的垃圾越多,整个程序运行的速度就越慢,即使异步方法本身的微基准测试没有显示显着的成本。

对于实际产生执行的异步方法(由于等待尚未完成的对象),异步方法基础结构需要分配一个 Task 对象以从该方法返回,因为该 Task 充当此特定调用的唯一引用。但是,许多异步方法调用可以在不产生任何结果的情况下完成。在这种情况下,异步方法基础结构可能会返回一个缓存的、已经完成的任务,它可以反复使用,以避免分配不必要的任务。但是,它只能在有限的情况下执行此操作,例如当异步方法是非泛型 Task、Task<Boolean> 或 Task<TResult> 时,其中 TResult 是引用类型并且异步方法为空。虽然这个集合在未来可能会扩展,

考虑实现像 MemoryStream 这样的类型。MemoryStream 派生自 Stream,因此可以覆盖 Stream 的新 .NET 4.5 ReadAsync、WriteAsync 和 FlushAsync 方法,为 MemoryStream 的性质提供优化的实现。由于读取操作只是针对内存缓冲区,因此只是内存副本,如果 ReadAsync 同步运行,则性能会更好。用异步方法实现它看起来像下面这样:

XML
public override async Task<int> ReadAsync(
  byte [] buffer, int offset, int count,
  CancellationToken cancellationToken)
{
  cancellationToken.ThrowIfCancellationRequested();
  return this.Read(buffer, offset, count);
}

很容易。并且因为 Read 是一个同步调用,并且因为在这个方法中没有会产生控制的等待,所以 ReadAsync 的所有调用实际上都将同步完成。现在,让我们考虑流的标准使用模式,例如复制操作:

XML
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 检索到的字节数与上次调用 ReadAsync 时相同,我们就可以通过返回上次调用返回的相同 Task 来完全避免 ReadAsync 方法的任何分配开销。对于像这样的低级操作,我们期望非常快并被重复调用,这样的优化可以产生显着的差异,尤其是在发生垃圾收集的数量方面。

图 2 优化任务分配

XML
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 库):

XML
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 方法,该方法允许我们在 Task 完成后将任务存储到字典中——但前提是下载成功。当然,这段代码更复杂,需要更多的思考来编写和维护,因此与任何性能优化一样,在性能测试证明复杂性会产生影响和必要的差异之前,避免花费时间进行优化。这种优化是否会产生影响实际上取决于使用场景。您需要提出一组代表常见使用模式的测试,并使用对这些测试的分析来确定这些复杂性是否以有意义的方式提高了代码的性能。

图 3 手动缓存任务

XML
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 = GetContentsInternalAsync(url);
      contents.ContinueWith(delegate {
        s_urlToContents.TryAdd(url, contents);
      }, CancellationToken.None,
        TaskContinuationOptions.OnlyOnRanToCompletion |
         TaskContinuatOptions.ExecuteSynchronously,
        TaskScheduler.Default);
  }
  return contents;
}
 
private static async Task<string> GetContentsInternalAsync(string url) {
  var response = await new HttpClient().GetAsync(url);
  return response.EnsureSuccessStatusCode().Content.ReadAsString();
}

要考虑的另一个与任务相关的优化是您是否甚至需要从异步方法返回的 Task。C# 和 Visual Basic 都支持创建返回 void 的异步方法,在这种情况下,永远不会为该方法分配 Task。从库公开的异步方法应始终编写为返回 Task 或 Task<TResult>,因为作为库开发人员的您不知道使用者是否希望等待该方法的完成。但是,对于某些内部使用场景,返回空值的异步方法可以有其一席之地。存在返回 void 的异步方法的主要原因是支持现有的事件驱动环境,如 ASP.NET 和 Windows Presentation Foundation (WPF)。它们使实现按钮处理程序变得容易,页面加载事件等通过使用 async 和 await。如果您确实考虑使用 async void 方法,请在异常处理方面非常小心:转义 async void 方法的异常会冒泡到调用 async void 方法时当前的任何 SynchronizationContext 中。

关心上下文

.NET Framework 中有多种“上下文”:LogicalCallContext、SynchronizationContext、HostExecutionContext、SecurityContext、ExecutionContext 等(从数量上看,您可能会期望框架的开发人员在金钱上有动力引入新的上下文,但我保证你不是)。其中一些上下文与异步方法非常相关,不仅在功能上,而且在它们对异步方法性能的影响方面。 

同步上下文SynchronizationContext 在异步方法中扮演着重要的角色。“同步上下文”只是对以特定于给定库或框架的方式编组委托调用的能力的抽象。例如,WPF 提供 DispatcherSynchronizationContext 来表示 Dispatcher 的 UI 线程:将委托发布到此同步上下文会导致该委托排队等待 Dispatcher 在其线程上执行。ASP.NET 提供了一个 AspNetSynchronizationContext,它用于确保作为 ASP.NET 请求处理的一部分发生的异步操作被串行执行并与正确的 HttpContext 状态相关联。等等。总而言之,.NET Framework 中有大约 10 个 SynchronizationContext 的具体实现,一些是公共的,一些是内部的。

当等待 .NET Framework 提供的任务和其他可等待类型时,这些类型的“等待者”(如 TaskAwaiter)在发出等待时捕获当前 SynchronizationContext。等待完成后,如果当前的 SynchronizationContext 被捕获,则表示异步方法剩余部分的延续将发布到该 SynchronizationContext。这样,编写从 UI 线程调用的异步方法的开发人员无需手动将调用编组回 UI 线程以修改 UI 控件:此类编组由框架基础结构自动处理。

不幸的是,这种编组也涉及成本。对于使用 await 来实现他们的控制流的应用程序开发人员来说,这种自动封送处理几乎总是正确的解决方案。然而,图书馆往往是另一回事。应用程序开发人员通常需要这样的封送处理,因为他们的代码关心它运行的上下文,例如能够访问 UI 控件,或者能够访问 HttpContext 以获取正确的 ASP.NET 请求。但是,大多数库不会受到这种限制。结果,这种自动编组通常是完全不必要的成本。再次考虑前面显示的将数据从一个流复制到另一个流的代码:

XML
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 兆字节的源数据和异步完成读写的流(这是大多数),这意味着从后台线程到 UI 线程的跳数超过 500。为了解决这个问题,Task 和 Task<TResult> 类型提供了一个 ConfigureAwait 方法。ConfigureAwait 接受控制此封送处理行为的布尔 continueOnCapturedContext 参数。如果使用默认值 true,则 await 将在捕获的 SynchronizationContext 上自动完成。但是,如果使用 false,则 SynchronizationContext 将被忽略,并且框架将尝试在前一个异步操作完成的地方继续执行。

XML
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 的上述代码位于名为 CopyStreamToStreamAsync 的方法中,该方法是从 WPF UI 线程调用的,如下所示:

XML
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,并且该方法在这些 Post 完成之前无法完成(因为 Post 用于处理该方法的其余部分)。但是这些 Posts 不会完成,因为处理它们的 UI 线程在对 Wait 的调用中被阻塞。这是一个循环依赖,导致死锁。如果使用 ConfigureAwait(false) 写入 CopyStreamToStreamAsync,

执行上下文ExecutionContext 是 .NET Framework 的一个组成部分,但大多数开发人员幸福地不知道它的存在。ExecutionContext 是上下文的祖父,封装了多个其他上下文,如 SecurityContext 和 LogicalCallContext,并表示应该自动跨代码中异步点流动的所有内容。每当您在框架中使用 ThreadPool.QueueUserWorkItem、Task.Run、Delegate.BeginInvoke、Stream.BeginRead、WebClient.DownloadStringAsync 或任何其他异步操作时,如果可能(通过 ExecutionContext.Capture)在幕后捕获 ExecutionContext,以及然后使用捕获的上下文来处理提供的委托(通过 ExecutionContext.Run)。例如,如果调用 ThreadPool.QueueUserWorkItem 的代码当时正在模拟 Windows 身份,将模拟相同的 Windows 身份以运行提供的 WaitCallback 委托。如果调用 Task.Run 的代码首先将数据存储到 LogicalCallContext 中,则可以通过提供的 Action 委托中的 LogicalCallContext 访问相同的数据。ExecutionContext 也流经任务的等待。

在框架中进行了多项优化,以避免在不必要时捕获并在捕获的 ExecutionContext 下运行,因为这样做可能非常昂贵。但是,模拟 Windows 身份或将数据存储到 LogicalCallContext 等操作将阻碍这些优化。避免操作 ExecutionContext 的操作(例如 WindowsIdentity.Impersonate 和 CallContext.LogicalSetData)会在使用异步方法和一般使用异步时获得更好的性能。

摆脱垃圾收集之路

当涉及到局部变量时,异步方法提供了一种很好的错觉。在同步方法中,C# 和 Visual Basic 中的局部变量是基于堆栈的,因此不需要堆分配来存储这些局部变量。但是,在异步方法中,当异步方法在等待点挂起时,该方法的堆栈就会消失。对于在等待恢复后可供方法使用的数据,该数据必须存储在某处。因此,C# 和 Visual Basic 编译器将局部变量“提升”到一个状态机结构中,然后在第一个等待挂起时将其装箱到堆中,以便局部变量可以在等待点之间存活。

在本文前面,我讨论了垃圾收集的成本和频率如何受分配的对象数量影响,而垃圾收集的频率也受分配的对象大小的影响。分配的对象越大,垃圾收集需要运行的频率就越高。因此,在异步方法中,需要提升到堆的局部变量越多,垃圾收集发生的频率就越高。

在撰写本文时,C# 和 Visual Basic 编译器有时会超出真正需要的范围。例如,考虑以下代码片段:

XML
public static async Task FooAsync() {
  var dto = DateTimeOffset.Now;
  var dt  = dto.DateTime;
  await Task.Yield();
  Console.WriteLine(dt);
}

在等待点之后根本不会读取 dto 变量,因此在等待之前写入它的值不需要在等待期间继续存在。但是,编译器生成的用于存储局部变量的状态机类型仍然包含 dto 引用,如图 4所示

图 4 局部提升

XML
[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);
}

这会使该堆对象的大小略微膨胀,超出了真正需要的范围。如果您发现垃圾回收发生的频率比您预期的要高,请查看您是否真的需要所有已编码到异步方法中的临时变量。这个例子可以重写如下以避免状态机类上的额外字段:

XML
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 表达式可以用作更大表达式的一部分,允许您在可能有任何其他值返回表达式的地方等待 Task<TResult> 实例。例如,考虑以下代码,它返回三个任务结果的总和:

XML
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 字段中。当您完成第二个任务的 await 并等待第三个任务时,编译器生成代码,从前两个值创建一个 Tuple<int,int>,将该元组存储到相同的 <>__stack 字段中。这一切都意味着,根据您编写代码的方式,您最终可能会得到非常不同的分配模式。考虑改为按如下方式编写 SumAsync:这一切都意味着,根据您编写代码的方式,您最终可能会得到非常不同的分配模式。考虑改为按如下方式编写 SumAsync:这一切都意味着,根据您编写代码的方式,您最终可能会得到非常不同的分配模式。考虑改为按如下方式编写 SumAsync:

XML
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 一次等待所有任务,将所有这三个等待合并为一个:

XML
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 中涉及的任何分配,例如分配要传递给方法的 params 数组。和,如前所述,我们希望这个库函数也能抑制上下文封送处理。这样的解决方案显示在图5

图 5 应用多项优化

XML
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 中开发异步代码,异步方法都应该是您的首选工具。尽管如此,作为开发人员,了解框架在这些异步方法中代表您所做的一切对您来说是有好处的,这样您就可以确保最终结果尽可能好。NET Framework 从现在开始,异步方法应该是您的首选工具。尽管如此,作为开发人员,了解框架在这些异步方法中代表您所做的一切对您来说是有好处的,这样您就可以确保最终结果尽可能好。NET Framework 从现在开始,异步方法应该是您的首选工具。尽管如此,作为开发人员,了解框架在这些异步方法中代表您所做的一切对您来说是有好处的,这样您就可以确保最终结果尽可能好。


Stephen Toub 是 Microsoft 并行计算平台团队的首席架构师。

感谢以下技术专家对本文的审阅: Joe Hoag、  Eric LippertDanny Shih和 Mads Torgersen


https://docs.microsoft.com/en-us/archive/msdn-magazine/2011/october/asynchronous-programming-async-performance-understanding-the-costs-of-async-and-await


本文出自勇哥的网站《少有人走的路》wwww.skcircle.com,转载请注明出处!讨论可扫码加群:

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

会员中心
搜索
«    2024年4月    »
1234567
891011121314
15161718192021
22232425262728
2930
网站分类
标签列表
最新留言
    热门文章 | 热评文章 | 随机文章
文章归档
友情链接
  • 订阅本站的 RSS 2.0 新闻聚合
  • 扫描加本站机器视觉QQ群,验证答案为:halcon勇哥的机器视觉
  • 点击查阅微信群二维码
  • 扫描加勇哥的非标自动化群,验证答案:C#/C++/VB勇哥的非标自动化群
  • 扫描加站长微信:站长微信:abc496103864
  • 扫描加站长QQ:
  • 扫描赞赏本站:
  • 留言板:

Powered By Z-BlogPHP 1.7.2

Copyright Your skcircle.com Rights Reserved.

鄂ICP备18008319号


站长QQ:496103864 微信:abc496103864