Swift 5.5: что нового. Structured concurrency, actors и другие. Часть 2
Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!
Продолжим разбираться с изменениями в модели асинхронности, представленной в Swift 5.5. В прошлой статье мы обсудили добавление операторов async
и await
а также ряд дополнений для упрощения их адаптации. Сегодня посмотрим, как это использовать в реальных задачах. Прежде чем мы перейдём к «сладенькому» в лице SE-0304 и SE-0306, ещё несколько «вспомогательных» вещей.
Оборачивание старого кода: Continuations
Вполне естественно что тонны уже существующего кода никуда по мановению руки не денутся. Хоть Swift и молодой язык, написано на нём уже много и зачастую именно с использованием тех же callback
. Для упрощения миграции, разработчики компилятора представили удобный метод, названный ими continuations. Они есть двух типов: checked и unsafe. Сперва покажу, как это работает. Представим, что у нас есть метод загрузки ресурса по сети (пример всё ещё довольно синтетический).
func loadResource(byUrl: URL, completion: @escaping (Result<Data, Error>) -> Void) { URLSession.shared.dataTask(with: byUrl) { response, _, error in if let error = error { completion(.failure(error)) return } if let data = response { completion(.success(data)) return } completion(.failure(DataError.invalidResponse)) }.resume() }
Вот так вот он превращается в асинхронный метод.
func loadResource(byUrl: URL) async throws -> Data { try await withCheckedThrowingContinuation { continuation in loadResource(byUrl: byUrl) { result in switch result { case let .success(data): continuation.resume(returning: data) case let .failure(error): continuation.resume(throwing: error) } } } }
Мы уже много раз видели подобный паттерн, например, в том же RxSwift. Функция withCheckedThrowingContinuation
принимает один параметр-замыкание, который в свою очередь получает параметр continuation
, который мы «дёргаем», чтоб сообщить что работа завершилась тем или иным результатом. Есть 4 функции: withCheckedContinuation
, withCheckedThrowingContinuation
, withUnsafeContinuation
и withUnsafeThrowingContinuation
. Варианты со throwing ожидаемо могут завершаться с ошибкой, а checked варианты функций выполняют дополнительную работу, чтоб убедиться что вы случайно где-то не вызвали continuation более одного раза (ценой чуть больших накладных расходов).
Structured concurrency
Вот он, основной «двигатель» асинхронности, созданный для управления работой параллельных задач. Описывающий все аспекты этого нововведения SE-0304 получился очень большим. Поэтому тут я сконцентрируюсь только на основных аспектах (подозреваю что коллектив Рея Вандерлиха уже занят написанием книги-другой о новых парадигмах многопоточности).
Как я уже говорил в прошлой статье, асинхронные функции можно вызывать только в асинхронном контексте. Если вы пишете, например, консольное приложение — вы можете отметить асинхронным сразу главный метод.
@main struct Main { static func main() async { await doWork() print("Done, bye!") } }
Это может быть удобно, например, для серверных приложений на Swift. Но давайте будем откровенны: почти все мы тут пишем для iOS (возможно кто-то ещё для macOS, но таких меньше) и нам нужен способ запускать асинхронную работу из наших синхронных методов. Для этого предназначены два основных класса: Task
и TaskGroup
.
Task
выступает главным «кирпичиком» из которого строится всё остальное, создание задач — очень простое.
private func startTasks() async throws { let firstTask = async { try await loadResource(byUrl: URL(string: "https://dou.ua/forums/topic/33784/")!).count } let secondTask = async(priority: .background) { for _ in (1...1_000_000) { doSomeHeavyWork() } } let res1 = try await firstTask.get() let res2 = await secondTask.get() }
В SE-0304 создание тасков предполагается делать с помощью конструктора самого класса Task, но потом решили пойти другим путём, так что для создания задачи нужно использовать тот самый вызов функции async
, упомянутый в прошлой части. Помимо замыкания, в котором осуществляется работа, можно указать желаемый приоритет. Если задача запускается внутри другой задачи, она является дочерней и наследует её приоритет. Если нужно запустить независимую задачу, на помощь придёт функция asyncDetached
.
В результате вызова мы получаем handle таска, который можно использовать для его отмены c помощью метода cancel()
. Метод не останавливает выполнение принудительно, поэтому реальное завершение работы — задача разработчика. Внутри самого таска можно проверить не отменён ли он с помощью метода Task.checkCancellation()
, в случае отмены он выбросит исключение CancellationError
. Можно также воспользоваться свойством Task.isCancelled
, просто возвращающим флаг. Класс Task
предлагает также дополнительные возможности по управлению задачами: сон, временная «передача» управления системе, получение текущего контекста и так далее.
Одиночными задачами дело чаще всего не ограничивается. Во многих сценариях необходимо запустить несколько задач, а потом дождаться их завершения, на помощь приходят TaskGroup
. Во избежание потенциального неправильного использования они тоже недоступны для непосредственного конструирования, работать с ними предполагается через вспомогательные функции. Приведу пример (я в нём кое-что намеренно упростил).
private func startTasks() async throws { let urls = [ "https://dou.ua/forums/topic/33784/", "https://github.com/apple/swift-evolution/blob/main/proposals/0304-structured-concurrency.md", "https://dou.ua/forums/topic/33770/" ] let total = try await withThrowingTaskGroup(of: Int.self) { (group) -> Int in for url in urls { group.async { try await self.loadResource(byUrl: URL(string: url)!).count } } return try await group.reduce(0, +) } print(total) }
При вызове withThrowingTaskGroup
мы указываем, что будут возвращать наши дочерние задачи. После этого описываем, что именно мы хотим сделать в замыкании. Параметр group
как раз позволяет нам работать с этой группой: запускать задачи с помощью метода async
и дожидаться результатов, асинхронно итерируясь по самому group
.
В данном случае, мы загружаем несколько URL, и суммируем длину полученных данных. Я намеренно использовал тут reduce
, чтоб продемонстрировать ещё раз работу асинхронных коллекций, но если обработка данных не укладывается в рамки однострочников, можно использовать асинхронный for
var total = 0 for try await result in group { total += result } return total
Тут вы можете спросить: «а что делать, если подзадачи возвращают разные типы данных?». На этот случай тоже есть достаточно удобное решение.
async let
В обсуждениях на форуме Swift я пару раз встречал мнение, что structured concurrency, это слишком «низкоуровневый» подход, и для удобства программистов надо предлагать более простые конструкции. Первой из таких синтаксических плюшек стала предложенная в SE-0317 команда async let
. Для начала — пример. Метод startTasks()
взят из примера выше, просто переделан так, чтоб возвращать суммарный размер, а не печатать его.
private func doJob() async -> UIImage? { let url = URL(string: "https://picsum.photos/200")! return try? await AsyncImage(url: url).image } private func doFewJobs() async throws { async let image = doJob() async let sizes = startTasks() try await print("\(image?.size ?? .zero) and \(sizes)") }
Фактически, async let
это отложенная инициализация, которая будет выполнена тогда, когда асинхронная задача отработает и данные будут получены. Поэтому, когда нам уже действительно нужны данные, нам надо их «дождаться» с помощью await
. По этой же причине try
у нас находится не в строке, где мы объявляем sizes
, а ниже. Реальное разрешение отложенного вызова будет происходить в строке с print
и именно там мы и можем получить исключение.
Иногда меня удивляет, какие мощные механизмы скрываются за простым синтаксисом.
Actors
Акторы стали, пожалуй, одним из самых сложных нововведений релиза. Предвижу что в будущем мы увидим на StackOverflow массу вопросов, посвящённых их использованию. Как вы понимаете, полностью разобрать их тоже не получится, поэтому постараюсь продемонстрировать основные моменты SE-0306.
Новинка призвана помочь в борьбе с «гонками» при доступе к данным, пожалуй, самой частой и одной из самых сложноустранимых проблем асинхронного программирования. Вот типовой пример небезопасного класса.
class Account { private(set) var value: Decimal init(initial value: Decimal) { self.value = value } @discardableResult func deposit(amount: Decimal) -> Decimal { value += amount return value } func withdraw(amount: Decimal, to account: Account) -> Bool { guard value >= amount else { return false } value -= amount account.deposit(amount: amount) return true } }
Допустим, мы осуществляем с ним какую-то асинхронную работу.
private func doFewJobs() async { var acc = Account(initial: 10.0) asyncDetached { [acc] in print("First \(acc.deposit(amount: 10.0))") } asyncDetached { [acc] in print("Second \(acc.deposit(amount: 10.0))") } asyncDetached { [acc] in print("Third \(acc.deposit(amount: 10.0))") } }
В этом конкретном примере результат будет ожидаемым (в силу его утрированности), но в более сложных сценариях мы можем получить практически что угодно. Reference type, многопоточность, что может пойти не так?
Во избежание подобных проблем, в Swift 5.5 добавили новый тип данных, акторы (интересно, их начнут называть актёрами?). Actor-ы — это reference тип данных похожий на класс, с той лишь поправкой что их изменяемое состояние гарантированно изменяется в каждый момент времени только одним таском, что страхует от проблем с одновременным доступом. Это накладывает на нас ряд ограничений которые проще разобрать на примерах. Превратим наш Account
в актор.
actor Account { private(set) var value: Decimal init(initial value: Decimal) { self.value = value } @discardableResult func deposit(amount: Decimal) -> Decimal { value += amount return value } func withdraw(amount: Decimal, to account: Account) async -> Bool { guard value >= amount else { return false } value -= amount await account.deposit(amount: amount) return true } }
Изменений тут понадобилось не много, но есть нюанс: для вызова метода deposit
второго аккаунта нам надо использовать await
, так как на самом деле акторы общаются синхронизируя свои обновления, и этот вызов может завершиться не моментально. Соотвественно метод withdraw
становится асинхронным.
Кроме этого, асинхронным является и доступ к свойствам актора, поэтому нам нужно будет await-ить его.
async { [acc] in print("Final \(await acc.value)") }
Таким образом, actor-ы позволяют довольно безопасно работать с тем самым shared mutable state в многопоточном окружении. Впрочем, полностью расслабиться разработчикам не выйдет, actor защищает своё состояние только в синхронном коде, во время ожидания завершения асинхронных вызовов состояние может измениться, поэтому нужно разрабатывать код с учётом этого. Данный вопрос отлично разобран в видео с WWDC.
Actor во многом похожи на классы, перечислю их сходства:
- они являются reference типами и служат для хранения shared state
- они могут иметь инициализаторы, методы, свойства и сабскрипты
- статические методы и свойства работают в акторах так же как в классах так как у них нет
self
- они могут реализовывать протоколы и быть дженериками
Есть и разница.
- Акторы автоматически «получают» протокол
Actor
, который не могут реализовывать другие типы данных - Они не поддерживают наследование, поэтому процесс инициализации для них не так сложен как для классов. Впрочем, наследование могут реализовать в будущем
В SE-0316 описаны глобальные акторы, созданные, как легко догадаться, для работы с глобальными изменяемыми состояниями. Гипотетически их может быть много, но главное практическое применение на данный момент это упомянутый ранее @MainActor
. Доступ к методам и свойствам, отмеченным таким образом будет гарантированно осуществляться на главном потоке что, безусловно, пригодится для работы с UI.
На этом я, пожалуй, закончу вторую часть, она тоже получилась объёмной. Остальные нововведения останутся для третей статьи цикла.
9 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів