Swift 5.5: что нового. Structured concurrency, actors и другие. Часть 2

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Продолжим разбираться с изменениями в модели асинхронности, представленной в 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.

На этом я, пожалуй, закончу вторую часть, она тоже получилась объёмной. Остальные нововведения останутся для третей статьи цикла.

👍НравитсяПонравилось10
В избранноеВ избранном2
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

очень интересная статья, спасибо автору! Но нельзя ли разъяснить каким образом нужно использовать акторов так, чтобы это было абсолютно безопасно, желательно на примере.

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

Дуальность task-based concurrency и async/await, композитные задачи, граница «синхронный-асинхронный»... Дежа вю какое-то. Dotnet, TS, теперь и в Swift )) Почерк Андерса Хейлсберга, стопудово.

Впрочем, в этом нет ничего плохого. Зачем извращаться, если можно перенять отличную, проверенную временем концепцию.

Кто-то из создателей Свифта в одном из подкастов напрямую говорил, что есть намерение затащить в Свифт из жабаскрипта синтаксис «async/await». Потом прошла инфа, что SwiftUI создан, чтобы затащить побольше жабаскриптеров в экосистему Аппле. Не моя мысль — написал, что сам читал и слышал.

Підкажіть, будь ласка, що за подкаст?

Один из недавних выпусков «Swift by Sundell»

по поводу SwiftUI тут много занимательного было: youtu.be/UZXKWVbvfE4

Ага, только в js эта модель асинхронизма просочилась опять же из дотнета, где она появилась еще в 2011-м, ЕМНИП, году. Свифт 5.5 такое ощущение, что списывал больше с первоисточника, чем с js. Сделано почти дословно по Дону Сайму, Саймону Марлоу, Андерсу Хейлсбергу.

та я ж не спорю. не все могут смотреть вглубь веков )

Подписаться на комментарии