Асинхронное программирование на C#: как дела с производительностью?

в 7:15, , рубрики: .net, .net core, C#, clr, dotnet, enum, Equals, GetHashCode, microsoft, struct, ValueType, Блог компании Microsoft, высокая производительность, Программирование, производительность

Совсем недавно мы уже рассказывали о том, нужно ли переопределять Equals и GetHashCode при программировании на C#. Сегодня мы разберемся с параметрами производительности асинхронных методов. Присоединяйтесь!

Асинхронное программирование на C#: как дела с производительностью? - 1

В последних двух статьях в блоге 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();
}

Результаты показаны ниже:

Асинхронное программирование на C#: как дела с производительностью? - 2

Уже на этом этапе мы получили достаточно интересные данные:

  • Асинхронный метод довольно быстрый. GetPricesForAsync выполняется синхронно в этом тесте и примерно на 15 % (*) медленнее, чем чисто синхронный метод.
  • Синхронный метод GetPricesFor, который вызывает асинхронный метод InitializeMapIfNeededAsync, имеет еще более низкие издержки, но что самое удивительное, он вовсе не выделяет ресурсы (в столбце Allocated в приведенной выше таблице стоит 0 как для GetPricesDirectlyFromCache, так и для GetStockPriceFor).

(*) Разумеется, нельзя сказать, что издержки при синхронном выполнении асинхронного метода составляют 15 % для всех возможных случаев. Это значение напрямую зависит от выполняемой методом рабочей нагрузки. Разница между издержками чистого вызова асинхронного метода (который ничего не делает) и синхронного метода (который ничего не делает) будет огромна. Идея этого сравнительного теста — показать, что издержки асинхронного метода, выполняющего относительно небольшой объем работы, являются сравнительно невысокими.

Как получилось, что при вызове InitializeMapIfNeededAsync совсем не выделялись ресурсы? В первой статье этой серии я упоминал, что асинхронный метод должен выделять по крайней мере один объект в заголовке managed — сам экземпляр задачи. Давайте обсудим этот момент подробнее.

Оптимизация № 1: кэширование экземпляров задач, когда это возможно

Ответ на указанный выше вопрос очень прост: 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 широко использует этот способ и применяет кэшированные задачи везде, где это возможно.

Оптимизация № 2: использование 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();
}

Асинхронное программирование на C#: как дела с производительностью? - 3

Как видите, версия с ValueTask выполняется лишь немного быстрее, чем версия с Task. Главное отличие — предотвращается выделение кучи. Через минутку мы обсудим целесообразность такого перехода, но перед этим я хотел бы рассказать об одной хитрой оптимизации.

Оптимизация № 3: отказ от асинхронных методов в рамках общего пути

Если вы очень часто используете какой-то асинхронный метод и хотите уменьшить издержки еще больше, предлагаю вам следующую оптимизацию: удалить модификатор async, а затем проверять состояние задачи внутри метода и выполнять всю операцию синхронно, полностью отказавшись от асинхронных подходов.

Выглядит сложно? Рассмотрим пример.

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

Но, к сожалению, из-за ошибки компилятора этот код будет порождать замыкание (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
}

Как уже обсуждалось в статье Dissecting the local functions in C# («Усечение локальных функций в C#»), компилятор использует общий экземпляр closure для всех локальных переменных и аргументов в конкретной области. Следовательно, в такой генерации кода есть некий смысл, но она делает всю борьбу с выделением кучи бесполезной.

СОВЕТ. Такая оптимизация — очень коварная вещь. Преимущества незначительны, и даже если вы напишете правильную исходную локальную функцию, в ходе дальнейших изменений можно случайно получить внешнее состояние, вызывающее выделение кучи. Вы по-прежнему можете прибегать к оптимизации, если работаете с часто используемой библиотекой (например, BCL) в методе, который определенно будет применяться на нагруженном участке кода.

Издержки, связанные с ожиданием задачи

На данный момент мы рассмотрели только один специфический случай: издержки асинхронного метода, который выполняется синхронно. Это сделано намеренно. Чем «меньше» асинхронный метод, тем более заметны издержки в его общей производительности. Более детализированные асинхронные методы, как правило, запускаются синхронно и выполняют меньшую рабочую нагрузку. И вызываем мы их обычно чаще.

Но мы должны знать об издержках асинхронного механизма, когда метод «ожидает» завершения невыполненной задачи. Чтобы оценить эти издержки, мы внесем изменения в 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();
}

Асинхронное программирование на C#: как дела с производительностью? - 4

Как видите, разница ощутима — как в плане быстродействия, так и с точки зрения использования памяти. Кратко поясним полученные результаты.

  • Каждая операция await для незавершенной задачи выполняется примерно 4 микросекунды и выделяет почти 300 байт (**) при каждом вызове. Именно поэтому GetStockPriceFor выполняется почти вдвое быстрее, чем GetStockPriceForAsync, и выделяет меньше памяти.
  • Асинхронный метод на основе ValueTask занимает немного больше времени, чем вариант с Task, когда этот метод не выполняется синхронно. Конечный автомат метода на основе ValueTask<T> должен хранить больше данных, чем конечный автомат метода на основе Task<T>.

(**) Это зависит от платформы (x64 или x86) и ряда локальных переменных и аргументов асинхронного метода.

Производительность асинхронных методов 101

  • Если асинхронный метод выполняется синхронно, издержки довольно малы.
  • Если асинхронный метод выполняется синхронно, то возникают следующие издержки в использовании памяти: для методов async Task издержек нет, а для методов async Task<T> перерасход составляет 88 байт на каждую операцию (для платформ x64).
  • ValueTask<T> позволяет устранить упомянутые выше издержки для асинхронных методов, выполняемых синхронно.
  • Когда асинхронный метод на основе ValueTask<T> выполняется синхронно, то это занимает немного меньше времени, чем метод с Task<T>, в противном случае наблюдаются небольшие различия в пользу второго варианта.
  • Издержки в плане производительности для асинхронных методов, ожидающих выполнения незавершенной задачи, значительно выше (примерно 300 байт на каждую операцию для платформ x64).

Разумеется, измерения — наше всё. Если вы видите, что асинхронная операция вызывает проблемы с производительностью, можете переключиться с Task<T> на ValueTask<T>, кэшировать задачу или сделать общий путь выполнения синхронным, если это возможно. Вы также можете попытаться укрупнить свои асинхронные операции. Это поможет повысить производительность, упростить отладку и анализ кода в целом. Не каждый маленький фрагмент кода должен быть асинхронным.

Автор: sahsAGU

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js