Loading…
Transcript

Принципы оптимизации асинхронных операций в .NET

Руслан Гибадуллин

1. Введение

  • Принцип асинхронного программирования
  • Сценарии использования асинхронного программирования
  • Крупномодульный и мелкомодульный параллелизм
  • Реализация асинхронного подхода
  • Ключевые слова async и await
  • Проблематика и цель исследования

Принцип асинхронного программирования

Принцип асинхронного программирования

Принцип асинхронного программирования состоит в том, что длительно выполняющиеся (или потенциально длительно выполняющиеся) функции реализуются асинхронным образом. Он отличается от традиционного подхода синхронной реализации длительно выполняющихся функций с последующим их вызовом в новом потоке (Thread) или в задаче (Task) для введения параллелизма по мере необходимости.

Асинхронный подход обеспечивает:

- параллельное выполнение операций ввода-вывода без связывания потоков;

- уменьшение количества кода в рабочих потоках обогащенных клиентских приложений => async и await.

Сценарии использования асинхронного программирования

Клиент-серверные приложения

Первый сценарий касается серверных приложений, которые обрабатывают множество параллельных операций ввода-вывода. В таких приложениях важна не безопасность потоков (разделяемое состояние минимально), а эффективность использования потоков, чтобы поток, обрабатывающий клиентские запросы, не простаивал на сетевых операциях.

Обогащенные клиентские приложения

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

Виды параллелизма:

  • Крупномодульный
  • Мелкомодульный

Реализация асинхронного подхода

Реализация асинхронного подхода

Асинхронность возможна благодаря объекту Task:

  • выступает объектом-обещанием (promise);
  • предоставляет результат фоновой работы;
  • обеспечивает функцию обратного вызова (callback).

Реализации асинхронного подхода с мелкомодульным параллелизмом затруднена из-за небходимости создания класса StateMachine, обеспечивающий императивное управление состоянием задачи (Task) посредством класса TaskCompletionSource.

Ключевые слова async и await

Реализация ожидания: ключевое слово await упрощает присоединение признаков продолжения.

Приведенные ниже строки:

var результат = await выражение;

оператор(ы);

компилятор развернет в следующий функциональный эквивалент:

var awaiter = выражение.GetAwaiter();

awaiter.OnCompleted(() =>

{

var результат = awaiter.GetResult();

оператор(ы);

});

Ключевые слова async и await

Захват локального состояния: await может располагаться внутри циклов и других конструкций. Пример демонстрирует использование await в цикле for:

async void DisplayPrimeCounts()

{

for (int i = 0; i < 10; i++)

Console.WriteLine(await GetPrimesCountAsync(1 * 1000000 + 2, 1000000));

}

Ключевые слова async и await

Ожидание в пользовательском интерфейсе: в случае запуска в потоке пользовательского интерфейса контекст синхронизации гарантирует, что выполнение будет возобновлено в том же самом потоке (т.е. в потоке UI).

Когда запускается обработчик события (в коде которого присутствует await), выполнение продолжается до выражения await, после чего управление возвращается в цикл сообщений:

while (приложение не завершено)

{

Ожидать появления чего - нибудь в очереди сообщений

Что - то получено: к какому виду сообщений оно относится?

Сообщение клавиатуры/ мыши->запустить обработчик событий

Пользовательское сообщение Beginlnvoke / Invoke->выполнить делегат

}

Проблематика и цель исследования

Проблематика и цель исследования

Чрезмерная асинхронность может снизить производительность из-за накладных расходов связанных с выделением объектов-обещаний в управляемой памяти (куче).

Цель исследования заключается в формировании принципов асинхронного программирования, направленных на повышение эффективности управления состоянием асинхронных операций, при этом минимизируя накладные расходы, связанные с управлением памятью.

2. Синхронное завершение асинхронной функции

  • Временные затраты
  • Пример 1
  • Пример 2

2. Синхронное завершение асинхронной функции

Временные затраты

Временные затраты на синхронное завершение

Ожидание асинхронной функции, которая завершается синхронно, связано с небольшими накладными расходами (компилятор все равно добавляет код для управления состоянием метода и возможным продолжением) – примерно 20 наносекунд на современных компьютерах. Напротив, переход в пул потоков вызывает переключение контекста – возможно одну или две микросекунды, а переход в цикл обработки сообщений пользовательского интерфейса – минимум в десять раз больше (и еще больше, если пользовательский интерфейс занят).*

* Albahari, J. (2023). C# 12 in a Nutshell: The Definitive Reference. O'Reilly Media, Inc.

Пример 1

Пример 1

При ожидании задачи компилятор оптимизирует код, проверяя свойство IsCompleted. Если задача уже завершена, выполнение происходит с возвратом завершённого экземпляра задачи без создания продолжения. Это называется синхронным завершением.

Недостаток:

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

Пример 2

Пример 2

При повторяющихся вызовах асинхронного метода получения веб-страницы по одинаковым URI гарантируем получение одного и того же объекта Task<string>. Это обеспечивает и дополнительное преимущество минимизации нагрузки на сборщик мусора. И если задача завершена, то ожидание не требует больших затрат благодаря оптимизации, которую предпринимает компилятор.

3. Кеширование Task/Task<T>

  • Task
  • Task<bool>
  • Task<int>

3. Кеширование Task/Task<T>

Task

Кеширование Task

В данном примере, если метод завершается синхронно, ему не нужно возвращать новый Task, так как возвращаемое значение отсутствует.

В таких случаях платформа .NET использует кешированный необобщенный Task, который возвращает пустое значение (эквивалент void в синхронных методах). Этот кешированный синглтон доступен через свойство Task.CompletedTask.

Task<bool>

Кеширование Task<bool>

Поскольку есть только два возможных результата типа bool (true и false), то существует только два возможных объекта Task, которые нужны для представления этих результатов. В сценарии синхронного завершения среда .NET обеспечивает кеширование этих объектов, возвращая их с соответствующим значением без выделения памяти.

Task<int>

Кеширование Task<int>

При использовании Task<int> в качестве типа возвращаемого объекта асинхронного метода ситуация – иная. Кеширование всех возможных значений Task<int> потребовало бы сотни гигабайт памяти, так как Int32 представляет собой 32-битное целое число со знаком, которое может принимать около 4,3 миллиарда уникальных значений (в диапазоне от -2 147 483 648 до 2 147 483 647). Поэтому среда выполнения предоставляет ограниченный кеш для Task<int>, покрывающий только небольшой набор значений: значения Task<int> кешируются для диапазона значений от -1 до 9.*

* Исходный код класса Task в .NET Runtime [Электронный ресурс] // GitHub: репозиторий dotnet/runtime. – URL: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs (дата обращения: 16.04.2025).

4. Применение структуры ValueTask/ValueTask<T>

  • Синхронный сценарий
  • Асинхронный сценарий
  • Необобщенный ValueTask
  • Ограничения

4. Применение структуры ValueTask/ValueTask<T>

Синхронный сценарий

Синхронный сценарий

ValueTask<TResult> позволяет оборачивать как TResult, так и Task<TResult>.

При синхронном и успешном выполнении асинхронного метода, структура ValueTask<TResult> возвращается без размещения объекта в куче. Только при асинхронном выполнении создается объект Task<TResult>, который затем оборачивается в ValueTask<TResult>.

Асинхронный сценарий

Асинхронный сценарий: IValueTaskSource<T>

Асинхронный сценарий: IValueTaskSource<T> c применением ManualResetValueTaskSourceCore<TResult>

Необобщенный ValueTask

Необобщенный ValueTask

Зачем нужен необощенный ValueTask, если есть Task.CompletedTask?

В .NET Core 2.1 был представлен необобщенный ValueTask. Это дало возможность управлять асинхронными операциями с минимальными накладными расходами, аналогично обобщенным версиям, но с пустым возвращаемым значением. Освобождение от выделения в куче при асинхронном завершении с использованием необобщенного ValueTask достигается за счет использования необобщенного интерфейса IValueTaskSource, который позволяет реализовать логику асинхронной операции так, чтобы управлять её завершением без необходимости создания нового объекта Task в куче.

Ограничения

Ограничения на использование

ValueTask/ValueTask<T>

  • Многократное ожидание (await) на одном и том же ValueTask/ValueTask<TResult>: поскольку внутренний объект может быть обработан и использоваться в другой операции, многократное ожидание может привести к тому, что объект уже будет занят другой задачей.
  • Использование метода GetAwaiter().GetResult() до завершения операции: в отличие от Task, который поддерживает блокирующий вызов до завершения задачи, реализация IValueTaskSource или IValueTaskSource<TResult> не обязана поддерживать блокировку до завершения операции.

5. Тестирование

  • Программа и целевая платформа
  • Результаты

5. Тестирование

Программа и целевая платформа

Программа и целевая платформа

Тестирование реализаций* функции Аккермана проведено с применением .NET SDK 9.0.100 и библиотеки BenchmarkDotNet v0.14.0. Использована целевая платформа с характеристиками:

  • процессор Intel Core i5-9300H (8 логических, 4 физических ядра)
  • оперативная память DDR4 16 ГБ,
  • операционная система Windows 11 (10.0.22631.4460),
  • Runtime=.NET 9.0.3 (9.0.325.11113), X64 RyuJIT AVX2.

* Исходный код программы AsyncAckermannBenchmark [Электронный ресурс] // GitHub: репозиторий CSharpCooking/AsyncAckermannBenchmark. – URL: https://github.com/CSharpCooking/AsyncAckermannBenchmark/blob/main/AsyncAckermannBenchmark/Program.cs (дата обращения: 16.04.2025).

Результаты

Результаты тестирования

6. Заключение

6. Заключение

  • Если вы можете использовать кешированные задачи, то с точки зрения производительности, лучше придерживаться Task и Task<bool>. Более того, при малых аллокациях в памяти в синхронном сценарии вполне оправдано применение и Task<T>, так как данный тип не уступает в производительности ValueTask<T>.
  • Если в асинхронном сценарии удается эффективно использовать пул объектов с поддержкой интерфейса IValueTaskSource<T>, избегая лишних аллокаций в памяти при многократном использовании одних и тех же объектов, то применение ValueTask<TResult> предпочтительно.