Знакомимся с async/await в Swift

image-loader.svg

Здравствуй, Хабр. Меня зовут Даниил, я iOS-инженер в Ситимобил. Около нескольких месяцев прошло с того момента, как стало возможным погонять на Xcode 13 beta новую свифтовую асинхронность. Заинтересовавшись, я провел немного времени в изучении нюансов работы механизма, и сейчас хотел бы поделиться своим структурированным пониманием async/await. Не буду лезть в дебри, но уверен, что стоит показать не только внешние преимущества, но и некоторые внутренние улучшения.

Не могу не напомнить, что продукт всё ещё в стадии бета, и он достаточно отличается от того, что в итоге будет в релизной версии. Для получения самого свежего API лучше использовать последний тулчейн.

Для тех, кто хочет покопаться самостоятельно, вот материалы для изучения:

  • WWDC

  • Proposals — расширенная версия того, что можно увидеть в сессиях WWDC.

  • swift forum evolution — вообще, советую читать этот форум с небольшой периодичностью, клад для iOS-разработчика.

  • Блог коллеги из Spotify — разобрано устройство акторов, но в кишки async/await тоже скоро появятся.

Итак, приступим.

Есть ли нюансы?

Есть.

  • Начнём с грустного: async/await поддерживается начиная с 15-й iOS и не является обратно-развертываемым. Этот факт вызвал бурную дискуссию, в рамках которой инженеры Apple пояснили, что фича требует поддержки нового рантайма (подробнее об этом поговорим ниже). К слову, котлиновские корутины такой необходимости не имеют, что, видимо, свидетельствует о разном техническом стеке в реализации механизмов.

    Предположу, что какая-никакая совместимость с предыдущими версиями технически все же возможна, но не гарантирует ожидаемого перфоманса — такой расклад инженеров, вероятно, не устроил.

  • Поддержка async/await — функций интегрирована в некоторые системные SDK, например, URLSession, HealthKit или NSDocument. Пока что выглядит скудновато, но радует, что уже есть некая точка входа новой технологии в существующий проект: ничего не мешает начать строить свой транспортный слой с новой свифтовой многопоточностью.

Какие преимущества над уже имеющимися решениям, тем же самым GCD?

Вполне резонный вопрос. Их можно разделить на несколько составляющих — на фантик от конфетки и на саму конфетку.

»Фантик» во многих статьях был уже досконально разобран — это визуальная эстетичность кода. Читать код стало на порядок легче, отчасти от того, что мы избегаем callback hell«ов — на мой взгляд, существенный плюс. Это, в свою очередь, снижает вероятность допустить ошибку — забыть вызвать completionHandler, из-за нарушить логику работы программы, теперь нельзя. Да и что тут говорить, код, с закосом под синхронный, стал намного элегантнее. Вот это:

func obtainFirstCarsharing(
  completionHandler: @escaping (CarsharingCarDetail?) -> Void) 
{
   fetchCars { [weak self] cars in
       guard let self = self, let firstCar = cars.first else {
           completionHandler(nil)
           return
       }
       self.fetchCarDetail(withId: firstCar.id) { detail in
           completionHandler(detail)
       }
   }
}

Теперь может выглядеть так:

func obtainFirstCarsharing() async throws -> CarsharingCarDetail {
   let allCars = try await fetchCars()
   guard let firstCarId = allCars.first?.id else { throw NSError() }
  
   return try await fetchCarDetail(with: firstCarId)
}

Замечу, что асинхронная функция, помимо результирующей модели, может вернуть ошибку — это нормальное поведение, которое разработчик может закладывать. В таком случае у нас есть возможность обрабатывать ошибки посредством try/catch.

Теперь о »конфетке». Один из существенных плюсов в том, что async/await является неблокирующим механизмом. Сразу отмечу, что неблокирующий тут не равно непрерывный / синхронный. На это слово надо взглянуть под другим ракурсом — неблокирующим механизм является для потока. Что это значит?

Взглянем на примеры:

   let queue = DispatchQueue(label: "citymobil.queue.com")
    
   queue.sync { /* Execute WorkItem */ }

   // ----------------------------
      
   let semaphore = DispatchSemaphore(value: 0)
    
   semaphore.wait()

   // ----------------------------

   let _ = try await service.fetchCars()

Рассмотрим поведение потока с очередью, которая вызывает sync-метод — синхронно выполняет какую-нибудь WorkItem-задачу. В месте вызова sync поток блокируется и доступ к нему возвращается только после исполнения sync-замыкания.

С семафорами ситуация схожа, если не хуже: они, очевидно, находятся вне философии очередей — могут заблокировать какой-либо поток, в котором выполняется WorkItem, отданный очереди.

В случае с async/await синхронное выполнение метода приостанавливается: точкой приостановки является await, при этом сам поток не простаивает в ожидании. Как это возможно?

Вернемся к уже упомянутому участку кода:

func obtainFirstCarsharing() async throws -> CarsharingCarDetail {
   let allCars = try await fetchCars()
   guard let firstCarId = allCars.first?.id else { throw NSError() }
  
   return try await fetchCarDetail(with: firstCarId)
}

Здесь поток, на котором выполняется метод, доходит до вызова fetchCars. Сразу после него приостанавливается дальнейшее синхронное выполнение инструкций: метод перестает владеть потоком и отдает »владение» им системе, тем самым сообщая ей, что он временно освобожден от работы и может перейти к выполнению более приоритетных задач. Если такие задачи есть, то система направляет поток на их выполнение. Когда выяснится, что более приоритетных задач уже нет, то система направит поток на выполнение fetchCars. Замечу, что приостановок может быть несколько. Когда fetchCars в конечном счете выполнится, некоторый поток продолжит выполнять дальнейшие инструкции в теле метода.

image-loader.svg

Тут стоит держать в голове пару моментов:

  • Поток, в котором выполнялся код до await, и который подхватил дальнейшее выполнение после, не обязательно будет одним и тем же.

  • Несмотря на то, что в сниппете кода нет коллбеков, глобальное состояние приложения во время приостановки (там, где await), может кардинально поменяться — это обязательно нужно держать в голове.

Хочется дополнить механизм работы еще одним примером и сравнить разницу в поведении между новым и старых механизмом.

Ниже видим код, в котором с использованием GCD мы асинхронно, в бэкгруанде, запускаем 32 задачи, в каждой из которых синхронно исполняем еще какой-нибудь блок для работы:

 let syncQueue = DispatchQueue(
    label: "queue.sync.com", 
    attributes: .concurrent
 )

 for i in 1...32 {
    DispatchQueue.global().async {
      syncQueue.sync { /* do some work */ }
    }
 }

Здесь включаются в работу большое количество потоков. При этом каждое переключение между ними (context switch) становится все более ресурсоемким для системы при большом его количестве. Несмотря на то, что чего-то критичного в этих переключениях нет — context switch внутри одного процесса в общем то происходит достаточно быстро, и в большинстве случаев мы можем себе позволить не задумываться о нем — заблокированный поток, де факто, держит свой стек и занимает память. В довесок, мы можем легко воспроизвести ситуацию, где исчерпаем рабочие потоки, тем самым воссоздав thread explosion (взрыв потоков).

Мы можем избежать такой ситуации, грамотно спроектировав работу с многопоточным кодом — например, использовать здесь concurrentPerform или ненулевые семафоры. Но Apple, кажется,»встроил» подобные оптимизации в систему:

image-loader.svg

Аналогичный код, переписанный с async/await, на условном двухъядерном устройстве будет гонять по одному потоку, которые не станут простаивать в ожидании, а стало быть и переключения между ними не будет. Вместо этого, переключение будет происходить преимущественно внутри одного потока между continuation — объектами (чуть ниже вернемся к ним), и будет сводиться к переключению между методами. Apple заявляет, что это на порядок легче для системы.

Все выше — это следствие, которое возникает благодаря причине — новому пулу потоков (cooperative thread pool). Новый пул ограничивает параллелизм, тем самым, обеспечивая состояние как на картинке выше. Можно выработать правило — количество «работающих» одновременно потоков < количество ядер

Пазл с минимальной поддерживаемой iOS 15, кажется, сложился.

AsyncSequence

Тривиальный пример асинхронной последовательностиТривиальный пример асинхронной последовательности

Вместе с async/await была представлена асинхронная последовательность, подобная обычной Sequence, с тем условием, что каждый элемент последовательности здесь достается асинхронно. Не хочется особо останавливаться на нем, скажу лишь, что при создании такой структуры необходимо по аналогии с обычной последовательностью реализовать async методы makeIterator и nextElement . Также заметим, что исполнение тела цикла в примере выше последовательное.

Все замечательно, но как совмещать async/await с привычным нам интерфейсом?

У нас достаточно много обращений в сеть, и каждый такой запрос, равно как и любой другой асинхронный, построен на коллбэках. Допустим, наш транспортный слой остался нетронутым, но мы хотим перевести API такого сервиса на async/await, под капотом используя коллбэки. Как это сделать?

Примерно такПримерно так

Apple предоставляет глобальную функцию WithCheckedThrowingContinuation (аналогично есть API без возможности бросить ошибку — WithCheckedContinuation) которая является асинхронной, и при этом имеет кложур с параметром continuation (уже знакомый нам) в качестве аргумента.

В примере выше, внутри кложура, мы вызываем метод fetchCars уже с коллбэком, и после получения результата (результат получаем вызовом resume метода у continuation) возобновляем дальнейшее выполнение async-метода fetchCars. Отмечу, что resume необходимо вызвать обязательно, и исключительно один раз, иначе иначе нас ждет краш. Аргумент метода resume, в свою очередь, может быть структурой типа Error или типом Result

Есть и противоположная сторона интеграции асинхронного паттерна с не асинхронным. Разработчику определенно нужна возможность вызывать асинхронные функции из синхронного контекста, например, во viewModel выполнить сетевой запрос и обновить UI после. Сделать это можно внутри блока Task (в ранних версиях бетки — async) и TaskDetached (в ранних версиях бетки — asyncDetached):

@MainActor
func syncMethodUpdate() {
    Task {
        print("step 1 \(Thread.current)")
        let cars = try await service.fetchCars()
        print("step 2 \(Thread.current)")
        await updateUI(with: cars)
    }
    print("step 3 \(Thread.current)")
}

@MainActor
func syncMethodUpdateDetached() {
    TaskDetached {
        print("step 1 \(Thread.current)")
        let cars = try await service.fetchCars()
        print("step 2 \(Thread.current)")
        await updateUI(with: cars)
    }
    
    print("step 3 \(Thread.current)")
}

На минуту абстрагируемся от разницы между ними, и заметим, что возвращаемый тип функций — Task.Handle . Итак, мы приходим к еще одной важной сущности — Task (задача).

Task — Это базовый юнит многопоточности, каждая async-функция выполняется в Task. Попросту говоря, Task для асинхронной функции — то же самое, что поток для синхронной. Они, безусловно, заслуживают отдельной статьи, но я попытаюсь вкратце передать их суть. Задачи имеют приоритет, их можно отменять, и они могут находиться в трёх состояниях — приостановленные, выполняющиеся и завершенные. Но самое главное, что они могут быть структурированными и неструктурированными.

Структурированные — это задачи, которые находятся в иерархии (в дереве задач, состоящем из задач родительских и дочерних) и имеют ряд свойств: наличие родительской задачи; ограниченный жизненный цикл, который зависит от времени жизни родителя; более того, они наследуют приоритет родителя и отменяются по цепочке, если родительская задача была отменена.

Неструктурированные задачи, напротив, не удовлетворяют полностью этим критериям, и нужны для того, чтобы управлять их временем жизни вручную. Например, если мы хотим создать задачу нажатием на кнопку в интерфейсе и отменить ее, если нажав на другую кнопку отмены. Более живой пример — когда у нас есть потребность создать задачу для ячейки коллекции, когда она появилась на экране, и отменить ее, если она быстро с этого экрана ушла.

Итак, Task и TaskDetached из сниппета выше — неструктурированные задачи. Чем они отличаются?

Результат выводов с TaskРезультат выводов с TaskРезультат выводов с TaskDetachedРезультат выводов с TaskDetached

Видим, что Task наследует атрибуты контекста, из которого был вызван (isCancelled свойство, приоритет и тд) — все print были вызваны из главного потока.
TaskDetached, в свою очередь, не наследует ничего. Из сниппета видно, что каждый print тут был вызван из разных потоков, при том, два из них не на главном. Более того, заметим, что поток до вызова await, и после — различается, о чем ранее уже упоминалось. Profit.

Теперь поговорим про структурированные задачи. До сих пор все вызовы цепочек из await в теле метода выполнялись последовательно. При этом, конечно, параллелизм в работе с асинхронным кодом необходим. И достигается он посредством async let задач:

async {
     async let detail1 = service.fetchCarDetail(with: "432")
     async let detail2 = service.fetchCarDetail(with: "231")  
     async let detail3 = service.fetchCarDetail(with: "123")

     let details = try await [detail1, detail2, detail3]
}

В данном случае создаются child-таски (detail1, detail2, detail3), которые начинают немедленно и параллельно выполняться; поток, при этом, продолжает исполнять дальнейшие инструкции внутри метода. На строчке с вызовом await поток приостанавливается по уже известному нам сценарию.

Несмотря на возможности async let — задач, мы можем ее использовать, имея фиксированное количество операций. Такой тип задач не подойдет, если количество вызовов того же fetchCarDetail зависит от массива идентификаторов. Для этих целей Apple предоставляет группу — TaskGroup, которая создается вызовом функции withThrowingTaskGroup и в качестве аргумента кложура имеет свойство group:

let carsharingDetails = try await withThrowingTaskGroup(
     of: CarsharingCarDetail.self,
     returning: [CarsharingCarDetail].self
 ) { group in
        
     for id in ["id123", "id231", "id939", "id333", "id493"] {
         group.async(priority: .utility) {
            return try await carsharingDetail(id: id)
         }
     }
                        
     var details: [CarsharingCarDetail] = []
            
     for try await detail in group {
         details.append(detail)
     }
            
     return details
 }

С помощью вызова group.async можно асинхронно и параллельно запускать задачи. Когда дойдем до точки получения результата — циклом пройдемся по группе и получаем на выходе необходимый массив моделей. Внимательный читатель может заметить, что group конфирмит AsyncSequence.

Как переключиться на главный поток?

Вернемся к нашему примеру:

TaskDetached {
     let cars = try await service.fetchCars()
     await updateUI(with: cars)
 }

Для того, чтобы метод updateUI вызвался на главном потоке, его необходимо пометить атрибутом @MainActor:

@MainActor
func updateUI(with cars: [CarsharingCar]) {
  // update
}

Акторы — это зверь, который выходит за рамки этой статьи. Тем ни менее, вы можете легко найти информацию о них на приведенных в начале статьи ссылках — материала более чем достаточно.

Заключение

Мир многопоточности, как можем заметить, обширен, а с недавних пор и в iOS среде. Сегодня нам удалось познакомиться с еще одним механизмом, но если хочется пойти дальше, пожалуйста — на гитхабе есть репо с реализацией корутин.

Если же есть вопросы изи замечания по статье — велком в комментарии.

© Habrahabr.ru