The Swift Awaitening: Методичное пособие

20 сентября 2021 года состоялся релиз версии 5.5 языка Свифт. В ней программистам стало доступно множество новых фич, связанных с асинхронностью и concurrency (не представляю, как это правильно перевести на русский; «параллелизм» слишком уж как-то тяжко произносится).

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

Главная и корневая фича, конечно, — async/await, то есть возможность создавать асинхронные функции, которые обрабатываются процессором несколько иначе, чем обычные функции. Прямо как в Джаваскрипте или Си Шарпе, но, как водится, лучше.

Если очень просто, то асинхронными функциями называются такие функции, в которых могут (да и должны, в общем-то) присутствовать точки прерывания (suspension points), помеченные ключевым словом await, чисто как когда мы помечаем вызов функции, которая может выкинуть ошибку, ключевым словом try. Сама функция, аналогично с функциями с ошибками, должна быть помечена ключевым словом async.

В сигнатуре функции пишется async throws, а при вызове такой функции — try await.

Мнемоническое правило №1: в сигнатуре сперва новое, а в вызове сперва старое.

Мнемоническое правило №2: при вызове асинхронной функции её сперва необходимо «дождаться», а уже потом «попробовать на ошибки», поэтому try await именно в таком порядке.

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

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

Лучшим примером будет старый добрый sleep. Классическая версия этой функции блокирует текущий поток — просто крутит бесконечный цикл, пока часы не дотикают до нужной наносекунды. Асинхронная же версия этой функции работает абсолютно иначе: она заряжает в планировщик своё продолжение через заданное количество времени, а поток, на котором она была вызвана, будет занят всякой полезной работой на время «сна» функции, а не просто заблокирован.

Разумеется, такие уровни абстракции ощутимо сложнее старых добрых блокирующих вызовов как в разработке, так и в потреблении процессора и памяти. И потому встает закономерный вопрос: а зачем всё это было? Почему бы просто не пользоваться блокирующими функциями и всё?

А разгадка проста: сервер на блокирующих функциях может одновременно обрабатывать только одного пользователя. Конечно, можно запустить несколько инстансов этого сервера, и количество одновременно обслуживаемых пользователей пропорционально же увеличится. А вот один инстанс сервера на асинхронных функциях может обрабатывать теоретически бесконечное количество пользователей, поскольку первый же из них не займет собой всё внимание, а новые приходящие пользователи смогут без задержек отправить свой запрос и ждать его выполнения.

Кстати, более подробно я эту проблематику осветил в другом своем эссе, про фьючеры в Свифт-НИО.

Важно: как уже было сказано, теоретически асинхронный сервер может принимать бесконечность пользователей, но на практике, разумеется, это количество несколько ниже; в общих случаях оно упирается в количество ресурсов, требуемое для удержания в памяти сокетных соединений пользователей.

Кроме того, следует различать скорость обработки пользователей и пропускную способность: первое — это время от отправки запроса до получения ответа, а второе — способность сервиса адекватно принять запрос в обработку (без ошибки Gateway Timeout или Too Many Connections).

Пример: в кафешке на двадцать столов работает только одна касса. Столы пустуют, а на кассе очередь. Можно добавить еще касс, и это на время решит проблему фоновой очереди. Но это всё равно узкое место. Его можно было бы убрать терминалами или заказами из мобильного приложения. Обращаю внимание: людей всё равно может быть слишком много, и зал будет переполнен, но самое бесячее узкое место — вход и прием заказа — асинхронность решает успешно. Иными словами, мы тут не про скорость, а про более рациональное использование ресурсов.

Хозяйке на заметку: как правило, бекенды на PHP однопоточные, а в интернет смотрят через промежуточный сервер PHP-FPM (FastCGI Process Manager). При увеличении трафика PHP-FPM автоматически создает новые процессы (новые кассы, да), а когда трафик уходит, осторожно убирает ненужные процессы. Бекенду же на Свифте такой менеджер не нужен. Он сам себе менеджер.

Async/await

Итак. Асинхронный код выглядит примерно таким образом:

func callExternal() async throws -> String {
    try await getExternalString()
}

func foo() async -> String {
    let result: String
    do {
        result = try await getExternalString()
    } catch {
        result = "unknown"
    }
    return result
}

func main() async {
    await print(foo())
}

И тут, вероятно, вы зададите мне вопрос: эй, уважаемый, а как создать в программе асинхронность, если из обычной функции нельзя вызвать асинхронную функцию? Где то дно, из которого начинается цепочка вызовов асинхронных функций? Иными словами: как сделать асинхронную точку входа в программу?

Авторы языка обещали нам, что из обычного main.swift можно будет запускать асинхронные функции, но пока что-то не видать. Через это (легальный) способ сейчас ровно один — через точку входа @main. Для этого у вас в Package.swift запускабельный таргет должен быть не .target(...), а .executableTarget(...). Однако, такой тип таргета не предусматривает (и даже запрещает) main.swift, так что создавайте файл вроде App.swift и поехали (можно переименовать main.swift в Main.swift, но это похоже на хак).

Важно: необходимо также вставить platforms: [.macOS(.v12)] сразу после name, иначе волшебства не будет. Актуальна эта директива только для макоси, линуксовая версия языка будет её игнорировать.

@main
struct Main {
    static func main() async {
        await foo()
    }
}

Continuation

Закономерно встает и второй вопрос: а откуда берутся асинхронные функции? Как они взаимодействуют с синхронными функциями? Проще всего ответить примером превращения функции с completion-хендлером в асинхронную функцию. Вот, скажем, есть у нас функция с такой вот сигнатурой:

func execute(completion: @escaping (String) -> Void)

А мы создадим ей перегрузку со следующей сигнатурой (которая, однозначно, красивее и понятнее):

func execute() async -> String

Для этого в стандартной библиотеке появились новые функции из семейства withUnsafe* (но не одним лишь ансейфом). Разберем примеры.

Создание асинхронности из функции, которая ошибок не предусматривает:

func execute() async -> String {
    await withUnsafeContinuation { continuation in
        execute { result in
            continuation.resume(returning: result)
        }
    }
}

Однако же, если ошибки таки возможны, есть вариант withUnsafeThrowingContinuation:

// предполагается сигнатура внешней функции
func execute(
    completion: @escaping (String) -> Void,
    onError: @escaping (Error) -> Void
)

// наша функция, тоже, заметьте, приобрела throws
func execute() async throws -> String {
    try await withUnsafeThrowingContinuation { continuation in
        execute { result in
            continuation.resume(returning: result)
        } onError: { error in
            continuation.resume(throwing: error)
        }
    }
}

Эти функции помечены unsafe не просто так: дело в том, что continuation обязательно должен быть продолжен (resume) ТОЛЬКО один раз, иначе последствия непредсказуемы, и живые позавидуют мертвым.

В наличии также есть Checked-версии этих функций, которые убедятся, что вот это всё не произошло:

func execute() async throws -> String {
    try await withCheckedThrowingContinuation { continuation in
        execute { result in
            continuation.resume(returning: result)
        } onError: { error in
            continuation.resume(throwing: error)
        }
    }
}

Всё остальное идентично, за исключением того, что такой код будет выполняться самую малость дольше из-за внутренних проверок (чтобы resume не выполнился больше одного раза). Кому что ближе. Я самоуверен, а потому выбираю unsafe-версии.

Собственно, именно так в SwiftNIO сделан простенький хелпер вокруг EventLoopFuture, превращающий его из фьючера в асинхронный геттер значения:

// на самом деле он сделан немного иначе, но я укоротил
extension EventLoopFuture {
    func get() async throws -> Value {
        try await withUnsafeThrowingContinuation {
            self.whenComplete($0.resume(with:))
        }
    }
}

Этот хелпер (не конкретно этот, но с этой сигнатурой) уже доступен в SwiftNIO версии 2.33.0, так что можно у любого фьючера через await спросить get() и будет хорошо.

Хозяйке на заметку: этот хелпер был сделан до принятия пропозала SE-0310, который вносит в язык возможность помечать геттеры (только их) как throws и async, так что в качестве домашнего задания предлагаю реализовать асинковый геттер для EventLoopFuture с сигнатурой var value: Value { get async throws }

async let

Упомянутая выше механика, кстати, сделана на сдачу от основной фичи, которую мы сейчас быстренько рассмотрим — async let. Это вызов функции, который не задержит выполнение подпрограммы; а однажды мы вернемся к этому значению и заавейтим его. Как бы такие фьючеры, но на уровне языка. Пример:

async let image1 = getImage1()
async let image2 = getImage2()
async let image3 = getImage3Throwing()

// a few moments later

myFunc(await image1, await image2, try await image3)

// или

try await myFunc(image1, image2, image3)

Обратите внимание, что сама по себе функция myFunc не помечена как асинхронная или бросающая ошибки. Но поскольку image3 создан бросающей функцией, то и его авейт должен предваряться траем.

Удобная фича. Теперь кое-что посложнее.

TaskGroup

async let это, конечно, чудесно. Но что если мы не знаем, сколько картинок нам нужно загрузить? Если у нас есть динамически приезжающий произвольного размера список урлов, из которых нужно загрузить картинки? Для этого существуют таск группы. Этот пример будет вполне понятен и всеобъемлющ:

let urls: [String] = getImageUrls()
let images: [Image] = try await withThrowingTaskGroup(of: Image.self) { group in
    for url in urls {
        group.addTask(priority: .high) {
            try await loadImage(url)
        }
    }

    var result: [Image] = []
    while let image = try await group.next() {
        result.append(image)
    }

    return result
}
dump(images)

Конечно, таск группы могут больше, чем я показал, но как базовый (и довольно распространенный) кейс — вполне наглядно. Надо добавить, что таск групы — это частный случай более общего функционала AsyncSequence. Это такой протокол, которым помечаются последовательности, которые должны уметь работать в асинхронном контексте. Они умеют практически всё, что и обычные последовательности — map, filter, count, next (как в примере) и так далее. Только добавь await.

Sendable

Тут следует сделать маленькую паузу в технологическом угаре и вернуться к самым основам. Конкурентность это довольно опасная штука — многие уже могли попадать на рандомные краши из-за того, что одно и то же глобальное значение попытались записать два потока одновременно, и слава Б-гу, что произошел именно краш, а не тихая поломка памяти (это намного хуже). Так вот, чтобы этого не происходило, авторы языка ввели понятие Sendable-значения (в оригинале оно называлось ConcurrentValue; зачем переименовали — я до сих пор понять не могу).

Это такое значение, которое можно свободно пересылать по тредам и контекстам, и ничто нигде не сломается. Такими значениями, безусловно, должны являться (и уже являются) скаляры вроде чисел, строк, булеанов, массивов и словарей. Это всё потому, что это Copy-On-Write значения, и чтобы их изменить, нужно сперва сделать копию (передавать var-переменные между тредами нельзя, только let). Так что эти типы стандартной библиотеки уже заботливо помечены этим протоколом. А вот всё остальное нужно помечать руками.

Например, есть у нас структура

struct Foo {
    let bar: String
    let baz: Int
}

Её можно сразу смело пометить как Sendable:

struct Foo: Sendable { ... }
// или как расширение, но обязательно в том же файле
extension Foo: Sendable {}

Однако, если у этой структуры будут не-Sendable свойства или если хотя бы одно свойство будет var, то пометить её сендеблом компилятор уже не даст, и будет прав. Нельзя всегда получать всё, что хочешь.

Но иногда всё-таки можно. Если вы уверены прям уверены, что всё хорошо, и ничего никогда не сломается (это довольно опрометчивое предположение), либо же это структура/класс из чужой библиотеки, и вы точно уверены, что она гарантированно ничего внутри себя не сломает (например, EventLoop из SwiftNIO), то такой тип можно пометить вот так:

extension EventLoop: @unchecked Sendable {}

Но это на свой страх и риск, и не говорите, что вас не предупреждали, подпись тут.

Кроме того, анонимные функции, которые будут ездить по асинхронным контекстам, тоже должны быть сендеблами. Это достигается через атрибут @Sendable:

@Sendable
func loadImage(_ url: String) async throws -> Image { ... }

Либо же анонимная:

func foo(closure: @Sendable @escaping () async -> Void)

Так победим.

TaskLocal

Фича, которая всем нужна, но о которой никто не просил. Кто пользуется Вапором, поймет, о чем речь: в каждый роут заходит волшебный объект Request, хранящий в себе всю вселенную. Таскаться с этим объектом по всему коду немного тяжеловато.

И именно для решения этой проблемы, о которой вы не задумывались, существует эта фича. Для начала определим статическое свойство со спецатрибутом и дефолтным значением (опционал и nil в качестве дефолтного значения тоже прокатят):

extension Logger {
    @TaskLocal
    static var current = Logger(label: "default")
}

Затем в местах, где логгер должен быть особенным, делается следующая магия:

var customLogger = Logger(label: "custom")
customLogger[metadataKey: "requestID"] = "\(UUID())"
Logger.$current.withValue(customLogger) {
    someCodeThatMightUseLogger()
}

И теперь всё, что внутри этого скоупа обратится к Logger.current, попадет не на дефолтный логгер, а особенный, заранее насетапленный. Само собой, такой вызов может быть и асинковым, и кидающим ошибки, и возвращающим значение (всё это и снаружи и внутри). Я показал самую простую версию.

Применений у этой штуки масса: реквест айди, эвент луп (в мире асинка он редко будет нужен, но всё-таки), вот прям что угодно. Понятное дело, наиболее востребованным он будет у разработчиков фреймворков, но пользоваться им будут все. А кто не будет, сами себе злобные буратины.

Кстати, пользуясь случаем, хочу порекомендовать мой пакет LGNLog, который делает ровно то, что я продемонстрировал (Logger.current), и даже больше.

Создание и отмена таск

Бывают в жизни случаи, когда необходимо создать асинхронную асинхронность. Типа как dispatchQueue.async { ... }, чтобы код пошел дальше, а таска где-то крутилась и вертелась. Для этого есть два пути.

Первый создаст таску и унаследует все настройки текущего контекста (приоритет и всякое такое):

Task {
    print("123")
}

Второй полностью абстрагируется от текущего контекста:

Task.detached {
    print("456")
}

Каждый из них может внутри возвращать значение (а может и не возвращать), точно так же и с выбрасыванием ошибок. Кроме того, саму таску тоже можно сохранить в переменную и ловко ею манипулировать:

let handle: Task<String, Never> = Task {
    return "foo"
}

Например, можно дождаться результата:

let result: Result<String, Never> = await handle.result
// или
let result: String = await handle.value

А можно и отменить:

handle.cancel()

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

// обращаю внимание, что уже не Never, а Error
let handle: Task<String, Error> = Task {
    for i in 0...100_000_000 {
        if Task.isCancelled {
            throw CancellationError()
        }
        doSomething(i)
    }
}

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

do {
    let value = try await handle.value
} catch CancellationError {
    // охрана, отмена
} catch let error {
    // а вот тут произошла так называемая ошибка
}

Акторы

И самое сладкое. Акторы — это новый тип типов, который имеет свою особую семантику, чем-то похожую на классы, только с асинхронностью. Даже с академической точки зрения описание выглядит довольно просто: любой актор имеет внутри так называемый ящик входящих сообщений («первый пришел первый ушел»), и таким образом достигается последовательный доступ ко внутреннему состоянию актора (даже на чтение), и данные поломаться не могут. В теории.

Актор объявляется через ключевое слово actor:

actor Balance {
    let ID: UUID
    var balance: Decimal

    init(ID: UUID, balance: Decimal) {
        self.ID = ID
        self.balance = balance
    }

    func deposit(balance: Decimal) {
        self.balance += balance
    }
}

Важно понимать, что снаружи все свойства и методы актора необходимо вызывать через await, даже не помеченные async, даже let (хотя казалось бы, там-то чего, а вишь). А изнутри не требуется.

Правда, есть одно исключение: можно пометить свойство или метод модификатором nonisolated (скажем, тут это шикарно подойдет айдишке, она-то не меняется, её закрывать авейтом нет необходимости), и тогда авейтить это свойство или метод не потребуется. Вот только пометить им, скажем, метод deposit не получится, потому что внутри он оперирует изолированным свойством balance (напомню, что смотреть баланс снаружи через await можно).

Может встать закономерный вопрос: а как сделать актору Equatable или Hashable? Ответ: иметь однозначный неизолированный айдишник и использовать в соотв. функциях этих протоколов именно его. Кстати, функции сравнения и хеширования тоже потребуется помечать как nonisolated.

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

Как сделать из асинхронной функции синхронную?

Никак.

Тестирование асинхронщины

Тут с одной стороны хитро, а сдругой — неприятно.

Хитро потому, что в икскоде (и вообще макосевских тулчейнах) можно уже прямо из коробки писать

func testAsyncFunc() async {
    ...
}

И икскод подхватит эти тестовые методы и будет их выполнять (из консоли swift testтоже отработает). Прописывать static var allTests больше не нужно.

А вот неприятно потому, что под линукс такого еще нет. Соответствующий пул реквест уже вмержен, но, во-первых, он неизвестно когда выйдет в продакшен (кажется, что в 5.6), а во-вторых, магии с автодискавери асинхронных тестов пока не предвидится (в пул реквесте об этом нет).

Но выход есть, хоть и костыльный. Я им воспользовался в своих пакетах.

Во-первых, создается хелпер, как бы конвертирующий асинхронные тесты в синхронные. Им можно заворачивать тесты в static var allTests (да, в линуксе они будут снова нужны) вот таким образом. Приведу пример из своего пакета FDBSwift:

extension FDBTest {
    static let __allTests__FDBTest = [
        ("testEmptyValue",            asyncTest(testEmptyValue)),
        ("testSetGetBytes",           asyncTest(testSetGetBytes)),
        ("testTransaction",           asyncTest(testTransaction)),
        ("testGetRange",              asyncTest(testGetRange)),
        ("testAtomicAdd",             asyncTest(testAtomicAdd)),
        ("testSetVersionstampedKey",  asyncTest(testSetVersionstampedKey)),
        ("testClear",                 asyncTest(testClear)),
        ("testStringKeys",            asyncTest(testStringKeys)),
        ("testStaticStringKeys",      asyncTest(testStaticStringKeys)),
        ("testErrorDescription",      testErrorDescription),
        ("testFailingCommit",         asyncTest(testFailingCommit)),
        ("testTransactionOptions",    asyncTest(testTransactionOptions)),
        ("testNetworkOptions",        asyncTest(testNetworkOptions)),
        ("testWrappedTransactions",   asyncTest(testWrappedTransactions)),
        ("testGetSetReadVersionSync", asyncTest(testGetSetReadVersionSync)),
    ]
}

Заметьте, что это не совсем allTests, а что-то странное, с подчеркиваниями. На самом деле, такой код выдает автодискавери тестов :)

Затем там же, чуть ниже (это старый добрый XCTestManifests.swift, если что), собираются все тесткейсы в единый список:

public func __allTests() -> [XCTestCaseEntry] {
    [
        testCase(FDBTest.__allTests__FDBTest),
        testCase(TupleTests.__allTests__TupleTests),
    ]
}

А в еще более старом и добром LinuxMain.swift делается уже привычное:

import XCTest
import FDBTests

@main public struct Main {
    public static func main() {
        var tests = [XCTestCaseEntry]()
        tests += FDBTests.__allTests()
        XCTMain(tests)
    }
}

Полный пример смотрите в моем репозитории https://github.com/kirilltitov/FDBSwift/tree/master/Tests. Работает как часы, хоть и, как уже упоминал, костыльновато. Но работает.

Есть еще один немного неприятный нюанс: функции вроде XCAssertEqual еще не умеют принимать вещи, которые требуется авейтить. Поэтому нужно сперва делать let actual = await funcCall(), а потом уже actual запихивать в XCAssertEqual или иной тестовый метод. Немного неудобно, но жить можно.

А на сегодня всё. Пишите комментарии, указывайте на ошибки, задавайте вопросы, к слову, сервисы авторизации и комментариев уже переписаны с фьючеров на асинк/авейты : )

Теги: swift, concurrency, async, await