C#异步编程:您如何处理性能?

最近,我们已经讨论过在C#中进行编程时是否覆盖Equals和GetHashCode。 今天,我们将处理异步方法的性能参数。 立即加入!



在msdn博客的最后两篇文章中,我们介绍了C#中异步方法内部结构以及C#编译器提供的用于控制异步方法行为的扩展点

根据第一篇文章中的信息,编译器执行许多转换,以使异步编程尽可能类似于同步。 为此,他创建了状态机的实例,将其传递给异步方法的构建器,该方法调用任务的等待者对象,等等。当然,这种逻辑有代价,但是要花多少钱呢?

直到TPL库出现,异步操作才被大量使用,因此成本并不高。 但是今天,即使是一个相对简单的应用程序也可以每秒执行数百甚至数千个异步操作。 TPL并行任务库是在考虑到此类工作负载的情况下创建的,但是这里没有魔术,您必须付出一切。

为了估算异步方法的成本,我们将使用第一篇文章中经过稍微修改的示例。

public class StockPrices { private const int Count = 100; private List<(string name, decimal price)> _stockPricesCache; // Async version public async Task<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); } // Sync version that calls async init public decimal GetStockPriceFor(string companyId) { InitializeMapIfNeededAsync().GetAwaiter().GetResult(); return DoGetPriceFromCache(companyId); } // Purely sync version public decimal GetPriceFromCacheFor(string companyId) { InitializeMapIfNeeded(); return DoGetPriceFromCache(companyId); } private decimal DoGetPriceFromCache(string name) { foreach (var kvp in _stockPricesCache) { if (kvp.name == name) { return kvp.price; } } throw new InvalidOperationException($"Can't find price for '{name}'."); } [MethodImpl(MethodImplOptions.NoInlining)] private void InitializeMapIfNeeded() { // Similar initialization logic. } private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { return; } await Task.Delay(42); // Getting the stock prices from the external source. // Generate 1000 items to make cache hit somewhat expensive _stockPricesCache = Enumerable.Range(1, Count) .Select(n => (name: n.ToString(), price: (decimal)n)) .ToList(); _stockPricesCache.Add((name: "MSFT", price: 42)); } } 

StockPrices类从外部来源StockPrices股票价格,并允许您通过API请求价格。 与第一篇文章中的示例的主要区别是从字典到价格列表的过渡。 为了估算与同步方法相比各种异步方法的成本,操作本身必须完成一定的工作,在我们的案例中,这是对股价的线性搜索。

GetPricesFromCache方法GetPricesFromCache故意围绕一个简单的循环构建的,以避免资源分配。

同步方法和基于任务的异步方法的比较


在第一个性能测试中,我们比较了调用异步初始化方法( GetStockPriceForAsync )的异步方法,调用异步初始化方法( GetStockPriceFor )的同步方法以及调用同步初始化方法的同步方法。

 private readonly StockPrices _stockPrices = new StockPrices(); public SyncVsAsyncBenchmark() { // Warming up the cache _stockPrices.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } [Benchmark] public decimal GetPricesDirectlyFromCache() { return _stockPrices.GetPriceFromCacheFor("MSFT"); } [Benchmark(Baseline = true)] public decimal GetStockPriceFor() { return _stockPrices.GetStockPriceFor("MSFT"); } [Benchmark] public decimal GetStockPriceForAsync() { return _stockPrices.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } 

结果如下所示:



在这个阶段,我们已经收到了非常有趣的数据:

  • 异步方法非常快。 GetPricesForAsync在此测试中同步运行,比纯同步方法慢大约15%(*)。
  • 调用异步InitializeMapIfNeededAsync方法的同步GetPricesFor方法的成本更低,但最令人惊讶的是,它根本不分配资源(在上表的Allocated列中, GetPricesDirectlyFromCacheGetStockPriceFor成本均为0)。

(*)当然,不能说在所有可能的情况下同步执行异步方法的成本都是15%。 该值直接取决于该方法执行的工作量。 纯调用异步方法(不执行任何操作)和同步方法(不执行任何操作)的开销之间的差异将很大。 该比较测试的思想是表明异步方法的成本相对较低,该方法执行的工作量较小。

调用InitializeMapIfNeededAsync ,资源根本没有分配吗? 在本系列的第一篇文章中,我提到异步方法应在托管标头中分配至少一个对象-任务实例本身。 让我们更详细地讨论这一点。

优化#1:尽可能缓存任务实例


上面的问题的答案非常简单: AsyncMethodBuilder 对每个成功完成的异步操作使用任务的一个实例Task返回的异步方法在SetResult方法中使用具有以下逻辑的AsyncMethodBuilder

 // AsyncMethodBuilder.cs from mscorlib public void SetResult() { // Ie the resulting task for all successfully completed // methods is the same -- s_cachedCompleted. m_builder.SetResult(s_cachedCompleted); } 

仅对成功完成的异步方法调用SetResult方法,并且可以自由地一起使用每个基于Task的方法成功结果 。 我们甚至可以通过以下测试来跟踪这种行为:

 [Test] public void AsyncVoidBuilderCachesResultingTask() { var t1 = Foo(); var t2 = Foo(); Assert.AreSame(t1, t2); async Task Foo() { } } 

但这不是唯一可能的优化。 AsyncTaskMethodBuilder<T>以类似的方式优化工作:缓存任务Task<bool>和其他一些简单类型的任务。 例如,它缓存一组整数类型的所有默认值,并对Task<int>使用特殊的缓存,放置范围[-1; 9](有关更多详细信息,请参见AsyncTaskMethodBuilder<T>.GetTaskForResult() )。

以下测试证实了这一点:

 [Test] public void AsyncTaskBuilderCachesResultingTask() { // These values are cached Assert.AreSame(Foo(-1), Foo(-1)); Assert.AreSame(Foo(8), Foo(8)); // But these are not Assert.AreNotSame(Foo(9), Foo(9)); Assert.AreNotSame(Foo(int.MaxValue), Foo(int.MaxValue)); async Task<int> Foo(int n) => n; } 

不要过度依赖这种行为 ,但是总是很高兴认识到语言和平台的创建者正在尽一切可能以所有可用的方式提高生产率。 任务缓存是一种流行的优化方法,也用于其他领域。 例如, corefx存储库中Socket的新实现广泛使用了此方法,并在可能的情况下应用了缓存的任务

优化#2:使用ValueTask


上述优化方法仅在少数情况下有效。 因此,代替它,我们可以使用ValueTask<T> (**),这是一种类似于任务的特殊类型的值。 如果该方法同步运行,它将不会分配资源。

ValueTask<T>TTask<T>的可区分组合:如果“ value-task”完成,则将使用基值。 如果基本分配尚未用尽,则将为该任务分配资源。

同步执行操作时,此特殊类型有助于防止过多的堆置备。 要使用ValueTask<T> ,您需要更改GetStockPriceForAsync的返回类型:而不是Task<decimal>应指定ValueTask<decimal>

 public async ValueTask<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); } 

现在,我们可以使用其他比较测试来评估差异:

 [Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); } 



如您所见,使用ValueTask的版本仅比使用Task的版本快一点。 主要区别是防止堆分配。 稍后,我们将讨论这种过渡的可行性,但在此之前,我想谈一谈棘手的优化。

优化之三:在通用路径中放弃异步方法


如果您经常使用某种异步方法并希望进一步降低成本,建议您进行以下优化:删除async修饰符,然后检查方法内部任务的状态并同步执行整个操作,完全放弃异步方法。

看起来复杂吗? 考虑一个例子。

 public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var task = InitializeMapIfNeededAsync(); // Optimizing for acommon case: no async machinery involved. if (task.IsCompleted) { return new ValueTask<decimal>(DoGetPriceFromCache(companyId)); } return DoGetStockPricesForAsync(task, companyId); async ValueTask<decimal> DoGetStockPricesForAsync(Task initializeTask, string localCompanyId) { await initializeTask; return DoGetPriceFromCache(localCompanyId); } } 

在这种情况下, GetStockPriceWithValueTaskAsync_Optimized方法中未使用async修饰符,因此当它从InitializeMapIfNeededAsync方法接收任务时,它将检查其执行状态。 如果任务完成,则该方法仅使用DoGetPriceFromCache立即获取结果。 如果初始化任务仍在进行中,则该方法将调用本地函数并等待结果。

使用局部函数不是唯一的方法,而是最简单的方法之一。 但是有一个警告。 在最自然的实现过程中,局部函数将接收外部状态(局部变量和参数):

 public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized2(string companyId) { // Oops! This will lead to a closure allocation at the beginning of the method! var task = InitializeMapIfNeededAsync(); // Optimizing for acommon case: no async machinery involved. if (task.IsCompleted) { return new ValueTask<decimal>(DoGetPriceFromCache(companyId)); } return DoGetStockPricesForAsync(); async ValueTask<decimal> DoGetStockPricesForAsync() { await task; return DoGetPriceFromCache(companyId); } } 

但是,不幸的是,由于编译器错误 ,即使该方法在公共路径中执行此代码也将生成一个关闭。 这是从内部看这种方法的样子:

 public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var closure = new __DisplayClass0_0() { __this = this, companyId = companyId, task = InitializeMapIfNeededAsync() }; if (closure.task.IsCompleted) { return ... } // The rest of the code } 

正如在“ 剖析C#中的局部函数 ”一文中讨论的那样,编译器对特定区域中的所有局部变量和参数使用一个通用的闭包实例。 因此,这种代码生成具有某种意义,但是这使得分配堆的整个工作变得毫无用处。

提示 。 这样的优化是非常隐蔽的事情。 好处是微不足道的,即使您编写了正确的原始本地函数,也可能会意外地获得导致分配堆的外部状态。 如果使用肯定会在代码的已加载部分中使用的方法的常用库(例如BCL),您仍然可以诉诸优化。

与等待任务相关的费用


目前,我们仅考虑了一种特定情况:同步运行的异步方法的开销。 这是有目的的。 异步方法越小,其总体性能成本就越明显。 通常,更详细的异步方法将同步运行并执行较小的工作量。 而且我们通常会更频繁地致电给他们。

但是,当方法“等待”完成未完成的任务时,我们必须意识到异步机制的成本。 为了估算这些成本,我们将对InitializeMapIfNeededAsync进行更改,即使在初始化缓存时也将调用Task.Yield()

 private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { await Task.Yield(); return; } // Old initialization logic } 

我们在基准测试包中添加了以下方法进行比较测试:

 [Benchmark] public decimal GetStockPriceFor_Await() { return _stockPricesThatYield.GetStockPriceFor("MSFT"); } [Benchmark] public decimal GetStockPriceForAsync_Await() { return _stockPricesThatYield.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } [Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); } 



如您所见,在速度和内存使用方面,两者之间的差异是显而易见的。 简要解释结果。

  • 每个未完成任务的等待操作大约需要4微秒,并为每个调用分配近300个字节(**)。 这就是为什么GetStockPriceFor运行速度几乎是GetStockPriceForAsync的两倍,并且分配的内存更少。
  • 当此方法不同步执行时,基于ValueTask的异步方法所需的时间要比Task的变体长一些。 与基于Task <T>的方法的状态机相比,基于ValueTask <T>的方法的状态机应存储更多的数据。

(**)它取决于平台(x64或x86)以及异步方法的许多局部变量和参数。

异步方法性能101


  • 如果异步方法同步运行,那么开销将很小。
  • 如果异步方法是同步执行的,则会发生以下内存开销:对于异步Task方法,没有开销,对于异步Task <T>方法,每个操作的溢出量为88字节(对于x64平台)。
  • ValueTask <T>消除了同步执行的异步方法的上述开销。
  • 当基于ValueTask <T>的异步方法被同步执行时,它所花费的时间比使用Task <T>的方法要少一些,否则在支持第二个选项上会有细微的差异。
  • 等待完成未完成任务的异步方法的性能开销要高得多(对于x64平台,每个操作大约300个字节)。

当然,测量是我们的一切。 如果看到异步操作导致性能问题,则可以从Task<T>切换到ValueTask<T> ,缓存任务,或在可能的情况下使整个执行路径同步。 您也可以尝试聚合异步操作。 通常,这将有助于提高性能,简化调试和代码分析。 并非每小段代码都应该是异步的。

Source: https://habr.com/ru/post/zh-CN420627/


All Articles