探讨C#异步方法之性能

本文手动翻译自:The performance characteristics of async methods in C# – Developer Support (microsoft.com)

在前两篇文章中,我们学习了C#异步方法的原理,还介绍了C#编译器提供了哪些异步方法的扩展途径,让我们能够修改异步方法的工作流程。今天我们将探讨有关C#异步方法的性能问题。

通过第一篇文章,你知道了在编译器大费周章地优化后,异步编程体验已经非常近似于同步编程了。为了做到这一点,编译器创建了一个状态机,并将它传给异步方法构建器“ builder ”,而 builder 又会调用 task awaiter ,等等。显然这些操作都会产生性能开销,但我们为了得到这项“高阶魔法”,具体付出了多少代价?

Task 库诞生之前,异步操作通常是非常粗粒度的,因此这些异步导致的性能开销几乎可以被忽略掉。而到了今天,甚至是相对简单的应用程序,也可能每秒执行着成百上千次的异步操作。Task 库的设计考虑到了如此庞大的工作负载,但它其实并不是魔法,它有性能代价。通常用得很爽的技术,一旦滥用起来也可能导致南辕北辙的后果。

为了检测异步方法的性能开销,我们会用到第一篇文章中的稍作修改的例子:

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 类对外提供查询股票价格的接口,并在查询前会根据需要初始化缓存。这个例子与第一篇文章的区别是将缓存的结构从字典换成了数组。为了检测并比较异步方法和同步方法二者性能开销的优劣,这些“方法”至少得做一些(耗时)工作,比如在股票价格模型中进行线性搜索之类的。

DoGetPriceFromCache 方法是有意使用普通循环构建的,以避免任何内存分配。

鹿死誰手?!同步方法與異步方法の終極對決!

在第一个 benchmark 中,我们会比较三个具体的方法:调用了异步初始化方法的 GetStockPriceForAsync ;同样调用异步初始化方法,但自身为同步方法的 GetStockPriceFor ;以及自身和初始化方法都为同步方法的 GetPriceFromCacheFor

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

结果如下:

MethodMeanScaledGen0Allocated
GetPricesDirectlyFromCache2.177us0.960B
GetStockPriceFor2.268us1.000B
GetStockPriceForAsync2.523us1.110.026788B

得出的数据有点意思:

  • 异步方法相当快。GetPriceForAsync 在这次的测试中以同步方式运行,且比纯同步方法慢了大约 15% 。

  • 内部调用异步 InitializeMapIfNeedAsync 的同步方法 GetStockPricesFor 有着更好的性能,最令人惊讶的是它没有任何内存开销(Allocated 这一项数据中 GetPricesDirectlyFromCacheGetStockPriceFor 的消耗都为0)。

当然,对于所有可能的情况,我们不能认为当异步方法以同步方式运行时,异步机制的开销就是 15% 。该百分比取决于方法承担的工作量。如果真的去比较什么都不做的异步和同步方法的开销,会得出差异比较大的结果。这次 benchmark 测试只是说明,在工作量较小时,异步方法的额外开销是能被接受的。

还有一个问题,InitializeMapIfNeededAsync 方法为什么会出现没有任何内存开销的情况?在该系列文章的第一篇中我就提到过,异步方法至少会为了一个对象向系统申请内存分配,而这个对象就是 task 自己。下面我们就来探讨这个问题。

优化 #1.Task对象缓存

要回答上面的问题很简单:AsyncMethodBuilder 会在每一个成功执行完毕的异步操作中使用到一个 task 对象。依赖 AsyncMethodBuilder 且返回类型为 Task 的异步方法会在 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<T> 也做了类似的优化:它缓存了 Task<bool> ,以及其他泛型为原始类型的对象。例如,它缓存了一大堆的整数类型的所有默认值,还有 [-1,9) 范围的 Task<int> 对象。(可以在 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;
}

你不需要完全理解内部的实现细节,知道 C# 和平台框架的作者们在尽力从每个方面优化其性能就好。缓存一个 task 对象是非常常用的优化方式,在其他地方也被广泛应用。例如,corefx repo (链接)仓库里新的 Socket (链接)实现就大量使用了该优化方法,只要可能就尽量缓存 task 对象

优化 #2.使用ValueTask

上面一节提到的优化方案只在一些特定的情况下适用。除此之外,我们可以使用 ValueTask<T> :一个特殊的 task-like 的值类型,当方法以同步的方式执行时,它不会向系统申请内存。

ValueTask<T> 是强差别联合类型(discriminated union type):当该【valueTask】对象已经是完成任务的状态时,其泛型代表的就会被直接应用;如果在等待【valueTask】对象时它还没有完成任务,那么它才会申请内存。

译者注:对于差别联合类型,可以简单理解为该类型是给定多个具体类型的抽象类型。比如 【形状】 可声明为 【三角形】、【矩形】和【椭圆】的差别联合类型。强差别联合类型译者理解为同一时间下,该类型只能被定义为子类型中的一种。

ValueTask<T> 能够避免当异步方法以同步方式执行时的不必要内存开销。要使用它,我们只需要将方法 GetStockPriceForAsync 的返回类型从 Task<decimal> 改成 ValueTask<decimal>

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

接下来我们将它加入到 benchmark 测试中:

[Benchmark]
public decimal GetStockPriceWithValueTaskAsync_Await()
{
    return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult();
}
MethodMeanScaledGen0Allocated
GetPricesDirectlyFromCache1.260us0.900B
GetStockPriceFor1.399us1.000B
GetStockPriceForAsync1.552us1.110.026788B
GetStockPriceWithValueTaskAsync1.519us1.090B

正如你看到的——ValueTask 版本比 Task 版本稍微快那么一点,它们的主要区别在堆内存申请上。稍后我们会讨论在什么时候值得这样去优化代码,但在那之前,我想先谈一个能让你拿出去炫技的优化方法。

优化 #3.非必要不使用异步方法

如果你有一个被广泛使用的异步方法,并且想减少它的开销,你可以考虑下面的优化方案:拿掉方法的 async 关键字,在方法内部手动检查 task 对象的状态,同步处理整个操作,不处理任何异步机制。

听起来很复杂?下面来看看例子:

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 方法获取到 task 后,会检查 task 是否已经完成任务。如果 task 已经完成任务,该方法将调用 DoGetPriceFromCache 方法来立即获取结果。如果任务还在执行,该方法会调用一个本地方法来等待 task 的工作结果。

译者注:本地方法为C# 7.0新特性,即被定义在方法内部的方法。本地方法是一种语法糖,本质上还是基于面向对象实现的。

采用本地方法来实现此优化不是唯一的选择,但是最简单的方式之一。但有一个问题需要注意:在一般的本地方法实现中,本地方法会捕获一个闭包状态(enclosing state):定义本地方法的外部方法的局部变量和形参:

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

译者注:对于闭包,在数学上也称封闭性,定义为:有非空集合 S 和一个函数 F : F(S) -> S,则称 F 为在 S 上之二元运算,或称 (S,F) 具有封闭性(closure)。 若对某个集合的成员进行一种运算,生成的仍然是这个集合的元素,则该集合被称为在这个运算下闭合。在编程上,闭包可以理解为能够访问其他函数内部变量的函数,在这里自然就指本地函数。上方提到的【闭包状态】译者理解为C#的本地函数持有的外部函数的局部变量。

但很不幸的是,由于一个编译器bug,即使代码以同步方式运行——即不调用本地方法,本地方法也会产生内存消耗。编译器生成的代码类似下面这样:

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)时才会用到这个优化技巧。

等待 task 对象的性能开销

目前为止我们只讨论了一种情况:当异步方法以同步方式完成时的性能开销。当异步方法越【小】,那其整体性能开销就会越明显。细粒度的异步方法倾向于做更少的工作,更经常以同步方式完成,并且我们会更频繁地调用它。

我们同样应该知道在异步机制下,当方法 await 一个未完成任务的 task 对象时的开销。为了对比,我们修改 InitializeMapIfNeededAsync 方法,当缓存已经就绪时仍然调用 Task.Yield()

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

接下来是 benchmark 测试代码和测试结果:

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

译者注:原作者贴出的结果表格与上次测试的表格重复了,这里就不给表格,直接说结论。

我们能够看到运行速度和内存占用两项数据的差异更加明显了。下面简单解释一下:

  • 每次 await 未完成任务的 task 有大概 4us 和 300B 的开销。这也解释了为什么 GetStockPriceForGetStockPriceForAsync 快了快两倍,并且占用更少的内存。

  • 基于 Value-Task 的异步方法在非同步完成时会比基于 Task 的异步方法稍微慢一点。这是因为 Value-Task 的状态机需要保存更多数据。

上面 4us 和 300B 的测试结果是在 (x64 vs. x86)平台,并且异步方法中有大量本地变量的情况下得出的。

异步方法性能简单总结

  • 如果异步方法以同步的方式运行,那么性能开销将相当小

  • 如果异步方法以同步的方式运行,有关内存开销的详细如下:对于 async Task 方法来说,将没有任何开销;对于 async Task<T> 方法来说,每次大约有 88 bytes 的开销(x64 平台)。

  • ValueTask<T> 能够避免上述异步方法同步完成时的内存开销。

  • ValueTask<T> 异步方法同步完成时会比 Task<T> 方法快一点,反之则慢。

  • 当异步方法等待 task 时,所造成的开销与上面提到的相比是相当大的(在 x64 平台上大约每次有 300 bytes开销)。

最后,在你做优化前,要评估好优化方案。如果你发现一个异步操作导致了性能问题,你可以把 Task<T> 改为 ValueTask<T> ,或者用缓存 task 对象的方法,又或者尽可能把异步改造为同步(如同 优化 #3 优化方案)。你也可以将异步操作往粗粒度的方向优化,这不仅能改善性能,还调试变得简单,让代码变得更容易看懂。并非所有【细微】的代码都必须写成异步的。

课外阅读

上一篇
下一篇