Async/Await — Введение
Языковая конструкция Async/Await существует со времен C# версии 5.0 (2012) и быстро стала одним из столпов современного программирования на .NET — любой уважающий себя C# разработчик, должен использовать ее для повышения производительности приложений, общей отзывчивости и разборчивости кода.
Async/Await делает обманчиво простым внедрение асинхронного кода и избавляет программиста от необходимости разбираться в деталях его обработки, но многие ли из нас действительно знают, как она работает, и каковы преимущества и недостатки этого метода? Существует много полезной информации, но она разобщена, поэтому я решил написать эту статью.
Ну что ж, давайте углубимся в тему.
Конечный автомат (IAsyncStateMachine)
Первое, что нужно знать, это то, что под капотом каждый раз, когда у вас есть метод или функция с Async/Await, компилятор фактически превращает ваш метод в сгенерированный класс, который реализует интерфейс IAsyncStateMachine. Этот класс отвечает за сохранение состояния вашего метода в течение жизненного цикла асинхронной операции — он инкапсулирует все переменные вашего метода в виде полей и разбивает ваш код на разделы, которые выполняются при переходах конечного автомата между состояниями, так что поток может покинуть метод, и когда он вернется, состояние не изменится.
В качестве примера приведем очень простое определение класса с двумя асинхронными методами:
using System.Threading.Tasks;
using System.Diagnostics;
namespace AsyncAwait
{
public class AsyncAwait
{
public async Task AsyncAwaitExample()
{
int myVariable = 0;
await DummyAsyncMethod();
Debug.WriteLine("Continuation - After First Await");
myVariable = 1;
await DummyAsyncMethod();
Debug.WriteLine("Continuation - After Second Await");
myVariable = 2;
}
public async Task DummyAsyncMethod()
{
//Асинхронный вызов
}
}
}
Класс с двумя асинхронными методами
Если мы рассмотрим код, сгенерированный при сборке, мы увидим что-то вроде этого:
Обратите внимание, что у нас есть 2 новых внутренних класса, сгенерированных для нас, по одному для каждого асинхронного метода. Эти классы содержат по конечному автомату для каждого из наших асинхронных методов.
Далее, изучив декомпилированный код для
<AsyncAwaitExample>
d__0
, мы заметим, что наша внутренняя переменная «myVariable»
теперь является полем класса:Мы также можем видеть другие поля класса, используемые внутри для поддержания состояния
IAsyncStateMachine
. Конечный автомат переходит через состояния с помощью метода MoveNext()
, по сути, — большого переключателя. Обратите внимание, как в разных разделах происходит продолжение метода после каждого из асинхронных вызовов (с предшествующей меткой continuation).Это означает, что элегантность async/await имеет свою цену. Использование async/await на самом деле добавляет некоторую сложность (о которой вы можете не подозревать). В логике на стороне сервера это может не иметь решающего значения, но в частности при программировании мобильных приложений, где учитывается каждый цикл ЦП и КБ памяти, вы должны помнить об этом, поскольку объем накладных расходов может быстро возрасти. Позже в этой статье мы обсудим лучшие практики использования Async/Await только там, где это необходимо.
Для довольно поучительного объяснения конечного автомата, посмотрите это видео на YouTube.
Когда следует использовать Async/Await
Есть в целом два сценария, где Async/Await является правильным решением.
- Работа, связанная с вводом/выводом: Ваш код будет ожидать чего-то, например, данных из базы данных, чтения файла, вызова веб-службы. В этом случае вы должны использовать Async/Await, а не Task Parallel Library.
- Работа, связанная с процессором: ваш код будет выполнять сложные вычисления. В этом случае вы должны использовать Async/Await, но запустить работу нужно в другом потоке с помощью Task.Run. Вы также можете рассмотреть возможность использования Task Parallel Library.
Async до упора
Когда вы начнете работать с асинхронными методами, вы быстро заметите, что асинхронная природа кода начинает распространяться вверх и вниз по вашей иерархии вызовов — это означает, что вы также должны сделать свой вызывающий код асинхронным и так далее.
Может возникнуть искушение «остановить» это, заблокировав код с помощью Task.Result или Task.Wait, преобразовав небольшую часть приложения и обернув его в синхронный API, чтобы остальная часть приложения была изолирована от изменений. К сожалению, это рецепт создания трудно отслеживаемых взаимных блокировок.
Лучшее решение этой проблемы — позволить асинхронному коду расти по кодовой базе естественным образом. Если вы будете следовать этому решению, вы увидите расширение асинхронного кода до его точки входа, обычно это обработчик событий или действие контроллера. Отдайтесь асинхронности без остатка!
Больше информации в этой статье MSDN.
Если метод объявлен как async, убедитесь, что есть await!
Как мы уже обсуждали, когда компилятор находит async метод, он превращает этот метод в конечный автомат. Если ваш код не имеет await в своем теле, компилятор сгенерирует предупреждение, но конечный автомат, тем не менее, будет создан, добавив ненужные накладные расходы для операции, которая фактически никогда не завершится.
Избегайте async void
Async void — это то, чего действительно следует избегать. Возьмите за правило, использовать async Task вместо async void.
public async void AsyncVoidMethod()
{
//Плохо!
}
public async Task AsyncTaskMethod()
{
//Хорошо!
}
Методы async void и async Task
Для этого есть несколько причин, в том числе:
- Исключения, сгенерированные в async void методе, не могут быть перехвачены вне этого метода:
<
T>
метода, это исключение захватывается и помещается в объект Task. При использовании async void методов объект Task отсутствует, поэтому любые исключения, выбрасываемые из async void метода, будут вызваны непосредственно в SynchronizationContext, который был активен при запуске async void метода.Рассмотрим пример ниже. Блок захвата никогда не будет достигнут.
public async void AsyncVoidMethodThrowsException()
{
throw new Exception("Hmmm, something went wrong!");
}
public void ThisWillNotCatchTheException()
{
try
{
AsyncVoidMethodThrowsException();
}
catch(Exception ex)
{
//Строка ниже никогда не будет достигнута
Debug.WriteLine(ex.Message);
}
}
Исключения, выброшенные в async void методе, не могут быть перехвачены вне этого метода
Сравните с этим кодом, где вместо async void мы имеем async Task. В этом случае catch будет достижим.
public async Task AsyncTaskMethodThrowsException()
{
throw new Exception("Hmmm, something went wrong!");
}
public async Task ThisWillCatchTheException()
{
try
{
await AsyncTaskMethodThrowsException();
}
catch (Exception ex)
{
//Строка ниже может быть достигнута
Debug.WriteLine(ex.Message);
}
}
Исключение отлавливается и помещается в объект Task.
- Методы async void могут вызывать нежелательные побочные эффекты, если вызывающая сторона не ожидает, что они будут асинхронными: если ваш асинхронный метод ничего не возвращает, используйте async Task (без «
<
T>
» для Task) в качестве возвращаемого типа. - Async void методы очень сложно тестировать: из-за различий в обработке ошибок и компоновке трудно писать модульные тесты, которые вызывают async void методы. Поддержка асинхронного тестирования MSTest работает только для асинхронных методов, возвращающих Task или Task
<
T>
.
Исключением из этой практики являются асинхронные обработчики событий. Но даже в этом случае рекомендуется минимизировать код, написанный в самом обработчике, — ожидайте async Task метод, который содержит логику.
Больше информации в этой статье MSDN.
Предпочитайте return Task вместо return await
Как уже обсуждалось, каждый раз, когда вы объявляете метод как асинхронный, компилятор создает класс конечного автомата, который фактически оборачивает логику вашего метода. Это добавляет определенные накладные расходы, которые могут накапливаться, особенно для мобильных устройств, где у нас более строгие ограничения ресурсов.
Иногда метод не должен быть асинхронным, но возвращает Task
<
T>
и позволяет другой стороне обрабатывать его соответствующим образом. Если последнее предложение вашего кода является возвратом await, вам следует подумать о его рефакторинге, чтобы тип возвращаемого значения метода был Task<
T>
(вместо async T). Благодаря этому вы избегаете генерации конечного автомата, что делает ваш код более гибким. Единственный случай, в котором мы действительно хотим ждать, — это когда мы что-то делаем с результатом async Task в продолжении метода.public async Task<string> AsyncTask()
{
//Не очень хорошо!
//...Здесь происходит что-то не асинхронное
//await - последняя строка кода, после await нет продолжения
return await GetData();
}
public Task<string> JustTask()
{
//Лучше!
//...Здесь происходит что-то не асинхронное
//Возвращаем Task
return GetData();
}
Предпочитайте return Task вместо return await
Обратите внимание, что если у нас нет возврата await, а вместо этого вы возвращаете Task
<
T>
, возврат происходит сразу же, поэтому, если код находится внутри блока try/catch, исключение не будет перехвачено. Точно так же, если код находится внутри блока using, он сразу же удалит объект. Смотрите следующий совет.Не оборачивайте return Task внутри блоков try..catch{} или using{}
Return Task может вызвать неопределенное поведение, при использовании внутри блока try..catch (исключение, выброшенное асинхронным методом, никогда не будет перехвачено) или внутри блока using, поскольку задача будет возвращена немедленно.
Если вам нужно обернуть свой асинхронный код в блок try..catch или using, вместо этого используйте return await.
public Task<string> ReturnTaskExceptionNotCaught()
{
try
{
//Плохая идея...
return GetData();
}
catch (Exception ex)
{
//Строка ниже никогда не будет достигнута
Debug.WriteLine(ex.Message);
throw;
}
}
public Task<string> ReturnTaskUsingProblem()
{
using (var resource = GetResource())
{
//Плохая идея...К тому времени, когда на ресурс фактически сошлются, возможно, он уже будет удален
return GetData(resource);
}
}
Не оборачивайте return Task внутри блоков
try..catch{}
или using{}
.Больше информации в этом треде на stack overflow.
Избегайте использования .Wait()
или .Result
— используйте вместо этого GetAwaiter().GetResult()
Если вам необходимо заблокировать ожидание завершения Async Task, используйте
GetAwaiter().GetResult().
Wait
и Result
обернут любые исключения в AggregateException
, что усложняет обработку ошибок. Преимущество GetAwaiter().GetResult()
состоит в том, что оно возвращает обычное исключение вместо AggregateException
.public void GetAwaiterGetResultExample()
{
//Так нормально, но если выдается ошибка, она будет заключена в AggregateException
string data = GetData().Result;
//Так лучше, если выдается ошибка, она будет содержаться в обычном исключении
data = GetData().GetAwaiter().GetResult();
}
Если вам нужно заблокировать ожидание завершения Async Task, используйте
GetAwaiter().GetResult().
Более подробная информация по этой ссылке.
Если метод асинхронный, добавьте суффикс Async к его имени
Это соглашение, используемое в .NET для более простого различения синхронных и асинхронных методов (за исключением обработчиков событий или методов веб-контроллера, но они все равно не должны явно вызываться вашим кодом).
Методам асинхронной библиотеки стоит использовать Task.ConfigureAwait(false) для повышения производительности
.NET Framework имеет понятие «контекст синхронизации», который представляет собой способ «вернуться туда, где вы были раньше». Всякий раз, когда ожидают Task, она захватывает текущий контекст синхронизации перед ожиданием.
После завершения Task вызывается метод
.Post()
контекста синхронизации, который возобновляет работу с того места, где он был раньше. Это полезно для возврата к потоку пользовательского интерфейса или для возврата к тому же контексту ASP.NET и т. д.При написании библиотечного кода вам редко нужно возвращаться к контексту, в котором вы были раньше. Когда используется Task.ConfigureAwait(false), код больше не пытается возобновить с того места, где он был раньше, вместо этого, если это возможно, код завершается в потоке, выполнившем задачу, что позволяет избежать переключения контекста. Это немного повышает производительность и может помочь избежать взаимных блокировок.
public async Task ConfigureAwaitExample()
{
//Рекомендуется всегда использовать ConfigureAwait(false) в коде библиотеки.
var data = await GetData().ConfigureAwait(false);
}
Как правило, используйте ConfigureAwait(false) для серверных процессов и кода библиотеки.
Это особенно важно, когда библиотечный метод вызывается большое количество раз, для лучшей отзывчивости.
Как правило, используйте ConfigureAwait (false) для серверных процессов в целом. Нам не важно, какой поток используется для продолжения, в отличие от приложений, в которых нам нужно вернуться к потоку пользовательского интерфейса.
Теперь… В ASP.NET Core Microsoft покончила с SynchronizationContext, поэтому теоретически вам это не нужно. Но если вы пишете библиотечный код, который потенциально может быть повторно использован в других приложениях (например, UI App, Legacy ASP.NET, Xamarin Forms), это остается наилучшей практикой.
Для хорошего объяснения этой концепции, посмотрите это видео.
Отчет о прогрессе от асинхронных задач
Довольно распространенным вариантом использования асинхронных методов является работа в фоновом режиме, освобождение потока пользовательского интерфейса для выполнения других задач и поддержание респонсивности. В этом сценарии вы можете захотеть сообщить о прогрессе обратно в пользовательский интерфейс, чтобы пользователь мог следить за ходом процесса и взаимодействовать с операцией.
Для решения этой распространенной проблемы .NET предоставляет интерфейс IProgress
<
T>
, который предоставляет метод Report<
T>
, который вызывается асинхронной задачей, чтобы сообщить о ходе выполнения вызывающей стороне. Этот интерфейс принимается как параметр асинхронного метода — вызывающая сторона должна предоставить объект, который реализует этот интерфейс..NET предоставляет Progress
<
T>
, реализацию по умолчанию IProgress<
T>
, которую на самом деле рекомендуется использовать, поскольку он обрабатывает всю низкоуровневую логику, связанную с сохранением и восстановлением контекста синхронизации. Progress<
T>
также предоставляет событие и обратный вызов Action<
T>
— оба вызываются, когда задача сообщает о ходе выполнения.Вместе IProgress
<
T>
и Progress<
T>
обеспечивают простой способ передачи информации о ходе выполнения из фоновой задачи в поток пользовательского интерфейса.Обратите внимание, что
<
T>
может быть простым значением, таким как int, или объектом, который предоставляет контекстную информацию о прогрессе, такую как процент выполнения, строковое описание текущей операции, ETA и так далее.Учитывайте, как часто вы сообщаете о прогрессе. В зависимости от выполняемой операции, вы можете обнаружить, что ваш код сообщает о прогрессе несколько раз в секунду, что может привести к тому, что пользовательский интерфейс станет менее респонсивным. В таком сценарии рекомендуется сообщать о прогрессе в более крупных интервалах.
Больше информации в этой статье в официальном блоге Microsoft .NET.
Отмена асинхронных задач
Другим распространенным вариантом использования фоновых задач является возможность отмены выполнения. .NET предоставляет класс CancellationToken. Асинхронный метод получает объект CancellationToken, который затем совместно используется кодом вызывающей стороны, и асинхронным методом, таким образом предоставляя механизм для сигнализации отмены.
В наиболее распространенном случае отмена происходит следующим образом:
- Вызывающий объект создает объект CancellationTokenSource.
- Вызывающая сторона вызывает отменяемый асинхронный API и передает CancellationToken из CancellationTokenSource (CancellationTokenSource.Token).
- Вызывающая сторона запрашивает отмену с помощью объекта CancellationTokenSource (CancellationTokenSource.Cancel ()).
- Задача подтверждает отмену и отменяет себя, обычно используя метод CancellationToken.ThrowIfCancellationRequested.
Обратите внимание, что для того, чтобы этот механизм работал, вам нужно будет написать код для проверки отмены, запрашиваемой через регулярные промежутки времени (т.е. на каждой итерации вашего кода или в естественной точке остановки в логике). В идеале, после запроса отмены асинхронная задача должна быть отменена как можно быстрее.
Вы должны рассмотреть возможность использования отмены для всех методов, выполнение которых может занять много времени.
Больше информации в этой статье в официальном блоге Microsoft .NET.
Отчет о прогрессе и отмене — пример
using System;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Threading;
namespace TestAsyncAwait
{
public partial class AsyncProgressCancelExampleForm : Form
{
public AsyncProgressCancelExampleForm()
{
InitializeComponent();
}
CancellationTokenSource _cts = new CancellationTokenSource();
private async void btnRunAsync_Click(object sender, EventArgs e)
{
// Создание индикатора прогресса.
В этом примере мы сообщаем <int> как о прогрессе, но на самом деле мы можем сообщать о сложном объекте, предоставляя больше информации, такой как текущая операция, ETA и т. д.
var progressIndicator = new Progress<int>(ReportProgress);
try
{
//Вызываем наш асинхронный метод, передать индикатор прогресса и токен отмены в качестве параметров
await AsyncMethod(progressIndicator, _cts.Token);
}
catch (OperationCanceledException ex)
{
//Обработка отмены
lblProgress.Text = "Cancelled";
}
}
private void btnCancel_Click(object sender, EventArgs e)
{
//Вызов отмены
_cts.Cancel();
}
private void ReportProgress(int value)
{
// Печать прогресса в метке
lblProgress.Text = value.ToString();
}
private async Task AsyncMethod(IProgress<int> progress, CancellationToken ct)
{
for (int i = 0; i < 100; i++)
{
// Имитация асинхронного вызова, выполнение которого занимает некоторое время
await Task.Delay(1000);
//Проверка запрошена ли отмена
if (ct != null)
{
ct.ThrowIfCancellationRequested();
}
//Отчет о прогрессе
if (progress != null)
{
progress.Report(i);
}
}
}
}
}
Ожидание некоторый период времени
Если вам нужно подождать некоторое время (например, повторить попытку проверки доступности ресурса), обязательно используйте Task.Delay — никогда не используйте Thread.Sleep в этом сценарии.
В ожидании завершения нескольких асинхронных задач
Используйте Task.WaitAny, чтобы дождаться завершения любой задачи. Используйте Task.WaitAll, чтобы дождаться завершения всех задач.
Нужно ли торопиться переходить на C# 7 или 8? Записывайтесь на бесплатный вебинар, где обсудим эту тему.
alhimik45
В принципе стандартные советы. Единственный спорный момент — это Предпочитайте return Task вместо return await.
Если возвращать Task от вызова асинхронного метода без использования await, то метод, который это делает не попадёт в стектрейс, выброшенный внутри вызываемого метода. И порой это может помешать пониманию хода программы. Плюс учитывая следующий совет о неработающих try-catch и using в таких методах, то я бы сформулировал совет наоборот (у нас на работе он был в стайлгайде): всегда делайте await в середине асинхронного стека вызовов вместо return Task.
gdt
Полностью согласен, тот, кто придумал этот совет — никогда не разгребал стектрейсы после такого кода. Мы у себя тоже стараемся так не делать.
mayorovp
А я разгребал, и не вижу никаких сложностей.
gdt
Это замечательно, и я восхищаюсь вашим профессионализмом в этом вопросе. И, однако, сам факт того, что вы лично не увидели никаких сложностей где-то там у себя — совершенно не означает, что их ни у кого нет.
GreenNinja
Так то, что вы их увидели себя — ровно также не означает, что у других они будут. Даже интересно узнать что за случай у вас там такой, по которому вы не могли понять стектрейс.
Пример:
Class1. var result = await class2.Get();
Class2. return class3.Get();
Class3. Бросает ошибку.
Стектрейс будет вида:
Возник эксепшен некоторого типа.
в class3.Get()
в class1.Method()
По цепочке вызова можно понять что и где произошло. Можно понять прочитав тип и сообщение ошибки. Если у вас и возниакют проблемы, то уж извините, но скорее всего проблема в вашем коде…
P.S. Расскажите о своём опыте, пожалуйста, возможно я ошибаюсь.
alhimik45
Сталкивался с такой ситуацией https://dotnetfiddle.net/EsWoQL. Тут по трейсу не поймешь через какой метод прошло выполнение.
GreenNinja
Действительно, ситуация реальная. Но что-то мне подсказывает, что это как-то слишком сферически и вакуумно. На практике обычно и ошибки более конкретные, да и стек трейс очень редко получается коротким.
Но как вывод для себя:
Использовать return await…; повсеместно не стоит, т.к. проблема не понять стектрейс — крайне мала, а создавать на каждый чих конечный автомат — не лучшая идея, с точки зрения производительности. Если писать красивый и понятный код, следовать банальным принципам в программирвоании — то проблема с непонимание сткетрейса пропадёт.
alhimik45, спасибо за пример!
alhimik45
Один запрос к БД нивелирует эти наносекунды выигрыша. Всё таки слишком уж premature optimization сразу лепить return Task и потенциально получать проблемы с разбором стектрейсов и дальнейшем написанием try-catch/using. Да и при наличии проблем с производительностью этот финт точно не в числе первых применяемых должен быть.
gdt
Ниже уже ответили за меня, по сути ситуация была примерно такая же, только перегрузок гораздо больше, и несколько таких слоёв в стек-трейсе (25-35 методов длиной). Всё это было замешано с обфускацией и TaskCompletionSource, в который устанавливали Exception и потом не await'или в некоторых ситуациях — соответственно когда GC собирал эту задачу — прилетал UnobservedTaskException с чудесным стеком. Я не говорю, что нереально разобраться — однако людское время дороже, чем машинное — если вы понимаете, о чём я.