Программирование
Async/await против GCD
Использование async/await сильно упрощает разработку, а также делает код более простым и читаемым.
Всем привет, меня зовут Маринин Алексей и я работаю в компании Озон на позиции ведущего разработчика мобильных приложений, сегодня я хотел бы немного поговорить о том, как async/await могут улучшить нашу жизнь, заменив привычные инструменты.
Понимание концепции структурированного параллелизма
Task в Swift — это новая асинхронная конструкция, которая была добавлена в Swift 5.5. Task — это единица асинхронной работы. Когда вы создаете экземпляр Task, вы предоставляете замыкание содержащее какую-то работу, которую эта задача должна выполнить. Только код, выполняющийся как часть задачи, может взаимодействовать с этой задачей, а вот чтобы взаимодействовать с текущей задачей, вы вызываете один из статических методов Task. Кроме того, Task также предоставляет дополнительные возможности, такие как отмена задачи, проверка статуса выполнения и управление приоритетом выполнения.
Каждая задача в группе задач имеет одну и ту же родительскую задачу, и у каждой задачи могут быть дочерние задачи. Из-за явных отношений между задачами и группами задач такой подход называется структурированным параллелизмом.
Что такое async?
Async (асинхронность) — это концепция программирования, которая позволяет выполнять задачи независимо друг от друга и без блокировки выполнения кода. В асинхронном программировании задачи выполняются асинхронно, то есть без ожидания завершения предыдущей задачи, и результаты возвращаются в момент их готовности.
Что такое await?
Ключевое слово await используется для вызова асинхронных методов и является неразлучным другом для ключевого слова async в Swift. Без await нельзя дождаться завершения асинхронной операции, которую выполняет метод, помеченный как async.
Зачем нужна конструкция async/await?
Конструкция async/await в Swift (а также в других языках программирования) используется для упрощения написания асинхронного кода.
Асинхронное программирование — это подход, при котором операции не блокируют поток выполнения программы, позволяя приложению продолжать работу, в то время как асинхронные операции выполняются в фоновом режиме. Это очень полезно для задач, которые требуют загрузки данных из сети, выполнения длительных вычислений или обращения к внешним источникам данных.
В языке Swift async/await упрощает написание асинхронного кода, облегчая программирование. Вместо использования функций обратного вызова (callbacks) или цепочек обещаний (promises), которые могут быть сложными для понимания и поддержки, разработчик может использовать async/await, чтобы писать код, который выглядит похожим на синхронный код.
Кроме того, async/await позволяет избежать таких проблем, как гонки данных (race conditions) и блокировки потоков (deadlocks), которые могут возникать при написании асинхронного кода вручную. Вместо этого, разработчик может явно указывать, какие операции должны выполняться асинхронно и как их результаты должны обрабатываться.
Конечно, использование async/await может быть непривычным для разработчиков, которые привыкли к другим подходам к асинхронному программированию. Однако, когда разработчик освоит эту конструкцию, он сможет писать более понятный, безопасный и поддерживаемый асинхронный код.
Базовый пример применения
Вот пример простой функции, которая загружает изображение с сервера в асинхронном режиме:
func loadImageFromServer() async throws -> UIImage { let imageURL = URL(string: "https://example.com/image.png")! let (data, _) = try await URLSession.shared.data(from: imageURL) guard let image = UIImage(data: data) else { throw NSError(domain: "com.example", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to load image"]) } return image }
Эта функция использует URLSession для загрузки данных изображения по URL-адресу. Она помечена как async, поэтому она может быть вызвана с использованием await внутри другой функции, которая также является асинхронной.
Функция загружает данные изображения асинхронно и преобразует их в UIImage. Если при загрузке данных произойдет ошибка, то функция выбросит ошибку типа Error.
async func displayImage() { do { let image = try await loadImageFromServer() // Показываем загруженное изображение imageView.image = image } catch { print("Failed to load image: \(error)") } }
Эта функция использует ключевое слово async для того, чтобы пометить ее как асинхронную. Она вызывает loadImageFromServer
с помощью await, чтобы дождаться загрузки изображения, и затем отображает его на экране.
Разбор реального примера
Условие: давайте предположим, что нам необходимо пойти в сеть по трём разным запросам или же одним и тем же, но с разными параметрами, т.е есть три запроса и эти три запроса должны-быть асинхронные т.к мы не можем ждать, что пока закончится первый запрос, мы идём ко второму, когда закончится второй к третьему и так далее.
protocol NetworkServiceType { func getData(with param: String, completion: @escaping (String) -> Void) } final class NetworkService: NetworkServiceType { func getData(with param: String, completion: @escaping (String) -> Void) { let delay = Int.random(in: 0...3) print("Стартуем выполнение задачи: \(param)") DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + .seconds(delay)) { print("Закончили выполнение задачи \(param)") completion(param) } } }
Мы определили протокол NetworkServiceType
, внутри которого содержится метод getData(with:completion:)
. Этот метод принимает String параметр и замыкание completion
, которое принимает в себя String и будет вызвано после того, как будет получен результат.
Затем определяем класс NetworkService
, который реализует этот протокол. Его метод getData(with:completion:)
запускает задачу в асинхронном режиме на глобальной очереди с помощью DispatchQueue.global(qos: .background).asyncAfter
, а затем вызывает замыкание completion
с переданным String.
Давайте реализуем наше условие:
final class ViewController: UIViewController { private let networkService: NetworkServiceType = NetworkService() private var data = [String]() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .green.withAlphaComponent(0.3) fetchData() } func fetchData() { networkService.getData(with: "Audi") { data in self.data.append(data) } networkService.getData(with: "BMW") { data in self.data.append(data) } networkService.getData(with: "Lada") { data in self.data.append(data) } } }
Давайте посмотрим, что покажет консоль:
Стартуем выполнение задачи: Audi Стартуем выполнение задачи: BMW Стартуем выполнение задачи: Lada Закончили выполнение задачи Audi Закончили выполнение задачи BMW Закончили выполнение задачи Lada
А если запустить код ещё-раз:
Стартуем выполнение задачи: Audi Стартуем выполнение задачи: BMW Стартуем выполнение задачи: Lada Закончили выполнение задачи Lada Закончили выполнение задачи Audi Закончили выполнение задачи BMW
Так как у нас случайная величина задержки, мы понимаем, что каждый раз, когда отрабатывает функция networkService.getData
, задача может иметь разное время выполнения. В коде выше, у нас есть одна особенность, т.к все задачи асинхронные, то они будут каждый раз выполняться в разном порядке и отсюда вытекает вопрос: «А что если нам будет необходимо добиться такого поведения, чтобы каждый запрос не начинал свою работу, пока предыдущий запрос не завершится?”.
Давайте попробуем реализовать это поведение при помощи DispatchGroup:
private let dispatchGroup = DispatchGroup()
Затем добавим в viewDidLoad:
dispatchGroup.notify(queue: .main) { self.data.forEach { print("В массиве лежит: \($0)") } }
А функцию fetchData немного подправим:
func fetchData() { dispatchGroup.enter() networkService.getData(with: "Audi") { data in self.data.append(data) self.dispatchGroup.leave() } dispatchGroup.enter() networkService.getData(with: "BMW") { data in self.data.append(data) self.dispatchGroup.leave() } dispatchGroup.enter() networkService.getData(with: "Lada") { data in self.data.append(data) self.dispatchGroup.leave() } }
При помощи DispatchGroup мы добились того, что теперь каждый запрос, после того как завершит свою работу, будет передавать эстафету работы следующему запросу, т.е как-только отработает запрос для “Audi”, мы переходим на запрос “BMW”, после отработки которого переходим на запрос “LADA”.
Работа с DispatchGroup заключается в следующих шагах:
1. Создание объекта DispatchGroup с помощью конструктора:
let group = DispatchGroup()
2. Добавление задач в группу с помощью метода enter():
group.enter()
Вызов enter() указывает, что в группе появилась новая задача.
3. Запуск асинхронной задачи, которую нужно выполнить:
networkService.getData
4. Завершение задачи и оповещение группы о ее завершении с помощью метода:
leave()
5. Замыкание, которое будет выполнено после завершения всех задач в группе с помощью метода notify():
dispatchGroup.notify(queue: .main) { self.data.forEach { print("В массиве лежит: \\($0)") } }
Вызов notify()
указывает, что нужно выполнить замыкание после завершения всех задач в группе.
В этом примере мы создали группу DispatchGroup и добавили в нее три задачи. Каждая задача была запущена в асинхронном режиме на глобальной очереди. После запуска каждой задачи мы вызвали метод leave()
для оповещения группы о завершении задачи. Это всё чудесно, но у этой реализации есть небольшие особенности:
- DispatchGroup не управляет временем ожидания. Если какая-то задача в группе не завершится, то метод
wait()
будет заблокирован навсегда, что приведет к зависанию приложения. Чтобы избежать этой проблемы, можно использовать методwait(timeout:)
, который задает время ожидания. - Если возникает ошибка в одной из задач, то DispatchGroup не предоставляет механизма для отслеживания ошибок и прерывания выполнения задачи. В этом случае, приложение может зависнуть или работать неправильно.
- DispatchGroup не позволяет отслеживать прогресс выполнения задач в группе. Если вам нужно отслеживать прогресс выполнения нескольких асинхронных задач, то вам может потребоваться использование других инструментов, такие как OperationQueue.
- DispatchGroup может быть неудобным для использования в более сложных сценариях с большим количеством задач.
Но это еще не всё. А что если нам необходимо класть данные в массив в том порядке, в котором мы запускаем наши запросы?
Давайте немного улучшим нашу функцию fetchData
func fetchData() { dispatchGroup.enter() networkService.getData(with: "Audi") { data in self.data[1, default: []].append(data) self.dispatchGroup.leave() } dispatchGroup.enter() networkService.getData(with: "BMW") { data in self.data[2, default: []].append(data) self.dispatchGroup.leave() } dispatchGroup.enter() networkService.getData(with: "Lada") { data in self.data[3, default: []].append(data) self.dispatchGroup.leave() } }
Когда метод getData(with:completion:)
завершает свою работу и вызывает замыкание completion с полученными данными, выполняется соответствующий блок кода внутри замыкания, который добавляет данные в словарь data с соответствующим ключом (1, 2 или 3). Таким образом, функция используется для параллельной загрузки данных.
Так же не забудем изменить data на:
private var data = [Int: [String]]()
И добавить новую реализацию для получения отсортированного словаря:
dispatchGroup.notify(queue: .main) { let result = self.data .keys .sorted(by: { $0 < $1 }) .compactMap { self.data[$0] } .flatMap { $0 } result.forEach { print("В массиве лежит: \($0)") } }
В этом участке кода происходит уведомление dispatchGroup.notify
, которое запускает блок кода на главной очереди (queue: .main) после того, как все вызовы enter()
были сопоставлены с соответствующими вызовами leave(), и все задачи в dispatchGroup были выполнены.
В блоке кода выполняется следующее:
- Создается отсортированный массив result на основе словаря data. Мы обращаемся к его ключам и сортируем их. При помощи compactMap достаём нужные значения, которые не являются nil по ключу, т.е если по ключу $0 есть значение — мы его достанем. Выражение flatMap { $0 } возвращает одномерный массив строковых значений, который содержит все строки из массивов, полученных из словаря data. Этот массив будет использоваться для вывода информации о всех строках в консоль.
- Затем result обходится при помощи цикла forEach, который печатает каждое значение.
Интересно получилось реализовать заданное… А что же будет, если переписать тоже самое, но только при помощи async/await ? Добавим для протокола NetworkServiceType
новую функцию:
func getAsyncData(with param: String) async throws -> String
А теперь её реализуем всё там же, в классе NetworkService:
func getAsyncData(with param: String) async throws -> String { let delay = Int.random(in: 0...3) print("Стартуем выполнение задачи: \(param)") try await Task.sleep(nanoseconds: UInt64(delay) * NSEC_PER_SEC) print("Закончили выполнение задачи \(param)") return param }
Здесь метод getData(with:)
был изменен на асинхронный, используя ключевое слово async. Вместо замыкания completion этот метод возвращает нужное для нас значение. Он также использует Task.sleep для имитации задержки выполнения задачи. Вызов Task.sleep
блокирует поток до тех пор, пока задача не будет завершена.
Вызов метода getData(with:)
может быть выполнен с использованием await, как показано ниже:
func fetchData() async throws { async let audi = networkService.getAsyncData(with: "Audi") async let bmw = networkService.getAsyncData(with: "BMW") async let lada = networkService.getAsyncData(with: "Lada") try await data.append(contentsOf: [audi, bmw, lada]) }
Функция fetchData() async throws
является асинхронной и может выбрасывать исключения. Она использует конструкцию async let
для выполнения нескольких асинхронных задач getAsyncData(with:)
с параметрами «Audi», «BMW» и «Lada». Каждая из этих задач запускается параллельно и выполняется асинхронно. Затем функция ожидает завершения всех задач с помощью try await data.append(contentsOf: [audi, bmw, lada])
, где data — массив строк. После успешного завершения всех задач, функция fetchData(
) завершается и передает управление обратно в viewDidLoad()
. В viewDidLoad()
используется конструкция Task {...}
для асинхронного запуска функции fetchData()
. Когда все задачи завершены, код в блоке {…} продолжает выполнение и выводит все элементы массива data.
Вывод
Основываясь на вышеприведенном примере, можно с уверенностью заявить, что использование async/await сильно упрощает разработку, а также делает код более простым и читаемым, что видно невооруженным глазом и несомненно оказывает самый благоприятный эффект на долгосрочную судьбу проекта.
Что еще посмотреть про async/await:
- Meet async/await in Swift
- Box Feed: новости на SwiftUI и Async/Await
- Swift async/await. Чем он лучше GCD?
- Swift async/await на примерах
- Async/await в Swift 5.5: асинхронность «здорового человека»