Асинхронное программирование в C#
Небольшой конспект доклада об асинхронности в .NET. Cоветую его посмотреть, будет полезен как новичкам, так и уже опытным ребятам.
Что такое асинхронность?
Асинхронность — выполнение программного кода, не блокирующее потоки во время ожидания.
Например: делаем запрос в бд для получения некоторых данных.
У нас есть поток и запрос, который выполняет этот поток. При синхронном выполнении наш поток будет ждать, пока БД не ответит. В асинхронном случае наш поток будет выполнять другой запрос, пока БД обрабатывает текущий.
TAP
С приходом .NET Framework 4.0 появился новый способ для работы с асинхронностью — TAP. По сути, это набор классов, которые предоставляют более удобный интерфейс для работы с асинхронным кодом. Но мы всё ещё можем использовать старые подходы: APM и EAP. Но Майкрософт советует использовать TAP.
Центральным классов в TAP является Task. Он описывает отдельную задачу, выполнение которой может завершиться в какой-то момент.
Детальнее про подходы работы с многопоточностью.
Как создать задачу?
Фреймворк предоставляет множество способов для создания и запуска задач, а также оборачивания старого кода (APM и EAP).
Фабрики запущенных задач
Task.Run(Action/Func)
Task.Factory.StartNew(Action/Func)
Фабрики завершенных задач
Task.FromResult(Result)
Task.FromCanceled(Result)
Task.FromException(Result)
Task.CompletedTask
Конструктор
var task = new Task(Action/Func)
task.Start()
Фабрики для оборачивания в таски
Task.Factory.FromAsync(APM pattern)
TaskCompletionSource(EAP, APM, etc)
Класс TaskScheduler
Класс, который содержит стратегию запуска и планирования задач Task. Существуют две стандартные реализации: ThreadPoolTaskScheduler и SynchronizationContextTaskScheduler. Первая запускает задачи с помощью пула потоков, а вторая — использует текущий контекст синхронизации.
Чтобы указать планировщику, что наша задача продолжительная мы можем использовать флаг TaskCreationOptions.LongRunning (указывается при создании задачи). Это гарантирует, что под нашу задачу выделиться отдельный поток и в него не будут планироваться продолжения других операций.
У каждого потока есть свой «текущий» контекст, который необязательно должен быть уникальным. Иногда нам нужно, чтобы завершение или продолжение асинхронной операции выполнялись в том же контексте, откуда было запущено. Например, приложения с UI. После нажатия на кнопку мы запускаем асинхронную операцию, результат которой нужно вывести на экран. Но мы не сможем получить доступ к UI элементу, так как он находится в другом потоке. Для этого нам нужно запустить обновление данных в контексте UI потока. Именно для этого и нужен контекст синхронизации.
Контекст синхронизации при работе с Task и Thread
Комбинирование задач
Есть ситуации, когда нам нужно ожидать выполнения нескольких задач, для этого мы можем использовать комбинаторы задач.
Task.WaitAll();
Task.WaitAny();
Task.WhenAll();
Task.WhenAny();
Но нужно быть внимательным, так как WaitAll и WaitAny являются блокирующими операциями. Вместо них желательно использовать WhenAll и WhenAny.
Как получить результат?
Получить результат из операции можно несколькими способами. Некоторые из них являются блокирующими. Старайтесь избегать синхронного ожидания, так как это может привести к дедлокам.
Блокирующее получение результата:
.Result
.GetAwaiter().GetResult() - //лучше не использовать так как этот метод предназначен для компилятора, а не для нас.
.Wait()
Асинхронное:
await
Что такое sync over async deadlock?
Продолжение задач
Есть ситуации, когда нам нужно выполнить несколько операций одну за одной. Для этого у нас есть метод ContinueWith, с помощью которого можно построить цепочку продолжений.
Пример:
var task = Task.Run(() => Console.WriteLine("Async task"));
var nextTask = task.ContinueWith((prevTask) => {
Console.WriteLine("Continuation");
});
nextTask.ContinueWith((prevTask) => {
Console.WriteLine("Last Continuation");
});
Мы можем настраивать условия продолжения используя класс TaskContinuationOptions. С его помощью мы можем строить дерево задач, запускать продолжения по условиям и т. д.
Обработка исключений
Работа с исключениями в асинхронном коде отличается от синхронного. Мы можем и не узнать об исключении, если не будем явно его обрабатывать. А в более старых версиях .NET это могло привести к падению приложения.
Обработка исключения при использовании await:
try
{
await Task.Run(() => { throw new Exception(); });
}
catch (Exception e)
{
//
}
По умолчанию ContinueWith выполняется независимо от того произошло ли исключение в предыдущей задаче. Но мы можем его настроить двумя способами: в самом продолжении проверить предыдущую задачу или сделать отдельное продолжение, которое будет вызвано только при возникновении исключения.
Task.Run(() => { throw new Exception(); })
.ContinueWith((prev) => {
if (prev.IsFaulted)
{
//
}
});
Task.Run(() => { throw new Exception(); })
.ContinueWith((prev) => {
}, TaskCreationOptions.OnlyOnfaulted);
Стоит избегать асинхронных методов, которые ничего не возвращают (async void). Так как при возникновении исключения внутри метода, мы просто не сможем его обработать.
Комбинаторы задач также по-разному предоставляют исключения:
- Task.WaitAll() — вернёт AggregateException;
- Task.WhenAll() — вернёт только первое исключение;
Отмена задач
Если нам нужно отменить выполнение асинхронной задачи мы можем воспользоваться классом CancellationToken. Про него я рассказывал раньше.
async/await
Основная идея этого подхода заключается в написании асинхронного кода как синхронного. Это позволило создать более дружелюбный интерфейс для работы с асинхронностью.
Что делает async?
- Создаёт машину состояний, которая обрабатывает все продолжения и синхронизации.
- Разрешает использовать await.
- Позволяет передавать вверх по стеку результат и исключения, используя Task.
Что делает await?
- Позволяет не блокирующее ожидать результат.
- Запуск продолжения в нужном потоке.
- Возвращает результат или исключение.
Заблуждения по поводу await
- Запускает операцию асинхронно.
- Являться синтаксическим сахаром над Task.ContinueWtih.
- Обязательно запускает продолжение в новый поток.
- Всегда работает асинхронно.
Советы
- Не используйте комбинацию async void — вы не сможете отловить исключение, а также ожидать выполнение метода.
- Методы помеченные как async должны внутри себя содержать await.
- Старайтесь вежде использовать не блокирующее ожидание await вместо блокирующего.
- Для выполнения продолжительных операций используйте Task.Factory.StartNew вместе с TaskCreationOptions.LognRunning.
- Используйте await t.ConfigureAwait(false) для библиотечного кода.
- Не используйте совместно await и ContinueWith, так как они ничего не знают друг о друге.
Что осталось за кадром?
- Асинхронные потоки
- ValueTask
- Progress