C#篇:C#中异步方法的性能特征

您所在的位置:网站首页 asynctaskmethodbuilder C#篇:C#中异步方法的性能特征

C#篇:C#中异步方法的性能特征

#C#篇:C#中异步方法的性能特征 | 来源: 网络整理| 查看: 265

本文翻译自

https://blogs.msdn.microsoft.com/seteplia/2018/01/25/the-performance-characteristics-of-async-methods/ 在前两篇博客文章中,我们讨论了c#中异步方法的内部原理,然后讨论了c#编译器为调整异步方法的行为提供的扩展点。今天我们将探讨异步方法的性能特征。 正如您在本系列的第一篇文章中已经知道的,编译器做了很多转换,使异步编程体验与同步编程非常相似。但要做到这一点,编译器创建一个状态机实例,将它传递给一个异步方法构建器,调用task awaiter等。显然,所有这些逻辑都有自己的代价,但我们要付出多少代价呢? 在tpl之前,异步操作通常是相当粗粒度的,因此异步操作的开销可能可以忽略不计。但今天,即使是相对简单的应用程序每秒也可能有数百甚至数千次异步操作。TPL的设计考虑到了这种工作负载,但它并不神奇,它有一些开销。 要度量异步方法的开销,将使用我们在第一篇博客文章中使用的稍微修改过的示例。

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162public class StockPrices{ private const int Count = 100; private List _stockPricesCache; // Async version public async Task 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类使用来自外部源的股票价格填充缓存,并提供一个API来查询它。与第一篇文章示例的主要区别在于从字典切换到价格列表。为了度量不同形式的异步方法与同步方法的开销,操作本身至少应该做一些工作,并对股票价格模型进行线性搜索。 GetPricesFromCache故意使用普通循环构建,以避免任何分配。

同步版本对比 基于任务的异步版本

在第一个基准测试中,我们比较了调用异步初始化方法的异步方法(GetStockPriceForAsync)、调用异步初始化方法的同步方法(GetStockPriceFor)和调用同步初始化方法的同步方法。

12345678910111213141516171819202122232425private 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();}

The results are:

Method

Mean

Scaled

Gen 0

Allocated

GetPricesDirectlyFromCache

2.177 us

0.96

-

0 B

GetStockPriceFor

2.268 us

1.00

-

0 B

GetStockPriceForAsync

2.523 us

1.11

0.0267

88 B

这些数据已经非常有趣: 异步方法相当快。GetPricesForAsync在这个基准测试中同步完成,它比纯同步方法慢15%。 (调用异步InitializeMapIfNeededAsync方法的同步GetPricesFor方法具有更低的开销,但最令人惊讶的是它根本没有分配(上表中分配的列对于GetPricesDirectlyFromCache和GetStockPriceFor都是0)。 当然,对于所有可能的情况,您不能说异步方法同步运行时异步机制的开销是15%。百分比与方法所做的工作量非常相关。测量异步方法(不做任何事)和同步方法(不做任何事)的纯方法调用开销将显示出巨大的差异。这个基准测试的目的是表明,做相对少量工作的异步方法的开销是适度的。 调用InitializeMapIfNeededAsync怎么可能完全没有分配呢?我在本系列的第一篇文章中已经提到,异步方法必须在托管头中分配至少一个对象—任务实例本身。让我们来探索这方面。

优化 1。如果可能,缓存任务实例

前一个问题的答案非常简单:AsyncMethodBuilder为每个成功完成的异步操作使用一个任务实例。返回任务的async方法依赖于AsyncMethodBuilder,它在SetResult方法中具有以下逻辑:

12345678// AsyncMethodBuilder.cs from mscorlibpublic void SetResult(){ // I.e. the resulting task for all successfully completed // methods is the same -- s_cachedCompleted. m_builder.SetResult(s_cachedCompleted);}

SetResult方法只针对成功完成任务的异步方法调用,并且可以轻松共享每个基于任务的方法的成功结果。我们甚至可以通过下面的测试来观察这种行为:

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

但这不是唯一可能发生的优化。AsyncTaskMethodBuilder做了类似的优化:它缓存任务和其他一些基本类型的任务。例如,它缓存了一系列整数类型的所有默认值,并为Task提供了一个特殊的缓存,用于[-1;9)(更多信息请参见AsyncTaskMethodBuilder.gettaskforresult())。 下面的测试证明确实如此:

12345678910111213[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 Foo(int n) => n;}

您不应该过分依赖这种行为,但是您应该知道,语言和框架的作者尽了最大的努力以各种可能的方式微调性能。缓存任务是一种常见的优化模式,也用于其他地方。例如,corefx repo中的新套接字实现严重依赖于这种优化,并尽可能使用缓存的任务。

2:使用ValueTask

上面提到的优化只在少数情况下有效。我们可以使用ValueTask(**)代替依赖它:一个特殊的类似于任务的值类型,如果方法同步完成,它将不会分配。 ValueTask实际上是T和Task的区别结合:如果完成了“value Task”,那么将使用底层值。如果底层承诺尚未完成,则将分配任务。 当操作同步完成时,这种特殊类型有助于避免不必要的堆分配。要使用ValueTask,我们只需要将GetStockPriceForAsync的返回类型从Task



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3