Это краткая выжимка из рабочего доклада по работе с async/await в C#. Для наглядности, параллельно рассматривается подход с использованием блока ContinueWith
.
Основные паттерны асинхронного программирования
- Asynchronous Programming Model (APM)
- Event Asynchronous Pattern (EAP)
- Task Asynchronous Pattern (TAP)
- Задачи представляют параллельные операции
- Могут выполняться на отдельном или разных потоках.
- Могут быть скомбинированы и выстроены в цепочку вызовов.
async/await — синтаксическая обертка над задачами
await
ставит на паузу текущий метод, ожидая выполнения задачи.- Выглядит как блокирующая (синхронная) операция.
- Не блокирует текущий поток.
- Выполнение продолжается в том же контексте, из которого была вызвана задача, если явно не указано иное.
- Ключевое слово
async
указывается, чтобы среда исполнения воспринималаawait
как ключевое слово. await
метод начинает выполняться синхронно. Если он уже закончил свое выполнение то новый поток не создается. Все продолжается в том же потоке. Подробнее в ответе на stackoverflow.com.await
работает с любым типом, для которого реализован методGetAwaiter()
. Подробнее в статье - await anything.
Демонстрационное приложение
Примеры показываются на тестовом Windows Form приложении. GitHub репозиторий с приложением.
Асинхронные действия лежат в PeopleRepositoryAsync
:
public class PeopleRepositoryAsync
{
public async Task<List<string>> GetPeopleList()
{
await Task.Delay(2000);
return new List<string>
{
"John Smith",
"Ivan Ivanov",
"Joao Fetucini"
};
}
}
Метод GetPeopleList()
асинхронно ожидает 2 секунды и возвращает список пользователей.
Первое сравнение TAP и async/await подхода
Реализация с Task и ContinueWith
Добавим код для получения списка пользователей в обработчик нажатия кнопки “Fetch Data (with Task)” - buttonTask_Click
:
Task<List<string>> peopleTask = Repository.GetPeopleList();
List<string> people = peopleTask.Result;
Этот код не будет выполняться асинхронно. Он будет ожидать завершение задачи peopleTask
в основном потоке, поэтому UI заморозится. Добавим конструкцию ContinueWith(t => { })
:
peopleTask.ContinueWith(t =>
{
List<string> people = peopleTask.Result;
});
Теперь задача по получению пользователей выполнится асинхронно. После её завершения выполнится код в блоке ContinueWith
.
Добавим в ContinueWith
отображение полученных имен в textBoxMain
:
textBoxMain.AppendText($"{Environment.NewLine}Person list:{Environment.NewLine}");
foreach (var person in people)
{
textBoxMain.AppendText($"- {person}{Environment.NewLine}");
}
Если запустить приложение и нажать на “Fetch Data (with Task)”, то возникнет ошибка. Все потому, что код в блоке ContinueWith
выполняется в потоке, отличном от того где находится SynchronizationContext UI потока.
Для выполнения в нужном потоке добавим в вызов метода ContinueWith
аргумент TaskScheduler.FromCurrentSynchronizationContext()
:
peopleTask.ContinueWith(t => { ... }, TaskScheduler.FromCurrentSynchronizationContext());
Теперь приложение работает корректно. Перейдем к реализации этого кода с помощью ключевых слов async
и await
.
Реализация с async/await
Основное отличие от предыдущей реализации — код будет похож на синхронный. Перейдем в обработчик нажатия кнопки “Fetch Data (with await)” - buttonAwait_Click
. Добавим код для получения списка пользователей:
List<string> people = await Repository.GetPeopleList();
В объявление метода добавим слово async
, чтобы среда исполнения поняла что await
это ключевое слово, а не просто переменная. Вставим без изменений код из ContinueWith
:
textBoxMain.AppendText($"{Environment.NewLine}Person list:{Environment.NewLine}");
foreach (var person in people)
{
textBoxMain.AppendText($"- {person}{Environment.NewLine}");
}
Все. Приложение работает так же как и в предыдущем пункте. Код выглядит как синхронный. Выполнение продолжается в том же потоке, из которого и было вызвано.
Обработка ошибок
Для демонстрации добавим в метод GetPeopleList
код вызова ошибки (после Task.Delay(2000)
):
throw new NotImplementedException("Метод не реализован!");
Обработка ошибок для await метода
Сначала рассмотрим самый простой случай. Отлов и обработка ошибок для метода с await
происходит как в синхронном коде:
try
{
// Получение и вывод значений из репозитория
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "ОШИБКА");
}
finally
{
// Критичный к выполнению код
}
Всё. Дополнительно писать ничего не надо, ошибка будет поймана.
Обработка ошибок для Task метода
Для метода с блоком ContinueWith
обработать ошибки можно несколькими способами.
Первый, использовать еще один вызов ContinueWith
на задаче. Вызовем ContinueWith
с 2 дополнительными параметрами:
peopleTask.ContinueWith(t =>
{
// Получение и вывод значений из репозитория
},
CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion,
TaskScheduler.FromCurrentSynchronizationContext());
Главное — аргумент TaskContinuationOptions.OnlyOnRanToCompletion
. Он указывает, что блок кода выполнится только если в задаче не было ошибок.
Теперь, код для обработки ошибки:
peopleTask.ContinueWith(t =>
{
foreach (var exception in t.Exception.Flatten().InnerExceptions)
{
MessageBox.Show(exception.Message);
}
},
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted,
TaskScheduler.FromCurrentSynchronizationContext());
Опция OnlyOnFaulted
указывает, что код в блоке выполниться только при ошибке в задаче. Оператор foreach
разворачивает ошибки в «плоское» состояние, т. к. все ошибки представляются в виде иерархии и оборачиваются в AggregateException
.
Для имитации блока finally
, напишем:
peopleTask.ContinueWith(t =>
{
// Критичный к выполнению код
},
CancellationToken.None);
Второй способ обработки ошибок занимает меньше строк. В блок ContinueWith
добавляется условный оператор, который проверяет статус задачи:
peopleTask.ContinueWith(t =>
{
if (t.Status == TaskStatus.RanToCompletion)
{
// Получение и вывод значений из репозитория
}
if (t.Status == TaskStatus.Faulted)
{
// Обработка ошибок
}
// (finally) Критичный к выполнению код
},
TaskScheduler.FromCurrentSynchronizationContext());
Отмена действий
Обновим метод GetPeopleList
класса Repository
. Добавим параметр CancellationToken
и точку отмены после вызова Task.Delay(2000)
:
public async Task<List<string>> GetPeopleList(CancellationToken cancellationToken = new CancellationToken())
{
await Task.Delay(1500, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
return new List<string> { ... };
}
Обратит внимание, что отмена произойдет только после ожидания в 2 секунды. Само действие Task.Delay
не прерывается.
Для операции отмены используем кнопку “Cancel request” с обработчиком buttonCancel_Click
. Объекту CancellationToken
можно задать значение только при инициализации. Поэтому создадим переменную CancellationTokenSource
. Она позволяет генерировать токены и изменять их состояние во время выполнения.
В класс MainForm
добавим поле:
private CancellationTokenSource _tokenSource;
А в обработчик buttonCancel_Click
код для подачи токену сигнала отмены:
_tokenSource.Cancel();
Обработка отмены для async метода
В обработчике buttonAwait_Click
изменим вызов метода GetPeopleList()
, добавив инициализацию _tokenSource
и передав сгенерированный токен в качестве аргумента:
_tokenSource = new CancellationTokenSource();
List<string> people = await Repository.GetPeopleList(_tokenSource.Token);
Добавим обработку операции отмены:
catch (OperationCanceledException ex)
{
MessageBox.Show(ex.Message, "Canceled");
}
Обработка отмены для Task метода
Для buttonTask_Click
добавим похожий код для передачи токена:
_tokenSource = new CancellationTokenSource();
Task<List<string>> peopleTask = Repository.GetPeopleList(_tokenSource.Token);
Для обработки операции отмены в блок ContinueWith
добавим:
if (t.Status == TaskStatus.Canceled)
{
MessageBox.Show("Operation Canceled", "Canceled");
}
Deadlocks
Добавим в класс Repository
метод DeadlockTestAsync()
:
public async Task DeadlockTestAsync()
{
await Task.Delay(1500);
Console.WriteLine("Done!");
}
Вызовем этот метод в обработчике кнопки “Deadlock”:
async void buttonDeadlock_Click
{
Repository.DeadlockTestAsync().Wait();
}
Все. При нажатии на кнопку возникнет Deadlock. Почему? Рассмотрим по пунктам:
DeadlockTestAsync()
вызывается на потоке с UI.Task.Delay()
запускается в новом потоке.await
захватываетSynchronizationContext
и подключает continuation для выполнения действий после завершения.- Вернемся к вызову
DeadlockTestAsync()
. Wait()
ждет завершение задачи в UI потоке.Task.Delay()
ожидает выполнить продолжение на UI потоке.- Но поток в ожидание - Дедлок!
- Все потому, что задача не вернется из
DeadlockTestAsync()
, пока не выполнится “продолжение”.
Для избежания подобной ситуации, в библиотеках, лучше писать .ConfigureAwait(false)
:
await Task.Delay(1500).ConfigureAwait(false);
Это позволит выполнить “продолжение” в том же потоке, в котором работала задача. В моем примере это будет поток, отличный от UI потока.
Полезные ссылки
- Stephen blog - Async and Await
- Async/Await - Best Practices in Asynchronous Programming
- Async/Await FAQ
- Await anything