Принцип асинхронного программирования состоит в том, что длительно выполняющиеся (или потенциально длительно выполняющиеся) функции реализуются асинхронным образом. Он отличается от традиционного подхода синхронной реализации длительно выполняющихся функций с последующим их вызовом в новом потоке (Thread) или в задаче (Task) для введения параллелизма по мере необходимости.
Асинхронный подход обеспечивает:
- параллельное выполнение операций ввода-вывода без связывания потоков;
- уменьшение количества кода в рабочих потоках обогащенных клиентских приложений => async и await.
Первый сценарий касается серверных приложений, которые обрабатывают множество параллельных операций ввода-вывода. В таких приложениях важна не безопасность потоков (разделяемое состояние минимально), а эффективность использования потоков, чтобы поток, обрабатывающий клиентские запросы, не простаивал на сетевых операциях.
Второй сценарий упрощает поддержку потокобезопасности в обогащенных клиентских приложениях, для которых в целях упрощения программы проводится рефакторинг крупных методов в методы меньших размеров, получая в результате цепочки методов, которые вызывают друг друга (графы вызовов).
Виды параллелизма:
Асинхронность возможна благодаря объекту Task:
Реализации асинхронного подхода с мелкомодульным параллелизмом затруднена из-за небходимости создания класса StateMachine, обеспечивающий императивное управление состоянием задачи (Task) посредством класса TaskCompletionSource.
Реализация ожидания: ключевое слово await упрощает присоединение признаков продолжения.
Приведенные ниже строки:
var результат = await выражение;
оператор(ы);
компилятор развернет в следующий функциональный эквивалент:
var awaiter = выражение.GetAwaiter();
awaiter.OnCompleted(() =>
{
var результат = awaiter.GetResult();
оператор(ы);
});
Захват локального состояния: await может располагаться внутри циклов и других конструкций. Пример демонстрирует использование await в цикле for:
async void DisplayPrimeCounts()
{
for (int i = 0; i < 10; i++)
Console.WriteLine(await GetPrimesCountAsync(1 * 1000000 + 2, 1000000));
}
Ожидание в пользовательском интерфейсе: в случае запуска в потоке пользовательского интерфейса контекст синхронизации гарантирует, что выполнение будет возобновлено в том же самом потоке (т.е. в потоке UI).
Когда запускается обработчик события (в коде которого присутствует await), выполнение продолжается до выражения await, после чего управление возвращается в цикл сообщений:
while (приложение не завершено)
{
Ожидать появления чего - нибудь в очереди сообщений
Что - то получено: к какому виду сообщений оно относится?
Сообщение клавиатуры/ мыши->запустить обработчик событий
Пользовательское сообщение Beginlnvoke / Invoke->выполнить делегат
}
Чрезмерная асинхронность может снизить производительность из-за накладных расходов связанных с выделением объектов-обещаний в управляемой памяти (куче).
Цель исследования заключается в формировании принципов асинхронного программирования, направленных на повышение эффективности управления состоянием асинхронных операций, при этом минимизируя накладные расходы, связанные с управлением памятью.
Ожидание асинхронной функции, которая завершается синхронно, связано с небольшими накладными расходами (компилятор все равно добавляет код для управления состоянием метода и возможным продолжением) – примерно 20 наносекунд на современных компьютерах. Напротив, переход в пул потоков вызывает переключение контекста – возможно одну или две микросекунды, а переход в цикл обработки сообщений пользовательского интерфейса – минимум в десять раз больше (и еще больше, если пользовательский интерфейс занят).*
* Albahari, J. (2023). C# 12 in a Nutshell: The Definitive Reference. O'Reilly Media, Inc.
При ожидании задачи компилятор оптимизирует код, проверяя свойство IsCompleted. Если задача уже завершена, выполнение происходит с возвратом завершённого экземпляра задачи без создания продолжения. Это называется синхронным завершением.
Недостаток:
Если последовательность обращений относится к одному и тому же URI, то инициируется множество избыточных загрузок, которые все в конечном итоге обновят одну и ту же запись в кеше.
При повторяющихся вызовах асинхронного метода получения веб-страницы по одинаковым URI гарантируем получение одного и того же объекта Task<string>. Это обеспечивает и дополнительное преимущество минимизации нагрузки на сборщик мусора. И если задача завершена, то ожидание не требует больших затрат благодаря оптимизации, которую предпринимает компилятор.
В данном примере, если метод завершается синхронно, ему не нужно возвращать новый Task, так как возвращаемое значение отсутствует.
В таких случаях платформа .NET использует кешированный необобщенный Task, который возвращает пустое значение (эквивалент void в синхронных методах). Этот кешированный синглтон доступен через свойство Task.CompletedTask.
Поскольку есть только два возможных результата типа bool (true и false), то существует только два возможных объекта Task, которые нужны для представления этих результатов. В сценарии синхронного завершения среда .NET обеспечивает кеширование этих объектов, возвращая их с соответствующим значением без выделения памяти.
При использовании 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).
ValueTask<TResult> позволяет оборачивать как TResult, так и Task<TResult>.
При синхронном и успешном выполнении асинхронного метода, структура ValueTask<TResult> возвращается без размещения объекта в куче. Только при асинхронном выполнении создается объект Task<TResult>, который затем оборачивается в ValueTask<TResult>.
Зачем нужен необощенный ValueTask, если есть Task.CompletedTask?
В .NET Core 2.1 был представлен необобщенный ValueTask. Это дало возможность управлять асинхронными операциями с минимальными накладными расходами, аналогично обобщенным версиям, но с пустым возвращаемым значением. Освобождение от выделения в куче при асинхронном завершении с использованием необобщенного ValueTask достигается за счет использования необобщенного интерфейса IValueTaskSource, который позволяет реализовать логику асинхронной операции так, чтобы управлять её завершением без необходимости создания нового объекта Task в куче.
Тестирование реализаций* функции Аккермана проведено с применением .NET SDK 9.0.100 и библиотеки BenchmarkDotNet v0.14.0. Использована целевая платформа с характеристиками:
* Исходный код программы AsyncAckermannBenchmark [Электронный ресурс] // GitHub: репозиторий CSharpCooking/AsyncAckermannBenchmark. – URL: https://github.com/CSharpCooking/AsyncAckermannBenchmark/blob/main/AsyncAckermannBenchmark/Program.cs (дата обращения: 16.04.2025).