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, даже если «ну вроде уже должно было успеть».

В‑третьих, это именно child task: она не может «пережить» область видимости, в которой была создана.

Мини‑пример «на пальцах»:

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 в этом месте пытается спасти вас от «оно иногда работает, но только по вторникам».

3. Lifetime и scope: почему async let нельзя «оставить на потом»

Вот здесь обычно происходит магия, которая новичкам кажется «капризом языка». На самом деле — это одна из самых сильных гарантий Swift Concurrency.

Идея structured concurrency

Structured concurrency говорит: дочерняя задача не должна жить дольше родителя (или scope), в котором она создана. Это значит: нельзя «запустить 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», а «запусти сейчас, но обязательно разберись с результатом в этой же области».

4. Как правильно 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)
    }
}

5. Мини-схема: как 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 ставите там, где без результата уже нельзя продолжать.

6. Применяем в 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. Это прямо вынуждает писать код так, чтобы место запуска и место ожидания результата были в одном понятном scope. Для сопровождения это огромный плюс: меньше «утекших» фоновых задач, меньше сюрпризов, меньше ситуаций «оно всё ещё что-то качает, но мы уже вышли».

7. Нюанс с замыканиями: async let нельзя передать наружу

Очень частая попытка у начинающих — «ой, я сейчас сохраню async let в переменную и передам в callback». Но async let не предназначен для «передачи наружу». У него нет отдельного handle-объекта, как у Task.

Более того, async let нельзя захватывать в escaping-замыкание, потому что это продлевает жизнь значения за пределы scope, а это запрещено моделью. В обсуждении этого правила подчёркивается, что реализация 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 — это не «техническая формальность», а маркер точки, где выполнение может приостановиться.

8. Типичные ошибки при работе с 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 «отдельная жизнь». При выходе из scope неawait-нутые значения будут неявно отменены и затем awaited, то есть вы всё равно заплатите временем выполнения (обычно по самому длинному дочернему заданию). Если вам действительно нужно «пусть работает дальше, а я пошёл», это уже другой инструмент, и он требует отдельного разговора и дисциплины.

Ошибка №4: разбрасывать await по всему коду вместо одной точки сборки.
Иногда пишут так: «создал async let, потом где-то в середине await-нул один результат, потом в конце другой». Это не всегда ошибка компиляции, но почти всегда ошибка читаемости. Намного проще сопровождать код, когда есть чёткая «точка сборки»: let (a, b) = 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 лекция
Недоступна
Два счёта
Два счёта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ