翻译自一篇博文,原文: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或是新增一个通常的执行路径(如果可能的话)。但你也可以尝试将异步操作粗粒度化。这可以提高性能,简化调试,并使代码更好理解。并不是每一小段代码都必须是异步的。

其他参考资料

[翻译]C#中异步方法的性能特点的更多相关文章

  1. ElasticSearch中的JVM性能调优

    ElasticSearch中的JVM性能调优 前一段时间被人问了个问题:在使用ES的过程中有没有做过什么JVM调优措施? 在我搭建ES集群过程中,参照important-settings官方文档来的, ...

  2. 译<容器网络中OVS-DPDK的性能>

    译<容器网络中OVS-DPDK的性能> 本文来自对Performance of OVS-DPDK in Container Networks的翻译. 概要--网络功能虚拟化(Network ...

  3. [翻译]PYTHON中如何使用*ARGS和**KWARGS

    [翻译]Python中如何使用*args和**kwargs 函数定义 函数调用 不知道有没有人翻译了,看到了,很短,顺手一翻 原文地址 入口 或者可以叫做,在Python中如何使用可变长参数列表 函数 ...

  4. 记录bigdesk中ElasticSearch的性能参数

    定时采集bigdesk中的Elasticsearch性能参数,并保存到数据库或ELK,以便于进行长期监控. 基于python脚本实现,脚本如下: #coding=gbk import httplibi ...

  5. php中一些提高性能的技巧

    php中一些提高性能的技巧 tags:php性能 提高性能 php中的@ php的静态 引言:php作为一种脚本语言,本身的性能上肯定是不如c++或者java的.拥有简单易学的特性的同时,性能提升的空 ...

  6. webstorm快捷键 webstorm keymap内置快捷键英文翻译、中英对照说明

    20160114参考网络上的快捷键,整理自己常用的: 查找/代替shift+shift 快速搜索所有文件,简便ctrl+shift+N 通过文件名快速查找工程内的文件(必记)ctrl+shift+al ...

  7. vue中关于v-for性能优化---track-by属性

    vue中关于v-for性能优化---track-by属性 最近看了一些react,angular,Vue三者的对比文章,对比来说Vue比较突出的是轻量级与易上手. 对比Vue与angular,Vue有 ...

  8. 在Windows中监视IO性能

    附:在Windows中监视IO性能 本来准备写一篇windows中监视IO性能的,后来发现好像可写的内容不多,windows在细节这方面做的不是那么的好,不过那些基本信息还是有的. 在Windows中 ...

  9. 在Linux中监视IO性能

    dd命令 iostat命令 理解iostat的各项输出 iostat的应用实例 附:在Windows中监视IO性能 延伸阅读 dd命令 dd其实是工作于比较低层的一个数据拷贝和转换的*nix平台的工具 ...

随机推荐

  1. js中setTimeout和setInterval的应用方法(转)

    JS里设定延时: 使用SetInterval和设定延时函数setTimeout 很类似.setTimeout 运用在延迟一段时间,再进行某项操作. setTimeout("function& ...

  2. 第一篇 jQuery

    1-1 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3. ...

  3. DIj

    using System;using System.Collections.Generic;using System.Linq;using System.Text; namespace DefineG ...

  4. Linux Exploit系列之四 使用return-to-libc绕过NX bit

    使用return-to-libc绕过NX bit 原文地址:https://bbs.pediy.com/thread-216956.htm 这篇讲解的比较好,主要的问题是获得system地址和exit ...

  5. Nginx请求限制配置

    Nginx请求限制配置 请求限制可以通过两种方式来配置,分别是  连接频率限制和请求频率限制 首先我们要知道什么是http请求和连接,浏览器和服务端首先通过三次握手完成连接,然后发起请求,传输请求参数 ...

  6. hdu2159 二维02bag

    设f[i][j]为杀第j只怪时耐久度为i的最大经验值 完全背包类型:有N种物品和一个容量为V 的背包,每种物品都有无限件可用.放入第i种物品的耗费的空间是Ci,得到的价值是Wi. 求解:将哪些物品装入 ...

  7. iOS中为控件设置颜色渐变和透明度渐变

    项目中用到地图设置渐变色,查找资料找到两种方法:一种设置颜色,一种设置透明度: //为颜色设置渐变效果: UIView *view = [[UIView alloc] initWithFrame:CG ...

  8. mysqldump关于--set-gtid-purged=OFF的使用

    数据库的模式中我开启了gtid: mysql> show variables like '%gtid%'; +----------------------------------+------- ...

  9. spring MVC 后端 接收 前端 批量添加的数据(简单示例)

    第一种方式:(使用ajax的方式) 前端代码: <%@ page contentType="text/html;charset=UTF-8" language="j ...

  10. 后端返回图片的url,将其转成base64,再次进行上传

      //将图片变成base64再上传(主要是转化来自客户端的图片)  getUrlBase64=(url, ext)=> {     var canvas = document.createEl ...