Сейчас появилась масса информации о поддержке новых ключевых слов async и await в Microsoft .NET Framework 4.5. Эту статью следует рассматривать как «вторую ступень» в изучении асинхронного программирования; я исхожу из того, что вы прочитали минимум одну вводную статью по этой тематике. В моей статье не рассказывается ни о какой новой функциональности, поскольку информацию об этом вы найдете в онлайновых источниках вроде Stack Overflow, форумов MSDN и в FAQ по async/await. Я просто выделяю несколько важных принципов, которые могли ускользнуть от вас в залежах доступной документации.
Рекомендации в этой статье в большей мере являются тем, что можно было бы назвать «руководящими принципами» нежели реальными правилами. В каждом из этих руководящих принципов есть исключения. Я буду пояснять, что именно скрывается за каждым из этих принципов, чтобы вы четко понимали, где именно они применимы. Эти принципы суммированы в табл. 1; мы обсудим каждый из них в следующих разделах.
Табл. 1. Сводное описание руководящих принципов асинхронного программирования
Название | Описание | Исключения |
Избегайте async void | Отдавайте предпочтение асинхронным методам Task, а не асинхронным void-методам | Обработчики событий |
Соблюдайте асинхронность от начала до конца | Не смешивайте блокирующий и асинхронный код | Метод main консольной программы |
Конфигурируйте контекст | По возможности используйте ConfigureAwait(false) | Методы, требующие контекст |
Избегайте async void
Асинхронные методы могут вернуть один из трех типов: Task, Task<T> и void. Но естественными возвращаемыми типами для асинхронных методов являются только Task и Task<T>. При преобразовании синхронного кода в асинхронный любой метод, возвращающий тип T, становится асинхронным методом, возвращающим Task<T>, а любой метод, возвращающий void, становится асинхронным методом, возвращающим Task. Следующий фрагмент кода иллюстрирует синхронный метод, возвращающий void, и его асинхронный эквивалент:
void MyMethod()
{
// Выполняем синхронную работу
Thread.Sleep(1000);
}
async Task MyMethodAsync()
{
// Выполняем асинхронную работу
await Task.Delay(1000);
}
Асинхронные методы, возвращающие void, имеют специфическое применение: они делают возможным создание асинхронных обработчиков событий. Обработчики событий естественным образом возвращают void, поэтому асинхронным методам разрешается возвращать void, чтобы вы могли получить асинхронный обработчик событий. Однако некоторая семантика асинхронных void-методов слегка отличается от семантики асинхронного метода, возвращающего Task или Task<T>.
Асинхронные (далее для краткости — async) void-методы имеют другую семантику обработки ошибок. Если async-метод, возвращающий Task или Task<T>, генерирует исключение, оно захватывается и помещается в объект Task. В случае async void-методов никакого объекта Task нет, поэтому любое исключение будет сгенерировано непосредственно в том SynchronizationContext, который был активен в момент запуска этого async void-метода. Рис. 1 демонстрирует, что исключения, генерируемые async void-методами, нельзя перехватывать естественным образом.
Рис. 1. Исключения async void-методов нельзя захватывать с помощью catch
private async void ThrowExceptionAsync()
{
throw new InvalidOperationException();
}
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
try
{
ThrowExceptionAsync();
}
catch (Exception)
{
// Исключение никогда не будет захвачено здесь!
throw;
}
}
Эти исключения можно наблюдать, используя AppDomain.UnhandledException или аналогичное событие захвата всего (catch-all event) для GUI/ASP.NET-приложений, но применение этих событий для регулярной обработки исключений — верный путь к потере возможности дальнейшего сопровождения кода.
Async void-методы также имеют другую семантику композиции. Async-методы, возвращающие Task или Task<T>, легко поддаются композиции с помощью await, Task.WhenAny, Task.WhenAll и т. д. Async-методы, возвращающие void, не предусматривают простого способа уведомления вызвавшего кода о своем завершении. Вы можете легко запустить несколько async void-методов, но не так-то просто определить, когда они закончили работу. Async void-методы будут уведомлять свои SynchronizationContext при запуске и окончании, но собственный SynchronizationContext — слишком сложное решение для обычного прикладного кода.
Async void-методы трудно тестировать. Из-за различий в обработке ошибок и композиции писать модульные тесты, вызывающие async void-методы, сложно. MSTest обеспечивает поддержку асинхронного тестирования только для async-методов, возвращающих Task или Task<T>. Можно установить SynchronizationContext, который обнаруживает окончание выполнения всех async void-методов и собирает любые исключения, но куда легче просто переделать async void-методы так, чтобы они возвращали Task.
Очевидно, что async void-методы имеют ряд недостатков по сравнению с async Task-методами, но они весьма полезны в одном конкретном случае: использовании в качестве асинхронных обработчиков событий. Различия в семантике имеют смысл для таких обработчиков. Они генерируют свои исключения непосредственно в SynchronizationContext, т. е. ведут себя так же, как синхронные обработчики событий. Синхронные обработчики обычно являются закрытыми, поэтому они не подлежат композиции и их нельзя тестировать напрямую. Я предпочитаю такой подход: минимизация кода в асинхронных обработчиках событий, например пусть он ждет на async Task-методе, содержащем реальную логику. Следующий код иллюстрирует этот подход, используя async void-методы в качестве обработчиков событий и не принося в жертву возможности тестирования:
private async void button1_Click(object sender, EventArgs e)
{
await Button1ClickAsync();
}
public async Task Button1ClickAsync()
{
// Выполняем асинхронную работу
await Task.Delay(1000);
}
Async void-методы могут посеять хаос, если вызвавший код не ожидает, что они окажутся асинхронными. Когда возвращается тип Task, вызвавший код знает, что имеет дело с операцией future, а когда возвращаемый тип — void, вызвавший код может предположить, что метод завершается к моменту возврата им управления. У этой проблемы может быть множество неожиданных проявлений. Обычно неправильно предоставлять async-реализацию (или переопределение) void-метода какого-либо интерфейса (или базового класса). Некоторые события также предполагают, что их обработчики завершаются, когда они возвращают управление. Еще одна тонкая ловушка — передача асинхронной лямбды в метод, принимающий параметр Action; в этом случае async-лямбда возвращает void и наследует все проблемы async void-методов. Как общее правило, async-лямбды следует использовать, только если они преобразуются в тип делегата, который возвращает Task (например, Func<Task>).
Подводя итог по первому «руководящему принципу», становится ясным, что вы должны предпочитать async Task-методы async void-методам. Async Task-методы обеспечивают более простую обработку ошибок, возможности композиции и тестирования. Исключение из этого принципа — асинхронные обработчики событий, которые должны возвращать void. Под это исключение подпадают методы, логически являющиеся обработчиками событий, даже если они не являются таковыми в буквальном смысле (например, реализации ICommand.Execute).
Соблюдайте асинхронность от начала до конца
Асинхронный код напоминает мне анекдот про парня, который упомянул, что наш мир висит в космосе, но ему тут же возразила пожилая дама, заявив, что мир покоится на спине гигантской черепахи. Когда юноша поинтересовался, а на чем же стоит черепаха, дама ответила: «очень умно, молодой человек, но там черепахи от начала до конца!». Преобразуя синхронный код в асинхронный, вы обнаружите, что асинхронный код лучше всего работает, если он вызывает и вызывается другим асинхронным кодом — от начала и до конца. Другие тоже обратили внимание на тенденцию к такому распространению в асинхронном программировании и называют ее «заразительной» или сравнивают с вирусом зомбирования. Будь то черепахи или зомби, совершенно точно, что асинхронный код подталкивает программиста к преобразованию в асинхронный код и окружающего его кода. Это поведение свойственно всем типам асинхронного программирования — не только новым ключевым словам async и await.
«Асинхронность от начала до конца» означает, что вы не должны смешивать синхронный и асинхронный код без тщательного рассмотрения возможных последствий. В частности, блокирование на асинхронном коде вызовом Task.Wait или Task.Result обычно является плохой идеей. Это особенно распространенная проблема среди программистов, которые только начинают заниматься асинхронным программированием, преобразуя в асинхронный код лишь малую часть своего приложения и обертывая его в синхронный API, чтобы изолировать от изменений остальное приложение. К сожалению, они сталкиваются с проблемами взаимоблокировок. После ответов на многочисленные вопросы, связанные с асинхронностью, на форумах MSDN, Stack Overflow и по электронной почте могу сказать, что на данный момент новички, только что освоившие азы асинхронного программирования, чаще всего задают такой вопрос: «Почему мой частично асинхронный код попадает во взаимоблокировку?».
На рис. 2 показан простой пример, где один метод блокируется на результате асинхронного метода. Этот код будет нормально работать в консольном приложении, но приведет к взаимоблокировке при вызове из GUI- или ASP.NET-контекста. Это поведение может сбить с толку, особенно учитывая, что пошаговое выполнение в отладчике указывает на то, что причина в выражении await, которое никогда не выполняется. Однако реальная причина взаимоблокировки скрыта выше по стеку вызовов — когда вызывается Task.Wait.
Рис. 2. Распространенная проблема взаимоблокировки при блокировании на асинхронном коде
public static class DeadlockDemo
{
private static async Task DelayAsync()
{
await Task.Delay(1000);
}
// Этот метод приводит к взаимоблокировке,
// когда вызывается в GUI- или ASP.NET-контексте
public static void Test()
{
// Вводим задержку
var delayTask = DelayAsync();
// Ждем окончания задержки
delayTask.Wait();
}
}
Корневая причина этой взаимоблокировки связана с тем, как await обрабатывает контексты. По умолчанию, когда ожидается незавершенный Task, текущий «контекст» захватывается и используется для возобновления метода по окончании выполнения Task. Этот «контекст» — текущий SynchronizationContext, если только он не null, и тогда это текущий TaskScheduler. GUI- и ASP.NET-приложения имеют SynchronizationContext, который разрешает выполнение только одной порции кода единовременно. Когда выражение await завершает выполнение, оно пытается выполнить остальную часть async-метода в рамках захваченного контекста. Но этот контекст уже имеет поток, который (синхронно) ожидает завершения async-метода. Получается, что каждый из них ждет друг друга, вызывая взаимоблокировку.
Заметьте, что в консольных приложениях этой взаимоблокировки не возникает. Они имеют другой SynchronizationContext — пула потоков, поэтому, когда выражение await завершается, остальная часть async-метода планируется к выполнению в потоке из пула. Метод получает возможность закончить выполнение, это приводит к завершению возвращаемой им задачи, и взаимоблокировки нет. Такое различие в поведение может сбить с толку, когда программисты пишут тестовую консольную программу, видят, что частично асинхронный код работает как ожидалось, а затем переносят тот же код в GUI- или ASP.NET-приложение, где он благополучно попадает во взаимоблокировку.
Лучшее решение этой проблемы — разрешить асинхронному коду естественным образом разрастаться по кодовой базе. Если вы последуете этому решению, то увидите, что асинхронный код нужно расширить до его точки входа, обычно обработчика событий или операции контроллера. В консольных приложениях это решение подходит не в полной мере, так как метод Main не может быть асинхронным. Если бы метод Main был асинхронным, он мог бы вернуть управление до окончания своей работы, что привело бы к прекращению программы. На рис. 3 демонстрируется это исключение из данного руководящего принципа: метод Main для консольного приложения — один из немногих случаев, где код может блокироваться на асинхронном методе.
Рис. 4. Метод Main может вызывать Task.Wait или Task.Result
class Program
{
static void Main()
{
MainAsync().Wait();
}
static async Task MainAsync()
{
try
{
// Асинхронная реализация
await Task.Delay(1000);
}
catch (Exception ex)
{
// Обработка исключений
}
}
}
Хотя распространение асинхронности по кодовой базе — лучшее решение, это подразумевает уйму начальной работы над приложением, чтобы увидеть реальные преимущества от перехода на асинхронный код. Существует несколько методик инкрементального преобразования большой кодовой базы в асинхронный код, но эта тематика выходит за рамки данной статьи. В некоторых случаях использование Task.Wait или Task.Result помогает в частичном преобразовании, однако вы должны понимать опасность взаимоблокировки, а также проблему с обработкой ошибок. Последней проблемой мы сейчас и займемся, а как избежать проблемы взаимоблокировки, я покажу позже в этой статье.
Каждый Task будет хранить список исключений. Когда вы ожидаете Task, первое исключение генерируется повторно, чтобы вы могли захватить специфический тип исключения (например, InvalidOperationException). Однако, когда код синхронно блокируется на Task, используя Task.Wait или Task.Result, все исключения обертываются в AggregateException, и это исключение генерируется заново. Вернитесь к рис. 3. Блок try/catch в MainAsync захватит специфический тип исключения, но, если вы поместите try/catch в Main, он всегда будет захватывать AggregateException. Обработка ошибок намного облегчается, когда не приходится иметь дело с AggregateException, поэтому я поместил «глобальный» try/catch в MainAsync.
На данный момент я показал две проблемы с блокированием на асинхронном коде: возможные взаимоблокировки и более сложная обработка ошибок. Но при использовании блокирующего кода в асинхронном методе есть еще одна проблема. Рассмотрим простой пример:
public static class NotFullyAsynchronousDemo
{
// Этот метод синхронно блокирует поток
public static async Task TestNotFullyAsync()
{
await Task.Yield();
Thread.Sleep(5000);
}
}
Этот метод не является полностью асинхронным. Он немедленно возвращает управление, попутно возвращая незаконченную задачу, но при возобновлении он будет синхронно блокировать любой выполняемый в данный момент поток. Если этот метод вызывается из GUI-контекста, он будет блокировать GUI-поток, а если он вызывается из ASP.NET-контекста запроса, то — поток, обрабатывающий текущий ASP.NET-запрос. Асинхронный код работает лучше всего, если он не блокируется синхронно. В табл. 2 дана шпаргалка по заменам синхронных операций асинхронными.
Табл. 2. «Асинхронный стиль» работы
Чтобы… | Вместо… | Используйте… |
Получить результат фоновой задачи | Task.Wait или Task.Result | await |
Ожидать завершения любой задачи | Task.WaitAny | await Task.WhenAny |
Получить результаты нескольких задач | Task.WaitAll | await Task.WhenAll |
Ожидать некий период времени | Thread.Sleep | await Task.Delay |
Подводя итог по второму «руководящему принципу», становится ясным, что вы должны избегать смешения асинхронного и блокирующего кода. Смешанный код может привести к взаимоблокировке, более сложной обработке ошибок и неожиданной блокировке потоков контекста. Исключение из этого принципа — метод Main для консольных приложений и, если вы высококвалифицированный программист, управление частично асинхронной кодовой базой.
Конфигурируйте контекст
Ранее в этой статье я кратко пояснил, как по умолчанию захватывается «контекст», когда ожидается незавершенный Task, и что этот захваченный контекст используется для возобновления async-метода. Пример на рис. 2 показывает, как возобновление с использованием захваченного контекста входит в противоречие с синхронным блокированием и вызывает взаимоблокировку. Это поведение, связанное с контекстом, может создать еще одну проблему — на этот раз с производительностью. По мере роста асинхронных GUI-приложений вы можете обнаружить, что многие малые части всех async-методов используют в качестве контекста GUI-поток. Это приведет к замедлению работы.
Чтобы ослабить остроту этой проблемы, по возможности ожидайте результат ConfigureAwait. Следующий фрагмент кода демонстрирует поведение контекста по умолчанию и применение ConfigureAwait:
async Task MyMethodAsync()
{
// Здесь код выполняется в исходном контексте
await Task.Delay(1000);
// Здесь код выполняется в исходном контексте
await Task.Delay(1000).ConfigureAwait(
continueOnCapturedContext: false);
// Здесь код выполняется без исходного контекста
// (в данном случае – в пуле потоков)
}
Используя ConfigureAwait, вы разрешаете некую долю параллелизма: часть асинхронного кода может выполняться параллельно с GUI-потоком, а не только в нем.
Помимо производительности, ConfigureAwait имеет еще один важный аспект: он помогает избегать взаимоблокировок. Снова взгляните на рис. 2: если добавить ConfigureAwait(false) к строке кода в DelayAsync, взаимоблокировка исключается. На этот раз, когда выражение await завершается, оно пытается выполнить остальную часть async-метода в контексте пула потоков. Метод получает возможность выполнить свою работу, т. е. закончить свою возвращаемую задачу, и взаимоблокировки нет. Эта методика особенно полезна, если вам нужно постепенно преобразовывать свое приложение из синхронного в асинхронное.
Если вы можете после какой-то точки использовать ConfigureAwait внутри метода, я рекомендую вам делать это для каждого await-выражения в этом методе. Вспомните, что контекст захватывается, только если ожидается незавершенный Task; если Task уже выполнен, контекст не захватывается. Некоторые задачи могут выполняться быстрее, чем ожидалось, на другом оборудовании и в других сетях, и вам потребуется корректно обрабатывать возвращенную задачу, которая была выполнена до начала ожидания. Модифицированный пример показан на рис. 4.
Рис. 4. Обработка возвращенной задачи, которая выполнена до начала ожидания
async Task MyMethodAsync()
{
// Здесь код выполняется в исходном контексте
await Task.FromResult(1);
// Здесь код выполняется в исходном контексте
await Task.FromResult(1).ConfigureAwait(continueOnCapturedContext: false);
// Здесь код выполняется в исходном контексте
var random = new Random();
int delay = random.Next(2); // задержка либо 0, либо 1
await Task.Delay(delay).ConfigureAwait(continueOnCapturedContext: false);
// Здесь код может выполняться в исходном контексте,
// но может выполняться и вне контекста.
// То же самое верно, когда вы ожидаете любой Task,
// который может выполниться очень быстро.
}
Не используйте ConfigureAwait, если у вас есть код, которому после выражения await в методе требуется контекст. В случае GUI-приложений это любой код, который манипулирует GUI-элементами, пише в свойства, связанные с данными, или зависит от специфичного для GUI типа вроде Dispatcher/CoreDispatcher. В случае приложений ASP.NET это любой код, который использует HttpContext.Current или формирует ASP.NET-ответ, включая выражения return в операциях контроллера. На рис. 5 демонстрируется один из распространенных шаблонов в GUI-приложениях: асинхронный обработчик событий отключает свой элемент управления где-то в начале метода, выполняет какие-то выражения await, а затем вновь включает этот элемент управления в конце обработчика; данный обработчик событий не может отказаться от контекста, так как ему нужно заново включить свой элемент управления.
Рис. 5. Асинхронный обработчик событий, который отключает, а затем включает свой элемент управления
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
try
{
// Здесь нельзя использовать ConfigureAwait...
await Task.Delay(1000);
}
finally
{
// Так как здесь нужен контекст
button1.Enabled = true;
}
}
У каждого async-метода свой контекст, поэтому, если один async-метод вызывает другой async-метод, их контексты независимы. На рис. 6 показана небольшая модификация варианта с рис. 5.
Рис. 6. У каждого асинхронного метода свой контекст
private async Task HandleClickAsync()
{
// Здесь нельзя использовать ConfigureAwait
await Task.Delay(1000).ConfigureAwait(continueOnCapturedContext: false);
}
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
try
{
// Здесь нельзя использовать ConfigureAwait
await HandleClickAsync();
}
finally
{
// Мы вернулись в исходный контекст для этого метода
button1.Enabled = true;
}
}
Контекстно-независимый код обеспечивает более высокую степень повторного использования. Попробуйте создать барьер в своем коде между контекстно-зависимым и контекстно-независимым кодом и свести к минимуму контекстно-зависимый код. На рис. 6 я посоветовал поместить всю базовую логику обработчика событий в тестируемый и контекстно-независимый async Task-метод, оставив в контекстно-зависимом обработчике событий лишь самый минимум кода. Даже если вы пишете приложение ASP.NET и у вас есть базовая библиотека, которая потенциально может использоваться совместно с настольными приложениями, подумайте о применении ConfigureAwait в библиотечном коде.
Подводя итог по третьему «руководящему принципу», становится ясным, что вы должны по возможности использовать ConfigureAwait. Контекстно-независимый код работает быстрее в GUI-приложениях и является полезной методикой предотвращения взаимоблокировок при работе с частично асинхронной кодовой базой. Исключения из этого принципа — методы, требующие контекста.
Изучайте свой инструментарий
Вы должны много чего знать об async и await, и вполне естественно, что иногда слегка теряешься. В табл. 3 дан краткий справочник по решениям часто встречающихся проблем.
Табл. 3. Решения распространенных проблем с асинхронным кодом
Проблема | Решение |
Создание задачи для выполнения кода | Task.Run или TaskFactory.StartNew (не конструктор Task или Task.Start) |
Создание оболочки задачи для какой-либо операции или события | TaskFactory.FromAsync или TaskCompletionSource<T> |
Поддержка отмены | CancellationTokenSource и CancellationToken |
Отчет о прогрессе | IProgress<T> и Progress<T> |
Обработка потоков данных | TPL Dataflow или Reactive Extensions |
Синхронизация доступа к общему ресурсу | SemaphoreSlim |
Асинхронная инициализация ресурса | AsyncLazy<T> |
Структуры «провайдер-потребитель) с поддержкой async | TPL Dataflow или AsyncCollection<T> |
Первая проблема — создание задачи. Очевидно, что создать задачу может async-метод и это самый простой вариант. Если вам нужно выполнять код в пуле потоков, используйте Task.Run. Если вы хотите создать оболочку задачи для существующей асинхронной операции или события, используйте TaskCompletionSource<T>. Следующая распространенная проблема — как обрабатывать отмену и отчеты о прогрессе. Базовая библиотека классов (BCL) включает типы, специально предназначенные для решениях этих вопросов: CancellationTokenSource/CancellationToken и IProgress<T>/Progress<T>. Асинхронный код должен применять Asynchronous Pattern на основе Task (TAP) (msdn.microsoft.com/library/hh873175), где подробно объясняются создание задачи, отмена и отчеты о прогрессе.
Другая проблема — как обрабатывать потоки асинхронных данных. Задачи — отличная штука, но они могут возвращать только один объект и выполняются только раз. Для асинхронных потоков можно использовать либо TPL Dataflow, либо Reactive Extensions (Rx). TPL Dataflow создает «замкнутую сеть» («mesh»). Rx — более мощное и эффективное решение, но труднее в изучении. Как TPL Dataflow, так и Rx имеют методы с поддержкой async (async-ready), и хорошо работают с асинхронным кодом.
Одно лишь то, что вам код асинхронный, еще не означает, что он безопасен. Общие ресурсы по-прежнему нужно защищать от одновременного доступа, и это усложняется тем фактом, что вы не можете ожидать из блокировки. Вот пример асинхронного кода, который может повредить общее состояние, если он выполняется дважды — пусть даже и в одном и том же потоке:
int value;
Task<int> GetNextValueAsync(int current);
async Task UpdateValueAsync()
{
value = await GetNextValueAsync(value);
}
Проблема в том, что метод считывает значение и приостанавливается на
выражении await, а когда метод возобновляется, он предполагает, что
значение не изменялось. Чтобы решить эту проблему, класс SemaphoreSlim был
дополнен перегруженными версиями WaitAsync с поддержкой async.
SemaphoreSlim.WaitAsync демонстрируется на
рис. 7.
Рис. 7. SemaphoreSlim обеспечивает асинхронную
синхронизацию
SemaphoreSlim mutex = new SemaphoreSlim(1);
int value;
Task<int> GetNextValueAsync(int current);
async Task UpdateValueAsync()
{
await mutex.WaitAsync().ConfigureAwait(false);
try
{
value = await GetNextValueAsync(value);
}
finally
{
mutex.Release();
}
}
Асинхронный код часто используется для инициализации ресурса, который потом кешируется и становится общим. Встроенного типа для этого нет, но Стефен Тауб (Stephen Toub) разработал AsyncLazy<T>, который действует как комбинация Task<T> и Lazy<T>. Исходный тип описывается в его блоге (bit.ly/dEN178), а обновленная версия доступна в соей библиотеке AsyncEx (nitoasyncex.codeplex.com).
Наконец, иногда возникает потребность в некоторых структурах данных с поддержкой async. TPL Dataflow предоставляет BufferBlock<T>, который действует как очередь «провайдер-потребитель» с поддержкой async (producer/consumer queue). В качестве альтернативы AsyncEx предоставляет AsyncCollection<T>, который является асинхронной версией BlockingCollection<T>.
Надеюсь, что принципы и решения, изложенные в этой статье, окажутся полезными вам. Async — по-настоящему потрясающее языковое средство, и теперь пора воспользоваться им!