Почему async/await лучше completion handler?
Сегодня хотелось бы поговорить о преимуществах async/await
, в сравнении с привычными нами способами работы с асинхронным кодом. async/await
был представлен Apple на сессии WWDC 21, но к сожалению, не во всех проектах решаются перейти на данную технологию. Давайте узнаем как сделать наш код более читаемым, безопасным и надежным.
Что такое async/await?
async/await
– это современный способ работы с асинхронным кодом в Swift. Он делает код более читаемым, безопасным и удобным, заменяя сложные вложенные completion handlers или DispatchGroup.
Механизм async/await
основан на принципе структурированного параллелизма. Это означает, что асинхронный код выполняется последовательно в рамках своей области видимости, в отличие от замыканий, которые могут разрывать поток выполнения и усложнять его отслеживание.
Пример асинхронного запроса через completion handler
:
// Service Layer func fetchState(callback: @escaping (Result<GameShakeStateDataResponseModel, NetworkError>) -> Void) { let router = GameShakeRequestFactory.getState networkService.performRequest(router: router) {(result: Result<GameShakeStateDataResponseModel, NetworkError>) in switch result { case .success(let model): callback(.success(model)) case .failure(let error): callback(.failure(error)) } } } // Presenter Layer func fetchState() { service.fetchState() { [weak self] result in guard let self else { return } switch result { case let .success(response): updateView(with: response.data) case let .failure(error): view?.showErrorToast(with: error.title) } } }
Если переписать код на async/await
:
// Service Layer func fetchState() async throws -> GameShakeStateDataResponseModel { try await networkService.performRequestAsync(router: GameShakeRequestFactory.getState) } // Presenter Layer func fetchState() { Task { do { let offer = try await service.fetchState() // handle updateView } catch { // handle error } } }
async
— объявляет асинхронную функцию;await
— приостанавливает выполнение функций до завершения задачи;throws
— указывает, что функция может выбрасывать ошибки;Task
— запускает асинхронную операцию в синхронном коде.
Читаемость и простота кода
Что хорошо во вложенности в async/await
- это то что ее нет.
В то время как в completion handler
одна из главных проблем — глубокая вложенность, также известная как callback hell. Когда одна асинхронная операция зависит от другой, completion
вложены друг в друга, усложняя чтение и поддержку.
fetchData { result in switch result { case .success(let data): process(data) { processedData in save(processedData) { success in if success { print("Data saved") } } } case .failure(let error): print("Error: \\(error)") } }
С async/await
код остается линейным и чистым:
Task { do { let data = try await fetchData() let processedData = try await process(data) try await save(processedData) print("Data saved") } catch { print("Error: \\(error)") } }
В данном примере наглядно виден принцип структурированного параллелизма. Код выполняется последовательно, строка за строкой. Мы можем ожидать асинхронную функцию до тех пор, пока она не вернет результат (или не выдаст ошибку) и не передаст его следующей функции.
Безопасность потоков
При использовании async/await
, большинство асинхронных операций (сетевые запросы, вычисления и т.д) выполняются в фоновом потоке. Однако при необходимости обновления UI, вам нужно явно указать переход на главный поток. Иначе это может привести к сбою приложения. Так как вы пытаетесь изменить интерфейс из фонового потока.
func fetchState() async throws -> GameShakeStateDataResponseModel { try await networkService.performRequestAsync(router: GameShakeRequestFactory.getState) } func fetchState() { Task { do { let offer = try await service.fetchState() self.view?.updateUI(with: offer) // CRASH!!! (Background thread) } catch { // handle error } } }
Использование @MainActor
гарантирует безопасность при работе с асинхронным кодом. Что также помогает вам избежать проблем с потоками.
func fetchState() { Task { do { let offer = try await service.fetchState() await MainActor.run { view?.updateUI(with: offer) } } catch { // handle error } } }
Обработка ошибок
В completion handler
ошибки передаются через Result<T, Error>
или отдельный параметр Error?
. Однако, если забыть вызвать completion
, вызывающий код может зависнуть. Это приведет к бесконечной загрузке счетчика или неопределенному состоянию пользовательского интерфейса.
В async/await
ошибки всегда требуют обработки:
- Если функция объявлена как
throws
, компилятор не позволит пропустить обработку; - Исключение сразу указывает на источник проблемы.
Параллельность и производительность
async/await
в Swift работает быстрее, чем старые подходы (замыкания, DispatchGroup
, OperationQueue
), потому как эффективнее управляет потоками и использует кооперативную многозадачность.
- Нет необходимости вручную управлять потоками или задачами, что предотвращает создание лишних потоков;
- Обеспечена параллельная работа задач без блокировки главного потока. Например, когда операция ожидает завершения при запросах в сети или в работе с диском, остальные операции могут продолжать выполняться;
- Минимизированы накладные расходы на переключение контекста.
Говоря о параллельном выполнений задач, async/await
предлагает ряд инструментов (async let
, TaskGroup
,Task.detached
). В данной статье мы остановимся на async let
, в сравнений с DispatchGroup
.
Вспомним как выглядит код с DispatchGroup
:
func fetchOffers() { let dispatchGroup = DispatchGroup() dispatchGroup.enter() forYouService.fetch() { [weak self] result in // ... dispatchGroup.leave() } dispatchGroup.enter() loyaltyStateService.fetchLoyaltyState() { [weak self] result in // ... dispatchGroup.leave() } dispatchGroup.enter() gameShakeService.fetchGameRules { [weak self] result in // ... dispatchGroup.leave() } dispatchGroup.notify(queue: .main) { [weak self] in // handle result } }
- Явного вызова
enter()
иleave()
, что потенциально повышает вероятность ошибок (например, забыв вызватьleave()
); - Отслеживания завершения через
notify()
, что добавляет дополнительные элементы синхронизации; - Ручной обработки ошибок в каждом замыкании.
async let
— это способ параллельного выполнения асинхронных операций.
Он позволяет запустить несколько независимых задач одновременно и затем дождаться их завершения без блокировки. Пример кода:
Task { async let forYouData = fetchForYou() async let loyaltyStateData = fetchLoyaltyState() async let gameRulesData = fetchGameRules() let (forYouResponse, loyaltyStateResponse, gameRulesResponse) = await (forYouData, loyaltyStateData, gameRulesData) // handle result }
- Синхронизация скрывается за механизмами асинхронных задач;
- Система сама управляет потоками;
- Ошибки можно обрабатывать централизованно через
do-catch;
- Код остается лаконичным и удобным для поддержки.
Итоги
Подводя итоги, хочется отметить следующие преимущества:
- Мы получаем простой и линейный код, где отсутствуют вложенность;
- Мы получаем асинхронный код, который выглядит как синхронный, за счет чего упрощается понимание и дебагинг кода;
- У нас есть четкое разделение между потоками, что снижает количество возникаемых проблем с многопоточностью;
- У нас есть надежная отладка ошибок;
- Также
async/await
дает современные способы работы с параллельными задачами.
В результате мы получаем чистый, надежный и безопасный код.
Полезные ссылки:
- Презентация async/await на WWDC 21
- Текстовая версия WWDC записи
- Cтатья с описание адаптаций async/await в проекте
- Эволюция async/await