[翻译]C#中异步方法的性能特点

翻译自一篇博文,原文:The performance characteristics of async methods in C#

异步系列

  • 剖析C#中的异步方法
  • 扩展C#中的异步方法
  • C#中异步方法的性能特点
  • 用一个用户场景来掌握它们

在前两篇中,我们介绍了C#中异步方法的内部原理,以及C#编译器提供的可扩展性从而自定义异步方法的行为。今天我们将探讨异步方法的性能特点。

正如第一篇所述,编译器进行了大量的转换,使异步编程体验非常类似于同步编程。但要做到这一点,编译器会创建一个状态机实例,将其传递给异步方法的builder,然后这个builder会调用task awaiter等。很明显,所有这些逻辑都需要成本,但是需要付出多少呢?

在TPL问世之前,异步操作通常是粗细粒度的,因此异步操作的开销很可能可以忽略不计。但如今,即使是相对简单的应用程序,每秒也可能有数百次或数千次异步操作。TPL在设计时考虑了这样的工作负载,但它没那么神,它会有一些开销。

要度量异步方法的开销,我们将使用第一篇文章中使用过的例子,并加以适当修改:

public class StockPrices
{
    private const int Count = 100;
    private List<(string name, decimal price)> _stockPricesCache;
 
    // 异步版本
    public async Task<decimal> GetStockPriceForAsync(string companyId)
    {
        await InitializeMapIfNeededAsync();
        return DoGetPriceFromCache(companyId);
    }
 
    // 调用init方法的同步版本
    public decimal GetStockPriceFor(string companyId)
    {
        InitializeMapIfNeededAsync().GetAwaiter().GetResult();
        return DoGetPriceFromCache(companyId);
    }
 
    // 纯同步版本
    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()
    {
        // 类似的初始化逻辑
    }
 
    private async Task InitializeMapIfNeededAsync()
    {
        if (_stockPricesCache != null)
        {
            return;
        }
 
        await Task.Delay(42);
 
        // 从外部数据源得到股价
        // 生成1000个元素,使缓存命中略显昂贵
        _stockPricesCache = Enumerable.Range(1, Count)
            .Select(n => (name: n.ToString(), price: (decimal)n))
            .ToList();
        _stockPricesCache.Add((name: "MSFT", price: 42));
    }
}

StockPrices这个类使用来自外部数据源的股票价格来填充缓存,并提供用于查询的API。和第一篇中的例子主要的不同就是从价格的dictionary变成了价格的list。为了度量不同形式的异步方法与同步方法的开销,操作本身应该至少做一些工作,比如对_stockPricesCache的线性搜索。

DoGetPriceFromCache使用一个循环完成,从而避免任何对象分配。

同步 vs. 基于Task的异步版本

在第一次基准测试中,我们比较1.调用了异步初始化方法的异步方法(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列对GetPricesDirectlyFromCacheGetStockPriceFor都为0)。

当然,你也不能说异步机制的开销对于所有异步方法同步执行的情况都是15%。这个百分比与方法所做的工作量非常相关。如果测量一个啥都不做的异步方法和啥都不做的同步方法的开销对比就会显示出很大的差异。这个基准测试是想显示执行相对较少量工作的异步方法的开销是适度的。

为什么InitializeMapIfNeededAsync的调用没有任何分配?我在第一篇文章中提到过,异步方法必须在managed heap上至少分配一个对象——Task实例本身。下面我们来探索一下这个问题:

优化 #1. 可能地缓存Task实例

前面的问题的答案非常简单:AsyncMethodBuilder对每一个成功完成的异步操作都使用同一个task实例。一个返回Task的异步方法依赖于AsyncMethodBuilderSetResult方法中做如下逻辑的处理:

// 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<T>做了一个类似的优化:它缓存了Task<bool>以及其他一些primitive type(原始类型)的task。比如,它缓存了整数类型的所有默认值,而且对于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>:一个特殊的“类task”的类型,如果方法是同步地执行完毕,那么就不会有额外的分配。

我们其实可以把ValueTask<T>看作TTask<T>的联和:如果“value task”已经完成,那么底层的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();
}
                          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<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId)
{
    var task = InitializeMapIfNeededAsync();
 
    // Optimizing for a common 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方法中得到一个task的时候,检查这个task是否已完成,如果已经完成,它就调用DoGetPriceFromCache直接立刻得到结果。但如果这个task还没有完成,它就调用一个本地函数(local function,从C# 7.0开始支持),然后等待结果。

使用本地函数不是唯一的选择但是是最简单的。但有个需要注意的,就是本地函数的最自然的实现会捕获一个闭包状态:局部变量和参数:

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

但很不幸,由于一个编译器bug,这段代码即使是从通常的路径上(即if字句中)完成的,依然会分配一个闭包(closure)。下面是这个方法被编译器转换后的样子:

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
}

编译器为给定范围中的所有局部变量/参数使用一个共享的闭包实例。所以上面的代码虽然看起来是有道理的,但是它使堆分配(heap allocation)的避免变得不可能。

提示:这种优化技巧性非常强。好处非常小,而且即使你写的本地函数是没问题的,在未来你也很可能进行修改,然后意外地捕获了外部变量,于是造成堆分配。如果你在写一个像BCL那样的高度可复用的类库,你依然可以用这个技巧来优化那些肯定会被用在热路径(hot path)上的方法。

等待一个task的开销

到目前为止我们只讨论了一个特殊情况:一个同步地执行完毕的异步方法的开销。这是故意的。异步方法越小,其总体的性能开销就越明显。细粒度异步方法做的事相对来说较少,更容易同步地完成。我们也会相对更加频繁地调用他们。

但我们也应该知道当一个方法等待一个未完成的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 |

正如我们所见,在速度和内存方面,差异都是显而易见的。下面是对结果的简短解释。

  • 每一个对未完成的task的“await”操作大概需要4us并且每次调用分配了约300B(依赖于平台(x64 vs. x86 ),以及异步方法中的局部变量或参数)的内存。这解释了为什么GetStockPriceFor约为GetStockPriceForAsync的两倍快,并分配更少的内存。
  • 当异步方法不是同步地执行完毕时,基于ValueTask的异步方法比基于Task的稍慢。因为基于ValueTask的异步方法的状态机需要保存更多数据。

异步方法性能的总结

  • 如果异步方法同步地执行完毕,额外的开销相当小。
  • 如果异步方法同步地执行完毕,以下内存开销会发生:对async Task来说没有额外开销,对async Task<T 来说,每个异步操作导致88 bytes的开销(x64平台)。
  • 对于同步执行完毕的异步方法,ValueTask<T>可以消除上一条中的额外开销。
  • 如果方法是同步执行完毕的,那么一个基于ValueTask<T>的异步方法比基于Task<T>的方法稍快;如果是异步执行完毕的,则稍慢。
  • 等待未完成的task的异步方法的性能开销相对大得多(在x64平台,每个操作需要300 bytes)。

一如既往地,记得先进行性能测试。如果你发现异步操作造成了性能问题,你可以从ValueTask<T> 切换到ValueTask<T>,缓存一个task或是新增一个通常的执行路径(如果可能的话)。但你也可以尝试将异步操作粗粒度化。这可以提高性能,简化调试,并使代码更好理解。并不是每一小段代码都必须是异步的。

其他参考资料

原文地址:https://www.cnblogs.com/raytheweak/p/9314229.html