JavaRush /Курси /Swift SELF /async let: паралельн...

async let: паралельні запити

Swift SELF
Рівень 68, Лекція 0
Відкрита

1. async let: що це таке і що взагалі можна розпаралелити

Якщо ви вже писали будь-який мережевий код, то, напевно, помічали дивне відчуття: процесор ніби майже нічого не робить, а застосунок усе одно «довго думає». Це нормально: мережа — це очікування. А коли у нас є кілька запитів, наприклад два id книг, очікування може накластися одне на одне: дочекалися першої відповіді, і лише потім почали другу — і так далі.

Проблема не в тому, що Swift «повільний», а в тому, що ми самі побудували очікування послідовно. В асинхронному коді час виконання часто схожий на чергу в кавʼярні: якщо ви стоїте один за одним, то загальний час — це сума очікувань. А якщо ви замовили каву й круасан одночасно в двох віконцях, то загальний час ближчий до найбільшого з двох очікувань.

async let — це якраз «друге віконце», але з дуже суворими правилами безпеки й часу життя задач.

Що таке async let

async let виглядає як звичайна константа, але поводиться інакше: вираз праворуч виконується в окремій дочірній задачі, яка починає працювати одразу, щойно виконання дійшло до цього рядка. Це частина моделі structured concurrency: дочірні задачі утворюють «дерево задач», а батьківська задача відповідає за те, щоб діти не втекли жити своїм життям.

Важливо запамʼятати три властивості.

По-перше, async let можна оголошувати лише в асинхронному контексті — у async функції або async замиканні, — тому що результат усе одно доведеться await-ити.

По-друге, читання значення async let вважається потенційною точкою очікування, тому компілятор вимагає await, навіть якщо «ну, здається, вже мало встигнути».

По-третє, це саме дочірня задача: вона не може «пережити» область видимості, в якій була створена.

Мініприклад «на пальцях»:

import Foundation

func slowNumber(_ x: Int) async -> Int {
    try? await Task.sleep(nanoseconds: 200_000_000)
    return x
}

func demo() async -> Int {
    async let a = slowNumber(1)
    async let b = slowNumber(2)
    return await a + await b
}

Зверніть увагу на стиль: a і b стартують паралельно, а await стоїть там, де ми справді хочемо використати результат.

Правило незалежності: що можна розпаралелити, а що не можна

Коли розробник уперше бачить async let, виникає бажання розпаралелити все підряд: читання файлу, запис файлу, запити, оновлення репозиторію, виведення в консоль… і десь тут починають плакати ваші майбутні звіти про помилки.

Щоб async let був коректним і корисним, операції мають бути незалежними за даними на старті. Тобто ми можемо запустити паралельно:

  • «сходи по книгу id=10»
  • «сходи по книгу id=20»

тому що другий запит не вимагає результату першого.

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

  • «отримай токен авторизації»
  • «виконай запит із цим токеном»

тому що другий крок не може стартувати без результату першого. Це вже залежність, отже, це послідовний await.

Ще один підступний приклад незалежності — коли ви насправді ділите спільний змінюваний обʼєкт. async let створює роботу, подібну до @Sendable, і компілятор буде вкрай підозріло ставитися до спроб мутувати захоплені змінні, тому що це потенційна гонка даних. І це добре: Swift у цьому місці намагається врятувати вас від «воно інколи працює, але лише по вівторках».

2. Час життя й область видимості: чому async let не можна «залишити на потім»

Ось тут зазвичай трапляється магія, яка новачкам здається «примхою мови». Насправді це одна з найсильніших гарантій Swift Concurrency.

Ідея structured concurrency

Structured concurrency каже: дочірня задача не повинна жити довше за батьківську або за область видимості, у якій вона створена. Це означає: не можна «запустити async let тут, а дочекатися його десь в іншому місці через хвилину», як ви могли б зробити, наприклад, зі створеними вручну задачами (Task { ... }) у неструктурованому стилі.

У async let час життя суворо обмежений областю видимості. І з цієї причини компілятор «дотискає» вас: результат потрібно отримати (await) у поточній області, інакше задача не має права існувати.

Що буде, якщо взагалі не await-нути

Цікавий момент: навіть якщо ви явно не зробили await, structured concurrency не дозволить вам «відпустити» задачу в фон назавжди. Під час виходу з області видимості Swift неявно скасує дочірні задачі й дочекається їх завершення.

Тобто ось такий код виглядає як «я йду одразу», але насправді він чекатиме, доки дочірні задачі не завершаться або не будуть скасовані й коректно завершені:

import Foundation

func go() async -> String {
    async let fast = slowNumber(1)
    async let slow = slowNumber(2)
    return "nevermind..."
}

Тому async let — це не «fire-and-forget», а «запусти зараз, але обовʼязково розберися з результатом у цій самій області».

3. Як правильно await-ити й де ставити try

Тепер давайте зберемо практичну механіку в голові, без філософії.

Читання значення async let — тільки через await

Оскільки значення «приїде пізніше», будь-яке посилання на нього має бути позначене await.

func sum() async -> Int {
    async let a = slowNumber(10)
    async let b = slowNumber(20)

    let x = await a
    let y = await b
    return x + y
}

Зручна точка складання: await (a, b)

Swift дозволяє дочекатися одразу пари значень, тобто кортежа, тож код стає візуально чистішим: у вас є чітка точка збирання результатів.

func sumTuple() async -> Int {
    async let a = slowNumber(10)
    async let b = slowNumber(20)

    let (x, y) = await (a, b)
    return x + y
}

Якщо може бути помилка: try await, і порядок фіксований

Якщо операція праворуч може кидати помилку, то очікування результату вимагає try await. Причому порядок саме такий: try await, а не навпаки. Це правило для викликів async throws у Swift.

import Foundation

enum DemoError: Error { case boom }

func mayThrow(_ ok: Bool) async throws -> Int {
    if !ok { throw DemoError.boom }
    return 42
}

func demoThrows() async -> Result<Int, Error> {
    do {
        async let v = mayThrow(false)
        return .success(try await v)
    } catch {
        return .failure(error)
    }
}

4. Мінісхема: як async let виглядає в часі

Перш ніж ми прикрутимо це до нашого CLI, корисно один раз побачити картинку. Тут немає «справжніх потоків» у сенсі «я можу гарантувати, що це паралельно на двох ядрах», але для очікування мережі ефект саме такий: поки один запит чекає на сервер, другий теж може чекати на сервер, а не стояти в черзі за першим.

flowchart TD
    A["t0: старт функції"] --> B["async let A: запит #1 стартував"]
    B --> C["async let B: запит #2 стартував"]
    C --> D["...виконуємо підготовчу роботу..."]
    D --> E["await (A, B): точка збирання результатів"]
    E --> F["t_end: продовжуємо з готовими даними"]

Ключова ідея: ви запускаєте незалежні операції раніше, а await ставите там, де без результату вже не можна продовжувати.

5. Застосовуємо в LibraryCLI: паралельний fetch двох книг

Зараз ми зробимо саме те, заради чого й була ця лекція: у межах спрощеного сценарію fetch-many паралельно отримаємо дві книги. Тут важливо не заходити в майбутні теми на кшталт часткового успіху й агрегації Result для кожного елемента — це буде окремий крок. Сьогодні ми робимо просту версію: або обидві книги завантажилися, або сценарій завершується помилкою.

Уявімо, що в нас уже є:

  • BooksEndpoint.details(id:), який уміє створити URLRequest;
  • ApiClient.fetch(_:as:) async throws -> T;
  • DTO і доменну модель.

Тоді «один запит» можна загорнути в маленьку функцію:

import Foundation

struct BookDTO: Decodable {
    let id: Int
    let title: String
}

func fetchBookDTO(id: Int, baseURL: URL, api: ApiClient) async throws -> BookDTO {
    let request = try BooksEndpoint.details(id: id).makeRequest(baseURL: baseURL)
    return try await api.fetch(request, as: BookDTO.self)
}

А тепер — версія з двома паралельними запитами:

import Foundation

// приклад, що компілюється за наявності ApiClient/BooksEndpoint
func fetchTwo(ids: (Int, Int), baseURL: URL, api: ApiClient) async throws -> (BookDTO, BookDTO) {
    async let a = fetchBookDTO(id: ids.0, baseURL: baseURL, api: api)
    async let b = fetchBookDTO(id: ids.1, baseURL: baseURL, api: api)

    return try await (a, b)
}

Тут відбувається все важливе.

async let створює дочірні задачі, які починають виконуватися одразу в момент оголошення. Повернення (a, b) вимагає try await, тому що кожна гілка може кинути помилку, і ми зобовʼязані явно це позначити в коді.

Чому ми збираємо результати саме тут

Тому що дочірня задача не може жити довше за область видимості, у якій оголошено async let. Це прямо змушує писати код так, щоб місце запуску й місце очікування результату були в одній зрозумілій області видимості. Для супроводу це величезний плюс: менше «втеклих» фонових задач, менше сюрпризів, менше ситуацій «воно ще щось качає, а ми вже вийшли».

6. Нюанс із замиканнями: async let не можна передати назовні

Дуже часта спроба в початківців — «ой, я зараз збережу async let у змінну й передам далі». Але async let не призначений для передачі назовні. У нього немає окремого керуючого обʼєкта, як у Task.

Ба більше, async let не можна захоплювати в escaping-замикання, тому що це продовжує життя значення за межі області видимості, а це заборонено моделлю. В обговоренні цього правила підкреслюється, що реалізацію async let можна оптимізувати аж до розміщення частини структур на стеку, тому «втеча» в escaping-контекст небезпечна.

Натомість у не-escaping async-замиканні захоплювати можна, але await усе одно має бути явним і всередині замикання:

import Foundation

func greet(_ f: () async -> String) async -> String { await f() }

func hello() async -> String {
    async let name = slowNumber(123).description
    return await greet { await name }
}

Таку поведінку підкреслює проста ідея: await — це не «технічна формальність», а маркер точки, де виконання може призупинитися.

7. Типові помилки під час роботи з async let

Помилка № 1: використати async let, а потім спробувати звернутися до значення без await.
Це виглядає як «ну я ж уже запустив, мабуть, готово», але для Swift це принципово: читання async let — потенційна точка очікування, і вона має бути позначена await. Якщо без await воно й справді «майже завжди встигало», вітаю: ви щойно написали код, який працює рівно до першого повільного Wi‑Fi.

Помилка № 2: намагатися розпаралелити залежні кроки.
Якщо другий запит не можна зібрати без результату першого, наприклад потрібен токен, потрібне посилання редіректа або потрібен nextPageToken, async let не підходить. Це не проблема Swift, це математика даних: ви не можете зробити крок 2, доки не отримаєте вхід для кроку 2. У таких місцях краще чесно писати послідовні await і не вдавати багатозадачність заради красивої картинки.

Помилка № 3: оголошувати async let і «забувати» про нього, думаючи, що він піде у фон.
async let — це не unstructured «окреме життя». Під час виходу з області видимості значення, для яких не викликано await, будуть неявно скасовані, а потім їх завершення буде дочекано. Тобто ви все одно заплатите часом виконання, зазвичай за найдовшим дочірнім завданням. Якщо вам справді потрібно «нехай працює далі, а я пішов», це вже інший інструмент, і він вимагає окремої розмови та дисципліни.

Помилка № 4: розкидати await по всьому коду замість однієї точки складання.
Іноді пишуть так: «створив async let, потім десь посередині await-нув один результат, потім наприкінці інший». Це не завжди помилка компіляції, але майже завжди помилка читабельності. Набагато простіше супроводжувати код, коли є чітка точка складання: let (x, y) = try await (a, b). Це перетворює паралельність із «магії» на ясний етап конвеєра.

Помилка № 5: намагатися мутувати спільний обʼєкт усередині гілок async let.
Навіть якщо здається, що «я обережно», у паралельних гілках дуже легко випадково торкнутися спільного репозиторію, спільного масиву або спільного логера так, що ви отримаєте гонку даних або перемішані стани. Swift спеціально обмежує такі речі й ставиться до них суворо. Правильна звичка — спочатку паралельно отримати дані, а операції зі зміною стану залишити окремій послідовній фазі.

1
Задача
Swift SELF, 68 рівень, 0 лекція
Недоступна
Паралельна каса
Паралельна каса
1
Задача
Swift SELF, 68 рівень, 0 лекція
Недоступна
Привітання профілю
Привітання профілю
1
Задача
Swift SELF, 68 рівень, 0 лекція
Недоступна
Дві книги паралельно
Дві книги паралельно
1
Задача
Swift SELF, 68 рівень, 0 лекція
Недоступна
Два рахунки
Два рахунки
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ