Совсем недавно мы уже рассказывали о том, нужно ли переопределять Equals и GetHashCode при программировании на C#. Сегодня мы разберемся с параметрами производительности асинхронных методов. Присоединяйтесь!
В последних двух статьях в блоге msdn мы рассмотрели внутреннюю структуру асинхронных методов в C# и точки расширения, которые компилятор C# предоставляет для управления поведением асинхронных методов.
Исходя из информации первой статьи, компилятор выполняет множество преобразований, чтобы сделать асинхронное программирование максимально похожим на синхронное. Для этого он создает экземпляр конечного автомата, передает его построителю асинхронного метода, который вызывает объект awaiter для задачи, и т. д. Разумеется, подобная логика имеет свою цену, но во что нам это обойдется?
Пока не появилась библиотека TPL, асинхронные операции не использовались в таком большом объеме, поэтому и издержки были невысоки. Но сегодня даже сравнительно простое приложение может выполнять сотни, если не тысячи, асинхронных операций в секунду. Библиотека параллельных задач TPL создавалась с учетом такой рабочей нагрузки, но здесь нет никакого волшебства, и за всё приходится платить.
Для оценки издержек асинхронных методов мы будем использовать слегка видоизмененный пример из первой статьи.
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
сохраняет в кэш цены акций из внешнего источника и позволяет запрашивать их через API. Основное отличие от примера из первой статьи заключается в переходе от словаря к списку цен. Чтобы оценить издержки различных асинхронных методов в сравнении с синхронными, сама операция должна выполнить определенную работу, в нашем случае это линейный поиск цен акций.GetPricesFromCache
намеренно построен на основе простого цикла, чтобы избежать выделения ресурсов.GetStockPriceForAsync
), синхронный метод, который вызывает асинхронный метод инициализации (GetStockPriceFor
), и синхронный метод, который вызывает синхронный метод инициализации.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();
}
GetPricesForAsync
выполняется синхронно в этом тесте и примерно на 15 % (*) медленнее, чем чисто синхронный метод.GetPricesFor
, который вызывает асинхронный метод InitializeMapIfNeededAsync
, имеет еще более низкие издержки, но что самое удивительное, он вовсе не выделяет ресурсы (в столбце Allocated в приведенной выше таблице стоит 0 как для GetPricesDirectlyFromCache
, так и для GetStockPriceFor
).InitializeMapIfNeededAsync
совсем не выделялись ресурсы? В первой статье этой серии я упоминал, что асинхронный метод должен выделять по крайней мере один объект в заголовке managed — сам экземпляр задачи. Давайте обсудим этот момент подробнее.AsyncMethodBuilder
использует один экземпляр задачи для каждой успешно завершенной асинхронной операции. Асинхронный метод, который возвращает 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<T>
оптимизирует работу похожим образом: он кэширует задачи для Task<bool>
и некоторых других простых типов. Например, он кэширует все значения по умолчанию для группы целочисленных типов и использует специальный кэш для 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;
}
Socket
в репозитории corefx repo широко использует этот способ и применяет кэшированные задачи везде, где это возможно.ValueTask
ValueTask<T>
(**), специальный тип значений, подобный задаче; он не будет выделять ресурсы, если метод выполняется синхронно.ValueTask<T>
представляет собой различаемое объединение T
и Task<T>
: если «значение-задача» завершено, то будет использоваться базовое значение. Если базовое выделение еще не исчерпано, то для задачи будут выделены ресурсы.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();
}
ValueTask
выполняется лишь немного быстрее, чем версия с 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
, он проверяет статус ее выполнения. Если задача завершена, метод просто использует DoGetPriceFromCache
, чтобы немедленно получить результат. Если задача инициализации всё еще выполняется, метод вызывает локальную функцию и ждет результатов.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);
}
}
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
}
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();
}
Task<T>
на ValueTask<T>
, кэшировать задачу или сделать общий путь выполнения синхронным, если это возможно. Вы также можете попытаться укрупнить свои асинхронные операции. Это поможет повысить производительность, упростить отладку и анализ кода в целом. Не каждый маленький фрагмент кода должен быть асинхронным.
К сожалению, не доступен сервер mySQL