Рекомендации по асинхронному программированию
Продукты и технологии:
В статье рассматриваются:
Рекомендации в этой статье в большей мере являются тем, что можно было бы назвать «руководящими принципами» нежели реальными правилами. В каждом из этих руководящих принципов есть исключения. Я буду пояснять, что именно скрывается за каждым из этих принципов, чтобы вы четко понимали, где именно они применимы. Эти принципы суммированы в табл. 1; мы обсудим каждый из них в следующих разделах.
Табл. 1. Сводное описание руководящих принципов асинхронного программирования
| Название | Описание | Исключения |
| Избегайте async void | Отдавайте предпочтение асинхронным методам Task, а не асинхронным void-методам | Обработчики событий |
| Соблюдайте асинхронность от начала до конца | Не смешивайте блокирующий и асинхронный код | Метод main консольной программы |
| Конфигурируйте контекст | По возможности используйте ConfigureAwait(false) | Методы, требующие контекст |
Избегайте async void
Рис. 1. Исключения async void-методов нельзя захватывать с помощью catch
Эти исключения можно наблюдать, используя AppDomain.UnhandledException или аналогичное событие захвата всего (catch-all event) для GUI/ASP.NET-приложений, но применение этих событий для регулярной обработки исключений — верный путь к потере возможности дальнейшего сопровождения кода.
Очевидно, что async void-методы имеют ряд недостатков по сравнению с async Task-методами, но они весьма полезны в одном конкретном случае: использовании в качестве асинхронных обработчиков событий. Различия в семантике имеют смысл для таких обработчиков. Они генерируют свои исключения непосредственно в SynchronizationContext, т. е. ведут себя так же, как синхронные обработчики событий. Синхронные обработчики обычно являются закрытыми, поэтому они не подлежат композиции и их нельзя тестировать напрямую. Я предпочитаю такой подход: минимизация кода в асинхронных обработчиках событий, например пусть он ждет на async Task-методе, содержащем реальную логику. Следующий код иллюстрирует этот подход, используя async void-методы в качестве обработчиков событий и не принося в жертву возможности тестирования:
Async void-методы могут посеять хаос, если вызвавший код не ожидает, что они окажутся асинхронными. Когда возвращается тип Task, вызвавший код знает, что имеет дело с операцией future, а когда возвращаемый тип — void, вызвавший код может предположить, что метод завершается к моменту возврата им управления. У этой проблемы может быть множество неожиданных проявлений. Обычно неправильно предоставлять async-реализацию (или переопределение) void-метода какого-либо интерфейса (или базового класса). Некоторые события также предполагают, что их обработчики завершаются, когда они возвращают управление. Еще одна тонкая ловушка — передача асинхронной лямбды в метод, принимающий параметр Action; в этом случае async-лямбда возвращает void и наследует все проблемы async void-методов. Как общее правило, async-лямбды следует использовать, только если они преобразуются в тип делегата, который возвращает Task (например, Func ).
Подводя итог по первому «руководящему принципу», становится ясным, что вы должны предпочитать 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. Распространенная проблема взаимоблокировки при блокировании на асинхронном коде
Корневая причина этой взаимоблокировки связана с тем, как 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
Хотя распространение асинхронности по кодовой базе — лучшее решение, это подразумевает уйму начальной работы над приложением, чтобы увидеть реальные преимущества от перехода на асинхронный код. Существует несколько методик инкрементального преобразования большой кодовой базы в асинхронный код, но эта тематика выходит за рамки данной статьи. В некоторых случаях использование 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.
На данный момент я показал две проблемы с блокированием на асинхронном коде: возможные взаимоблокировки и более сложная обработка ошибок. Но при использовании блокирующего кода в асинхронном методе есть еще одна проблема. Рассмотрим простой пример:
Этот метод не является полностью асинхронным. Он немедленно возвращает управление, попутно возвращая незаконченную задачу, но при возобновлении он будет синхронно блокировать любой выполняемый в данный момент поток. Если этот метод вызывается из 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:
Используя ConfigureAwait, вы разрешаете некую долю параллелизма: часть асинхронного кода может выполняться параллельно с GUI-потоком, а не только в нем.
Помимо производительности, ConfigureAwait имеет еще один важный аспект: он помогает избегать взаимоблокировок. Снова взгляните на рис. 2: если добавить ConfigureAwait(false) к строке кода в DelayAsync, взаимоблокировка исключается. На этот раз, когда выражение await завершается, оно пытается выполнить остальную часть async-метода в контексте пула потоков. Метод получает возможность выполнить свою работу, т. е. закончить свою возвращаемую задачу, и взаимоблокировки нет. Эта методика особенно полезна, если вам нужно постепенно преобразовывать свое приложение из синхронного в асинхронное.
Если вы можете после какой-то точки использовать ConfigureAwait внутри метода, я рекомендую вам делать это для каждого await-выражения в этом методе. Вспомните, что контекст захватывается, только если ожидается незавершенный Task; если Task уже выполнен, контекст не захватывается. Некоторые задачи могут выполняться быстрее, чем ожидалось, на другом оборудовании и в других сетях, и вам потребуется корректно обрабатывать возвращенную задачу, которая была выполнена до начала ожидания. Модифицированный пример показан на рис. 4.
Рис. 4. Обработка возвращенной задачи, которая выполнена до начала ожидания
Не используйте ConfigureAwait, если у вас есть код, которому после выражения await в методе требуется контекст. В случае GUI-приложений это любой код, который манипулирует GUI-элементами, пише в свойства, связанные с данными, или зависит от специфичного для GUI типа вроде Dispatcher/CoreDispatcher. В случае приложений ASP.NET это любой код, который использует HttpContext.Current или формирует ASP.NET-ответ, включая выражения return в операциях контроллера. На рис. 5 демонстрируется один из распространенных шаблонов в GUI-приложениях: асинхронный обработчик событий отключает свой элемент управления где-то в начале метода, выполняет какие-то выражения await, а затем вновь включает этот элемент управления в конце обработчика; данный обработчик событий не может отказаться от контекста, так как ему нужно заново включить свой элемент управления.
Рис. 5. Асинхронный обработчик событий, который отключает, а затем включает свой элемент управления
У каждого async-метода свой контекст, поэтому, если один async-метод вызывает другой async-метод, их контексты независимы. На рис. 6 показана небольшая модификация варианта с рис. 5.
Рис. 6. У каждого асинхронного метода свой контекст
Контекстно-независимый код обеспечивает более высокую степень повторного использования. Попробуйте создать барьер в своем коде между контекстно-зависимым и контекстно-независимым кодом и свести к минимуму контекстно-зависимый код. На рис. 6 я посоветовал поместить всю базовую логику обработчика событий в тестируемый и контекстно-независимый async Task-метод, оставив в контекстно-зависимом обработчике событий лишь самый минимум кода. Даже если вы пишете приложение ASP.NET и у вас есть базовая библиотека, которая потенциально может использоваться совместно с настольными приложениями, подумайте о применении ConfigureAwait в библиотечном коде.
Подводя итог по третьему «руководящему принципу», становится ясным, что вы должны по возможности использовать ConfigureAwait. Контекстно-независимый код работает быстрее в GUI-приложениях и является полезной методикой предотвращения взаимоблокировок при работе с частично асинхронной кодовой базой. Исключения из этого принципа — методы, требующие контекста.
Изучайте свой инструментарий
Вы должны много чего знать об async и await, и вполне естественно, что иногда слегка теряешься. В табл. 3 дан краткий справочник по решениям часто встречающихся проблем.
Табл. 3. Решения распространенных проблем с асинхронным кодом
| Проблема | Решение |
| Создание задачи для выполнения кода | Task.Run или TaskFactory.StartNew (не конструктор Task или Task.Start) |
| Создание оболочки задачи для какой-либо операции или события | TaskFactory.FromAsync или TaskCompletionSource |
| Поддержка отмены | CancellationTokenSource и CancellationToken |
| Отчет о прогрессе | IProgress и Progress |
| Обработка потоков данных | TPL Dataflow или Reactive Extensions |
| Синхронизация доступа к общему ресурсу | SemaphoreSlim |
| Асинхронная инициализация ресурса | AsyncLazy |
| Структуры «провайдер-потребитель) с поддержкой async | TPL Dataflow или AsyncCollection |
Другая проблема — как обрабатывать потоки асинхронных данных. Задачи — отличная штука, но они могут возвращать только один объект и выполняются только раз. Для асинхронных потоков можно использовать либо TPL Dataflow, либо Reactive Extensions (Rx). TPL Dataflow создает «замкнутую сеть» («mesh»). Rx — более мощное и эффективное решение, но труднее в изучении. Как TPL Dataflow, так и Rx имеют методы с поддержкой async (async-ready), и хорошо работают с асинхронным кодом.
Одно лишь то, что вам код асинхронный, еще не означает, что он безопасен. Общие ресурсы по-прежнему нужно защищать от одновременного доступа, и это усложняется тем фактом, что вы не можете ожидать из блокировки. Вот пример асинхронного кода, который может повредить общее состояние, если он выполняется дважды — пусть даже и в одном и том же потоке:
Task GetNextValueAsync(int current);
async Task UpdateValueAsync()
value = await GetNextValueAsync(value);
Проблема в том, что метод считывает значение и приостанавливается на выражении await, а когда метод возобновляется, он предполагает, что значение не изменялось. Чтобы решить эту проблему, класс SemaphoreSlim был дополнен перегруженными версиями WaitAsync с поддержкой async. SemaphoreSlim.WaitAsync демонстрируется на рис. 7.
Рис. 7. SemaphoreSlim обеспечивает асинхронную синхронизацию
SemaphoreSlim mutex = new SemaphoreSlim(1);
Task GetNextValueAsync(int current);
async Task UpdateValueAsync()
value = await GetNextValueAsync(value);
Надеюсь, что принципы и решения, изложенные в этой статье, окажутся полезными вам. Async — по-настоящему потрясающее языковое средство, и теперь пора воспользоваться им!
Выражаю благодарность за рецензирование статьи эксперту Стефену Таубу (Stephen Toub).
Что такое async/await в JavaScript: примеры использования
Паттерн async / await используют во многих языках программирования, чтобы выполнять асинхронный код. Асинхронность дает программе возможность производить несколько операций параллельно.
В JavaScript async и await создают код на основе промисов ( Promise ). Промисы в JavaScript можно сравнить с похожими конструкциями — Future для Java или Task для C#. Итак:
Паттерн async / await помогает упорядочить код и избавиться от длинных цепочек промисов и функций обратного вызова при написании асинхронного кода. При этом выполнение основных функций программы не прерывается и не тормозится.
Асинхронные функции поддерживаются большинством современных браузеров, поэтому их можно использовать для написания кода везде, за исключением Internet Explorer и Opera Mini.
Асинхронные функции п оддерживаются большинством браузеров
Функции используются во многих языках (C# 5.0, C ++, Python 3.5, Kotlin 1.1), в в этой статье мы разберем работу async и await на примере JavaScript.
Async
Синтаксис async
Асинхронная функция выглядит вот так:
async состоит из следующих частей:
Пример использования async
Давайте рассмотрим на примере, как используется async :
Результат выполнения кода:
Результат выполнения кода
Ловим ошибки в функциях async
Асинхронные функции и методы не вызывают ошибок. Если вы не используете специальные ключевые слова, то функция всегда будет возвращать промис, неважно — разрешенный или отклоненный.
Давайте запустим выполнение кода и посмотрим, что получилось:
Результат выполнения кода
Что такое await
Как мы выяснили, async делает функцию асинхронной и возращает промисы. А элемент await ждет результатов выполнения промисов и прописывается в коде перед ними.
Добавление в тело функции await вводит в приложение синхронное поведение несмотря на то, что функция сама по себе остается асинхронной.
Синтаксис await
[rv] = await expression
Пришло время посмотреть, как выглядит функция с использованием промисов и асинхронная функция с использованием async / await :
Фундаментальное различие в поведении кода состоит в том, что при асинхронности может одновременно разрешаться несколько промисов.
При использовании исключительно промисов, как в первом примере, все они будут выполняться последовательно, каждый с задержкой в несколько секунд, что существенно увеличивает время ожидания ответа от программы.
Пример использования await
Рассмотрим пример, где await ждет разрешения промиса и затем возвращает значение, которое получил:
В консоли результат работы кода будет выглядеть так:
Результат выполнения кода
Где нельзя использовать await
Все довольно просто — так как async / await идут в связке, то мы не можем использовать вторую часть этого уравнения без первой. То есть await не будет работать в коде верхнего уровня вне асинхронной функции.
await может работать самостоятельно, но только с модулями JavaScript. В этом случае await ждет выполнения программы дочернего модуля, прежде чем запуститься самому. И при этом он не блокирует загрузку других дочерних модулей.
Давайте рассмотрим пример использования await в модуле JavaScript:
Коротко об особенностях async/await
О том, как работает асинхронная функция в одной картинке / scoutapm.com
JavaScript async/await: что хорошего, в чём опасность и как применять?
Что хорошего в async/await
Важнейшим преимуществом async/await является синхронный стиль программирования. Давайте посмотрим на следующий пример:
И еще из приятного — это не только читаемость: async/await по умолчанию поддерживается всеми основными современными браузерами.
Async / wait может ввести в заблуждение
Автор некоторых статей сравнивают async / await с промисами и утверждают, что это следующее поколение в эволюции асинхронного программирования JavaScript, — при всем моем уважении, с этой точкой зрения я не согласен. Async / await — это улучшение, но в то же время не стоит считать его чем то более значительным, чем синтаксический сахар, потому что стиль программирования он кардинально не меняет.
Рассмотрим функции getBooksByAuthorWithAwait() и getBooksByAuthorWithPromises() в приведенном выше примере. Обратите внимание, что они не только идентичны функционально, но и имеют точно такой же интерфейс!
Это означает, что getBooksByAuthorWithAwait() вернет промис, если вы вызовете эту функцию напрямую.
Ну, это не обязательно плохо. Только название await может вызвать мысль: «О, отлично, так можно преобразовать асинхронные функции в синхронные функции»,- что на самом деле неверно.
Ловушки Async/await
Слишком много последовательностей
Благодаря await может создастся впечатление, что код будет исполняться последовательно, имейте в виду, что он все еще асинхронный, и нужно быть осторожными, чтобы не нагромождать слишком много последовательностей.
Этот код выглядит правильно с точки зрения логики. Однако работать он будет некорректно.
Вот правильный способ:
Или того хуже, вдруг вам захочется получить список книг одна за другой, тогда вам придется прибегнуть к промисам:
Обработка ошибок
try…catch
Error в catch — это то самое отклоненное значение. После того, как мы поймали исключение, у нас есть несколько способов работы с ним:
Преимущества использования try. catch :
В этом подходе есть и один недостаток. Так как try. catch поймает любое исключение в блоке, то будут выброшены ошибки, которые в обычных случаях промисами не отлавливаются. Для того, чтобы понять эту идею, взгляните на пример:
Запустите этот код и вы получите ошибку ReferenceError: cb is not defined в консоли, черного цвета. Ошибка выводилась с помощью console.log(), но не самим JavaScript. Иногда это может быть фатальным: если BookModel заключен глубоко в ряд вызовов функций, и один из вызовов проглатывает ошибку, тогда будет очень сложно найти неопределенную ошибку, подобную этой.
Функция возвращает оба значения
Другой способ обработки ошибок практикуется в языке Go. Он позволяет async-функции возвращать как ошибку, так и результат. За более подробным описанием направляю вас в эту статью.
Короче говоря, вы можете использовать async-функцию следующим образом:
Лично мне не нравится этот подход, поскольку он привносит стиль Go в JavaScript, который кажется неестественным, но в некоторых случаях это может оказаться весьма полезным.
Вспомните функциональность await : эта функция будет ждать, пока промис завершит свою работу. Также, пожалуйста, помните, что promise.catch() тоже вернет промис! Поэтому мы можем написать обработку ошибок следующим образом:
В этом подходе есть две незначительные проблемы:











