Асинхронное программирование с использованием ключевых слов async и await
Модель асинхронного программирования на основе задач (TAP) предоставляет абстракцию асинхронного кода. Вы пишете код как последовательность операторов, как обычно. Вы можете читать этот код, как если бы каждая инструкция завершалась до начала следующей. Компилятор выполняет множество преобразований, так как некоторые из этих инструкций могут начать работу и вернуть Task, представляющий текущую работу.
Это и есть цель такого синтаксиса: сделать возможным код, который читается как последовательность операторов, но выполняется в гораздо более сложном порядке на основе выделения внешних ресурсов и при завершении задач. Это аналогично тому, как люди дают инструкции для процессов, которые включают асинхронные задачи. В этой статье вы будете использовать пример инструкции для приготовления завтрака, чтобы увидеть, как ключевые слова async и await упрощают понимание кода, который включает в себя серию асинхронных инструкций. Можно написать инструкции аналогично следующему списку, чтобы объяснить, как приготовить завтрак.
- Налить чашку кофе.
- Нагреть сковородку, а затем поджарить два яйца.
- Поджарить три куска бекона.
- Сделать два тоста.
- Намазать тосты маслом и джемом.
- Налить стакан апельсинового сока.
Если у вас есть кулинарный опыт, вы бы выполняли эти инструкции асинхронно. Сначала вы бы поставили сковородку на огонь, а затем занялись бы беконом. Потом бы поставили тосты, а вслед за этим принялись бы за яичницу. На каждом этапе процесса необходимо запустить задачу, а затем обратить внимание на другие задачи, которые требуют вашего внимания.
Приготовление завтрака представляет собой хороший пример асинхронной непараллельной работы. Один пользователь (или поток) может обрабатывать все эти задачи. Продолжая аналогию с завтраком, один человек может приготовить завтрак асинхронно путем запуска очередной задачи до завершения предыдущей. Готовка продолжается вне зависимости от того, следит ли за ней кто-либо. Как только вы начали греть сковороду для яичницы, можно заняться обжаркой бекона. Когда бекон будет жариться, можно поместить хлеб в тостер.
Для параллельного алгоритма потребовалось бы несколько поваров (или потоков). Один готовит яйца, один — бекон и т. д. Каждый из них будет заниматься только одной задачей. Каждый повар (или поток) будет заблокирован синхронным ожиданием готовности бекона или тостов.
Теперь рассмотрим эти же инструкции, написанные на C#.
Синхронное приготовление завтрака заняло примерно 30 минут, так как общее время является суммой времен выполнения каждой задачи.
Классы Coffee , Egg , Bacon , Toast и Juice пусты. Они просто являются классами меток, используемыми в целях демонстрации, не содержат свойств и не используются для выполнения других задач.
Компьютеры не рассматривают эти инструкции так же, как люди. Компьютер будет задерживаться над каждой инструкцией до момента, когда работа будет завершена, прежде чем перейдет к следующему оператору. Вряд ли такой завтрак вас устроит. Более поздние задачи не будут начаты до завершения предыдущих. Потребуется гораздо больше времени для приготовления завтрака, к тому же часть уже остынет еще до подачи.
Если требуется, чтобы компьютер асинхронно выполнил инструкции выше, необходимо писать асинхронный код.
Эти проблемы важны для программ, которые вы пишете уже сегодня. При написании клиентских программ требуется, чтобы пользовательский интерфейс реагировал на ввод данных пользователем. Приложения не должны блокировать телефон при скачивании данных из Интернета. При написании серверных программ не стоит блокировать потоки. Эти потоки могут обслуживать другие запросы. Использование синхронного кода в ситуации, когда существуют асинхронные альтернативы, мешает масштабированию с минимальными затратами. Вы платите за эти заблокированные потоки.
Успешные современные приложения требуют использования асинхронного кода. Без поддержки языком при написании асинхронного кода требуются обратные вызовы, события завершения или другие способы, заслоняющие исходное назначение кода. Преимуществом синхронного кода является то, что эти пошаговые действия упрощают проверку и анализ. Традиционные асинхронные модели заставляют сосредоточиваться на асинхронности кода, а не на фундаментальных действиях в нем.
Не блокировать, а использовать await
Приведенный выше код демонстрирует дурную практику: использование синхронного кода для выполнения асинхронных операций. В таком виде код блокирует выполняющий поток, не позволяя делать другие действия. Он не будет прерван, пока задачи выполняются. Все равно что стоять и смотреть на тостер, пока поджаривается хлеб. Пока тост не готов, вы всех игнорируете.
Давайте начнем менять этот код, чтобы не блокировать поток во время выполнения задачи. Ключевое слово await позволяет обойтись без блокировки для запуска задачи, а затем продолжить выполнение, когда задача завершается. Простая асинхронная версия кода для приготовления завтрака будет выглядеть так:
Общее затраченное время примерно такое же, как у начальной синхронной версии. Этот код можно улучшить, используя ряд ключевых возможностей асинхронного программирования.
Тексты методов FryEggsAsync , FryBaconAsync и ToastBreadAsync были обновлены так, чтобы возвращать Task<Egg> , Task<Bacon> и Task<Toast> , соответственно. Методы переименованы и теперь содержат суффикс "Async". Их реализации показаны в составе окончательной версии далее в этой статье.
Этот код не блокируется при приготовлении яиц или бекона. Этот код, однако, не запускает других задач. По-прежнему придется поместить тост в тостер и смотреть на него, пока он не выскочит. Но по крайней мере можно отвечать всем, кто хочет вашего внимания. В ресторане, где будет размещаться несколько заказов, повар сможет начать готовить другой завтрак, пока первый готовится.
Теперь поток завтрака не блокируется в ожидании любой запущенной задачи, которая еще не завершена. Для некоторых приложений это изменение — все, что требуется. Приложение с графическим интерфейсом будет отвечать пользователю после этого изменения. Тем не менее в этом сценарии нам нужно больше. Нам не требуется последовательное выполнение каждой из задач компонента. Лучше запускать каждую из задач компонента, не ожидая завершения предыдущей задачи.
Одновременный запуск задач
Во многих случаях требуется запускать сразу несколько независимых задач. Затем, когда каждая задача завершается, можно продолжить другую работу, которая уже готова к этому. В нашей аналогии — так завтрак готовится быстрее. Вы также приготовите все примерно в одно и то же время. Вы получите горячий завтрак.
System.Threading.Tasks.Task и связанные типы — это классы, позволяющие делать выводы о задачах, которые находятся в процессе выполнения. Это позволяет писать код, который точнее определяет, как будет фактически готовиться завтрак. Вы начинаете готовить яйца, бекон и тосты примерно в одно и то же время. По мере необходимости вы обращаете внимание на отдельные задачи, переходите к другим, а затем ждете третьих, которые нуждаются в обработке.
Вы начинаете задачу и удерживаете объект Task, представляющий работу. Вы вызываете await для каждой задачи, прежде чем начать работу с ее результатами.
Давайте внесем эти изменения в код для приготовления завтрака. Первым делом сохраним задачи для отдельных операций при их запуске, чтобы не ждать их:
Затем вы можете переместить инструкции await для бекона и яиц в конец метода, сразу перед подачей завтрака:
Асинхронное приготовление завтрака заняло примерно 20 минут. Это позволило сэкономить время, так как некоторые задачи можно было выполнять параллельно.
Предыдущий код работает лучше. Запуск всех асинхронных задач выполняется за один раз. Вы ожидаете каждую задачу только в том случае, когда требуются результаты. Приведенный выше код может быть похож на код в веб-приложении, который отправляет запросы для разных микрослужб, а затем объединяет результаты в одну страницу. Вы отправляете все запросы сразу, а затем вызываете await , чтобы соединить все задачи и создать веб-страницу.
Сочетаемость задач
У вас все готово для завтрака в одно и то же время, за исключением тостов. Приготовление тоста — композиция асинхронной операции (поджарить хлеб) и синхронной операции (добавить масло и джем). Обновление этого кода иллюстрирует важную концепцию:
композиция асинхронной операции, за которой следует синхронная задача, является асинхронной операцией. Говоря иначе, если какая-либо часть операции является асинхронной, то и вся операция является асинхронной.
Приведенный выше код показал, что можно использовать объекты Task или Task<TResult> для хранения выполняемых задач. Вы вызываете await для каждой задачи, прежде чем использовать ее результат. Следующим шагом является создание методов, которые представляют сочетание другой работы. Перед подачей завтрака требуется дождаться задачи, представляющей поджарку хлеба перед добавлением масла и джема. Вы можете представить эту работу следующим кодом:
Предыдущий метод имеет async модификатор в сигнатуре. Он сообщает компилятору, что этот метод содержит инструкцию await ; она содержит асинхронные операции. Этот метод представляет задачу, в рамках которой поджаривается хлеб, а затем добавляется масло и джем. Этот метод возвращает Task<TResult>, представляющий сочетание этих трех операций. Теперь вид основного блока кода будет таким:
Предыдущее изменение показывает важную методику для работы с асинхронным кодом. Составные задачи можно создавать, разделяя операции в новом методе, который возвращает задачу. Вы можете выбрать, когда следует ожидать выполнения созданной задачи. Одновременно можно запускать другие задачи.
Асинхронные исключения
До этого момента вы неявно предполагали, что все эти задачи были выполнены успешно. Асинхронные методы создают исключения, точно так же, как и синхронные методы. В целом поддержка исключений и обработки ошибок в асинхронном коде преследует те же цели, что и поддержка асинхронного кода в целом: необходимо написать код, который выглядит как последовательность синхронных инструкций. Когда задачи не могут быть успешно завершены, они выдают исключения. Клиентский код может перехватывать эти исключения, когда запущенная задача ожидается ( awaited ). Например, предположим, что тостер загорается во время приготовления тоста. Это можно смоделировать, изменив метод ToastBreadAsync следующим образом:
При компиляции предыдущего кода будет выдано предупреждение о наличии недостижимого кода. Это сделано намеренно, поскольку после того, как тостер загорится, дальнейшие операции не будут выполняться обычным образом.
Запустите приложение после внесения этих изменений, и вы получите следующий результат:
Обратите внимание, что между возгоранием тостера и выдачей исключения выполняется довольно много задач. Если задача, которая выполняется асинхронно, вызывает исключение, эта задача завершается ошибкой. Созданное исключение находится в свойстве Task.Exception объекта Task. Задачи, завершившиеся с ошибкой, выдают исключение, когда эти задачи ожидаются.
Существует два важных механизма, работу которых нужно понимать: как исключение хранится в задаче, завершившейся с ошибкой, и как оно распаковывается и выдается повторно, когда код ожидает задачу, завершившуюся с ошибкой.
Когда асинхронный код выдает исключение, это исключение хранится в Task . Свойство Task.Exception имеет значение System.AggregateException, потому что во время асинхронного выполнения может быть выдано несколько исключений. Все выданные исключения добавляются в коллекцию AggregateException.InnerExceptions. Если это свойство Exception имеет значение null, то создается новое исключение AggregateException , и выданное исключение становится первым элементом в коллекции.
Чаще всего для задачи, завершившейся с ошибкой, свойство Exception содержит только одно исключение. Когда код ожидает ( awaits ) задачу, завершившуюся сбоем, первое исключение в коллекции AggregateException.InnerExceptions выдается повторно. Поэтому в выходных данных этого примера мы видим InvalidOperationException вместо AggregateException . Извлечение первого внутреннего исключения делает работу с асинхронными методами настолько похожей на работу с синхронными методами, насколько это возможно. Если ваш сценарий может создать несколько исключений, вы можете проверить свойство Exception в коде.
Перед тем как продолжить, закомментируйте эти две строки в методе ToastBreadAsync . Ведь вы не хотите, чтобы у вас появилась еще одна проблема:
Эффективное ожидание задач
Ряд инструкций await в конце приведенного выше кода можно улучшить с помощью методов класса Task . Один из этих API — WhenAll, который возвращает Task; она завершается после завершения всех задач в списке аргументов, как показано в следующем коде:
Другой вариант — использовать WhenAny, который возвращает Task<Task> , выполняемый по завершении любого из своих аргументов. Можно ожидать возвращенной задачи, зная, что она уже завершена. В следующем коде показано, как использовать WhenAny для ожидания первой задачи, чтобы затем обработать ее результат. После обработки результата завершенной задачи удалим ее из списка задач, передаваемого в WhenAny .
После всех этих изменений окончательная версия кода выглядит так:
Окончательная версия асинхронного приготовления завтрака заняла примерно 15 минут, так как в этом случае некоторые задачи выполнялись параллельно. Также одновременно отслеживалось несколько задач из кода, и действия выполнялись только тогда, когда это было необходимо.
Этот итоговый код выполняется асинхронно. Он более точно отражает, как пользователь будет готовить завтрак. Сравните предыдущий код с первым примером кода в этой статье. Основные действия по-прежнему очевидны при прочтении. Этот код можно прочитать так же, как указания по приготовлению завтрака в начале этой статьи. Возможности языка для async и await делают возможными преобразования, которые любой человек производит, выполняя эти инструкции: запуск задач без блокировки в ожидании их завершения.