Хабрахабр

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

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

В последних двух статьях в блоге msdn мы рассмотрели внутреннюю структуру асинхронных методов в C# и точки расширения, которые компилятор C# предоставляет для управления поведением асинхронных методов.

Для этого он создает экземпляр конечного автомата, передает его построителю асинхронного метода, который вызывает объект awaiter для задачи, и т. Исходя из информации первой статьи, компилятор выполняет множество преобразований, чтобы сделать асинхронное программирование максимально похожим на синхронное. Разумеется, подобная логика имеет свою цену, но во что нам это обойдется? д.

Но сегодня даже сравнительно простое приложение может выполнять сотни, если не тысячи, асинхронных операций в секунду. Пока не появилась библиотека TPL, асинхронные операции не использовались в таком большом объеме, поэтому и издержки были невысоки. Библиотека параллельных задач TPL создавалась с учетом такой рабочей нагрузки, но здесь нет никакого волшебства, и за всё приходится платить.

Для оценки издержек асинхронных методов мы будем использовать слегка видоизмененный пример из первой статьи.

public class StockPrices
// 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).

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

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

Оптимизация № 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();
}

Главное отличие — предотвращается выделение кучи. Как видите, версия с 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();
}

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

  • Каждая операция 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>, кэшировать задачу или сделать общий путь выполнения синхронным, если это возможно. Вы также можете попытаться укрупнить свои асинхронные операции. Это поможет повысить производительность, упростить отладку и анализ кода в целом. Не каждый маленький фрагмент кода должен быть асинхронным.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть