Async/await в Swift 5.5: асинхронность «здорового человека»
Не так давно WWDC21 представила новый интерфейс асинхронности async/await. Это одно из самых важных обновлений Swift за последнее время. Меня, как iOS-разработчика компании IRLIX, это событие не могло оставить равнодушной. Я решила вникнуть в нюансы работы async/await и сейчас хочу поделиться своим пониманием механизма, а также показать все его внешние и некоторые внутренние преимущества.
Для начала давайте разберемся с терминологией. Что такое async? Async означает «асинхронный». Этот термин можно рассматривать как атрибут метода, показывающий, что сам метод выполняет асинхронную работу. Что такое await? Await — это ключевое слово, которое будет использоваться для вызова асинхронных методов. Мы можем рассматривать их как лучших друзей в Swift, ведь они никогда не обходятся друг без друга. Например, можно сказать: «Await ожидает обратного вызова от своего приятеля async».
Как и для чего это использовать? Async/await является частью новых структурных изменений параллелизма в Swift 5.5. Это означает, что несколько фрагментов кода могут выполняться одновременно. Это очень упрощенное описание, но оно должно дать представление о том, насколько важен параллелизм в Swift для улучшения производительности приложений.
Интерфейс async/await позволяет работать с асинхронным кодом без блоков замыкания, использования различного синтаксического сахара или реактивного подхода (PromiseKit, RxSwift и подобные решения) и при этом писать более простой и чистый код. Ведь его синтаксис очень напоминает обычный синхронный код.
Механизм представляет собой асинхронную декларацию функции, которая в целом является новым термином в Swift. Асинхронность функции подразумевает под собой то, что переход на следующую операцию не осуществится до тех пор, пока не будет ответа от функции. При этом такое поведение не означает, что тред будет заблокирован на период выполнения. Напротив, во время ожидания ответа он будет свободно выполнять другие действия. Отмечу, что ответ от функции может получить совсем другой поток, и тут возможна проблема race condition.
Я хочу рассмотреть пример с async/await, который поможет наглядно показать удобство использования его в коде. Предположим, что нам нужно загрузить картинку с сервера, затем выполнить ее обработку, которая является ресурсоемкой задачей, а после вывести из картинки текст, что также довольно кропотливый процесс. Эти три действия должны выполняться асинхронно, чтобы не блокировать поток.
Взгляните, как выполнить их с помощью async/await: В объявлении нашей функции getTextFromImage мы указали ключевое слово await, которое указывает, что она будет работать асинхронно. Для обработки ошибок мы используем механизм throws try/catch. Он поможет вернуть конкретную ошибку во время выполнения функции. Соответственно, любой вызов функций внутри с try (в нашем случае - выполнение запроса, процессинг изображения, получение текста из изображения) при возникновении проблемы отправит ошибку в функцию getTextFromImage.
В первую очередь происходит выполнение функции imageURLRequest. Это не занимает много времени и не нужно ждать ответа, поэтому функция выполняется синхронно.
Теперь перейдем к разбору функции data(for: request). Это функция из библиотеки Foundation, и она уже поддерживает механизм async/await. Для получения данных нужно время. Исходя из логики работы async/await, выполнение функции getTextFromImage будет приостановлено, а управление потоком передано системе. Соответственно, система может использовать поток для совершенно другой работы. Когда ответ функции data(for: request) будет готов, система возобновит выполнение функции getTextFromImage.
Как я отметила выше, функция может продолжить выполнение в совершенно другом потоке. Для решения проблем с состоянием гонки в таких случаях используют акторы, новый метод синхронизации, который появился в swift вместе сконцепцией async/await. Оно по сути является новым подходом к решению задачи синхронизации процессов, значительно более эффективным по сравнению с уже существующими методами, такими как семафоры. Перейдем к нашему разбору функции: try перед await используется по тому же принципу по которому работает вообще обработка ошибок.
На этой функции мы показали, как работает асинхронная функция, и чем она отличается от синхронной.
Далее мы берем изображение из data и используем свойство processedImage, которое также является асинхронным. Следовательно, мы видим, что не только функции, но и свойства могут быть асинхронными.
Здесь представлена декларация свойства processedImage: Обратите внимание, что только свойства не имеющие сетеров, т.е. get only свойства могут являться асинхронными. Таким образом предотвращается возможность возникновения race condition.
Аналогично работает свойство processedText, только в конце функция возвращает текст. Подчеркну, что в данном случае Swift потребует обработки ошибки throw FetchError.badImage тем самый исключая случаи не выявленных исходов, что нельзя утверждать при использовании блоков как механизм асинхронного кода.
Погрузимся в детали и выясним, каким образом происходит процесс приостановки и возобновления асинхронной функции. Каждый поток имеет собственный стек, который хранит контекст исполняемой функции.
А в каждом стековом кадре хранятся входные параметры, переданные аргументы, сохраненные значения регистров, локальные переменные функции. При вызове функции стек растет за счет нового стекового кадра, и когда выполнение функции заканчивается происходит очистка стека. Таким образом описывается процесс выполнения синхронной функции.
Давайте разберем по частям механизм выполнения асинхронной функции. Посмотрите на следующее определение функции updateOffers, которая обновляет продукты, вызывая для каждого асинхронную функцию getOffer. Вследствие этого создается новый ProductOffer и добавляется к уже существующим. Далее рассмотрим особенности выполнения асинхронной функции. При выполнении асинхронной функции кроме обычного стекового кадра создается еще асинхронный стековый кадр в куче. Он хранит в себе все локальные переменные и данные необходимые для возобновления выполнения функции. При приостановке выполнения стековый кадр данной функции освобождает стек, после чего он может использоваться другими функциями. При возобновлении выполнения асинхронной функции, за счет стекового кадра, находящегося в куче, создается новый стековый кадр в стеке первичного или другого потока, и ее выполнение продолжается.
Резюмируя, я бы хотела еще раз подчеркнуть преимущество использования новой реализации асинхронности async/await по сравнению с ранее применявшимися методами. Так, раньше при приостановке операции происходила блокировка потока, а для выполнения новых функций создавались новые потоки. В некоторых случаях это приводило к состоянию взрыва числа потоков: созданию большого количества потоков, что увеличивало загрузку памяти и снижало производительность по причине переключения ядер процессора между выполнением разных потоков. В новой реализации асинхронности поток не блокируется во время приостановки функции, что позволяет обеспечить условие равенства количества потоков и процессорных ядер, а также более экономное расходование памяти. Заключение Я постаралась познакомить вас с очень интересным и полезным функционалом, который делает значительно эффективнее многозадачность в Swift. Используя данный интерфейс в работе над последними проектами, я не раз убедилась, что он позволяет писать более простой и чистый код.
Async/await улучшает читаемость сложного асинхронного кода. Можно отказаться от громоздких конструкций из замыканий, использования различного синтаксического сахара сторонних библиотек. При этом вызов нескольких асинхронных методов друг за другом становится намного более понятным и надежным.
Маст хэв, как говорится. Но помните, использовать async/await возможно только начиная с iOS 13 и выше. В более старых версиях интерфейс не поддерживается.