[翻译]C#中异步方法的性能特点
2021-03-28 23:27
标签:init path sse 常见 dir github readonly noi cache 翻译自一篇博文,原文:The performance characteristics of async methods in C# 异步系列 在前两篇中,我们介绍了C#中异步方法的内部原理,以及C#编译器提供的可扩展性从而自定义异步方法的行为。今天我们将探讨异步方法的性能特点。 正如第一篇所述,编译器进行了大量的转换,使异步编程体验非常类似于同步编程。但要做到这一点,编译器会创建一个状态机实例,将其传递给异步方法的builder,然后这个builder会调用task awaiter等。很明显,所有这些逻辑都需要成本,但是需要付出多少呢? 在TPL问世之前,异步操作通常是粗细粒度的,因此异步操作的开销很可能可以忽略不计。但如今,即使是相对简单的应用程序,每秒也可能有数百次或数千次异步操作。TPL在设计时考虑了这样的工作负载,但它没那么神,它会有一些开销。 要度量异步方法的开销,我们将使用第一篇文章中使用过的例子,并加以适当修改: 在第一次基准测试中,我们比较1.调用了异步初始化方法的异步方法( 结果如下: 结果很有趣: 当然,你也不能说异步机制的开销对于所有异步方法同步执行的情况都是15%。这个百分比与方法所做的工作量非常相关。如果测量一个啥都不做的异步方法和啥都不做的同步方法的开销对比就会显示出很大的差异。这个基准测试是想显示执行相对较少量工作的异步方法的开销是适度的。 为什么 前面的问题的答案非常简单: 只有对于每一个成功完成的异步方法, 但这并不是唯一的可能发生的优化。 下面的测试证明了确实如此: 你不应该过分依赖于这种行为,但是知道语言和框架的作者尽可能以各种可能的方式来优化性能总归是好的。缓存一个任务是一种常见的优化模式,在其他地方也使用这种模式。例如,在corefx仓库中的新的Socket实现就严重地依赖于这种优化,并尽可能地使用缓存任务。 上面的优化只在某些情况下有用。与其依赖于它,我们还可以使用 我们其实可以把 当操作同步地执行完毕时,这个特殊类型能帮助避免不必要的分配。要使用 然后我们就可以用一个额外的基准测试来衡量差异: 你可以看到,返回 如果你有一个非常广泛使用的异步方法,并且希望进一步减少开销,也许你可以考虑下面的优化:你可以去掉 听起来很复杂?来看一个例子: 在这个例子中, 使用本地函数不是唯一的选择但是是最简单的。但有个需要注意的,就是本地函数的最自然的实现会捕获一个闭包状态:局部变量和参数: 但很不幸,由于一个编译器bug,这段代码即使是从通常的路径上(即if字句中)完成的,依然会分配一个闭包(closure)。下面是这个方法被编译器转换后的样子: 编译器为给定范围中的所有局部变量/参数使用一个共享的闭包实例。所以上面的代码虽然看起来是有道理的,但是它使堆分配(heap allocation)的避免变得不可能。 提示:这种优化技巧性非常强。好处非常小,而且即使你写的本地函数是没问题的,在未来你也很可能进行修改,然后意外地捕获了外部变量,于是造成堆分配。如果你在写一个像BCL那样的高度可复用的类库,你依然可以用这个技巧来优化那些肯定会被用在热路径(hot path)上的方法。 到目前为止我们只讨论了一个特殊情况:一个同步地执行完毕的异步方法的开销。这是故意的。异步方法越小,其总体的性能开销就越明显。细粒度异步方法做的事相对来说较少,更容易同步地完成。我们也会相对更加频繁地调用他们。 但我们也应该知道当一个方法等待一个未完成的task时的异步机制的性能开销。为了度量这个开销,我们将 让我们为我们的性能基准测试添加以下的几个方法: 正如我们所见,在速度和内存方面,差异都是显而易见的。下面是对结果的简短解释。 一如既往地,记得先进行性能测试。如果你发现异步操作造成了性能问题,你可以从 [翻译]C#中异步方法的性能特点 标签:init path sse 常见 dir github readonly noi cache 原文地址:https://www.cnblogs.com/raytheweak/p/9314229.html
public class StockPrices
{
private const int Count = 100;
private List _stockPricesCache;
// 异步版本
public async Task
StockPrices
这个类使用来自外部数据源的股票价格来填充缓存,并提供用于查询的API。和第一篇中的例子主要的不同就是从价格的dictionary变成了价格的list。为了度量不同形式的异步方法与同步方法的开销,操作本身应该至少做一些工作,比如对_stockPricesCache的线性搜索。DoGetPriceFromCache
使用一个循环完成,从而避免任何对象分配。同步 vs. 基于Task的异步版本
GetStockPriceForAsync
),2.调用了异步初始化方法的同步方法(GetStockPriceFor
),3.调用了同步初始化方法的同步方法。private readonly StockPrices _stockPrices = new StockPrices();
public SyncVsAsyncBenchmark()
{
// 初始化_stockPricesCache
_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();
}
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
的开销甚至更小,但最奇妙的是它根本没有任何(managed heap上的)分配(上面的结果表中的Allocated列对GetPricesDirectlyFromCache
和GetStockPriceFor
都为0)。InitializeMapIfNeededAsync
的调用没有任何分配?我在第一篇文章中提到过,异步方法必须在managed heap上至少分配一个对象——Task实例本身。下面我们来探索一下这个问题:优化 #1. 可能地缓存Task实例
AsyncMethodBuilder
对每一个成功完成的异步操作都使用同一个task实例。一个返回Task
的异步方法依赖于AsyncMethodBuilder
在SetResult
方法中做如下逻辑的处理:// AsyncMethodBuilder.cs from mscorlib
public void SetResult()
{
// I.e. 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
了一个类似的优化:它缓存了Task
以及其他一些primitive type(原始类型)的task。比如,它缓存了整数类型的所有默认值,而且对于Task
还缓存了在[-1; 9)这个范围内的值(详见AsyncTaskMethodBuilder
)。[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
优化 #2: 使用
ValueTask
ValueTask
:一个特殊的“类task”的类型,如果方法是同步地执行完毕,那么就不会有额外的分配。ValueTask
看作T
和Task
的联和:如果“value task”已经完成,那么底层的value就会被使用。如果底层的任务还没有完成,那么一个Task实例就会被分配。ValueTask
,我们只需要把GetStockPriceForAsync
的返回结果从Task
ValueTask
:public async ValueTask
[Benchmark]
public decimal GetStockPriceWithValueTaskAsync_Await()
{
return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult();
}
Method | Mean | Scaled | Gen 0 | Allocated |
-------------------------------- |---------:|-------:|-------:|----------:|
GetPricesDirectlyFromCache | 1.260 us | 0.90 | - | 0 B |
GetStockPriceFor | 1.399 us | 1.00 | - | 0 B |
GetStockPriceForAsync | 1.552 us | 1.11 | 0.0267 | 88 B |
GetStockPriceWithValueTaskAsync | 1.519 us | 1.09 | - | 0 B |
ValueTask
的方法比返回Task
的方法稍快一点。主要的差别在于避免了堆上的内存分配。我们稍后将讨论是否值得进行这样的转换,但在此之前,我想介绍一种技巧性的优化。优化 #3: 在一个通常的路径上避免异步机制(avoid async machinery on a common path)
async
修饰符,在方法中检查task的状态,并且将整个操作同步执行,从而完全不需要用到异步机制。public ValueTask
GetStockPriceWithValueTaskAsync_Optimized
方法没有async
修饰符,它从InitializeMapIfNeededAsync
方法中得到一个task的时候,检查这个task是否已完成,如果已经完成,它就调用DoGetPriceFromCache
直接立刻得到结果。但如果这个task还没有完成,它就调用一个本地函数(local function,从C# 7.0开始支持),然后等待结果。public ValueTask
public ValueTask
等待一个task的开销
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();
}
Method | Mean | Scaled | Gen 0 | Gen 1 | Allocated |
------------------------------------------ |----------:|-------:|-------:|-------:|----------:|
GetStockPriceFor | 2.332 us | 1.00 | - | - | 0 B |
GetStockPriceForAsync | 2.505 us | 1.07 | 0.0267 | - | 88 B |
GetStockPriceWithValueTaskAsync | 2.625 us | 1.13 | - | - | 0 B |
GetStockPriceFor_Await | 6.441 us | 2.76 | 0.0839 | 0.0076 | 296 B |
GetStockPriceForAsync_Await | 10.439 us | 4.48 | 0.1577 | 0.0122 | 553 B |
GetStockPriceWithValueTaskAsync_Await | 10.455 us | 4.48 | 0.1678 | 0.0153 | 577 B |
GetStockPriceFor
约为GetStockPriceForAsync
的两倍快,并分配更少的内存。ValueTask
的异步方法比基于Task
的稍慢。因为基于ValueTask
的异步方法的状态机需要保存更多数据。异步方法性能的总结
async Task
来说没有额外开销,对async Task
ValueTask
可以消除上一条中的额外开销。ValueTask
的异步方法比基于Task
的方法稍快;如果是异步执行完毕的,则稍慢。ValueTask
切换到ValueTask
,缓存一个task或是新增一个通常的执行路径(如果可能的话)。但你也可以尝试将异步操作粗粒度化。这可以提高性能,简化调试,并使代码更好理解。并不是每一小段代码都必须是异步的。其他参考资料
ValueTask
‘s usage scenarios