JavaRush /Курси /Swift SELF /Запуск async-коду: Task {}<...

Запуск async-коду: Task {} і асинхронна точка входу

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

1. Вступ

Уявіть, що async — це кімната з басейном. У басейні можна плавати (await), але зайти туди можна лише через двері (async-контекст). Якщо ви спробуєте пірнути в басейн із коридору, компілятор вас зупинить: «Вибачте, тут діє техніка безпеки».

У Swift це працює так: await дозволений лише всередині async-функції або async-замикання. Це зроблено не для того, щоб ускладнити вам життя, а для того, щоб код залишався чесним: якщо функція всередині щось очікує, це має бути видно в її сигнатурі. Інакше кажучи, ефект піднімається вгору: зʼявився await — отже, функція теж має стати async.

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

2. Async-entrypoint у CLI: @main і static func main() async

Коли ми пишемо CLI-застосунок на Swift (а наш курс якраз збирає LibraryCLI), у ньому завжди є точка входу. У Swift її можна оформити двома поширеними способами:

  • файл main.swift із кодом верхнього рівня;
  • тип, позначений @main, у якого є static func main().

Swift уміє запускати застосунок асинхронно: main() може бути async (і навіть async throws). У термінах моделі Swift Concurrency це означає, що Swift створює задачу (task), у якій виконується main(), а коли вона завершується — завершується й застосунок. У моделі structured concurrency це описано саме так: @main із main() async працює за схемою «main виконується в задачі, завершилася задача — завершився застосунок».

Мінімальний приклад: @main + main() async

Перейдемо до практики на найпростішому прикладі. Це шаблон, який можна скомпілювати для виконуваного таргета, наприклад SwiftPM executable:


import Foundation

@main
struct LibraryCLIApp {
    static func main() async {
        let message = await loadWelcomeMessage()
        print(message)
    }
}

func loadWelcomeMessage() async -> String {
    "Ласкаво просимо до LibraryCLI!"
}

Тут важлива не сама «мережа» — ми її поки не чіпаємо, — а те, що тепер у нас є легальне місце, де await узагалі можливий: усередині main() async.

Чи можна await прямо в main.swift?

Так, сучасна модель structured concurrency описує, що код верхнього рівня теж може робити await: він виконується як задача, а її завершення завершує застосунок.

Виглядає це приблизно так:

import Foundation

let message = await loadWelcomeMessage()
print(message)

func loadWelcomeMessage() async -> String {
    "Ласкаво просимо до LibraryCLI!"
}

Але тут є практичний нюанс, особливо важливий для SwiftPM-проєкту: зазвичай ви обираєте або main.swift, або @main-тип, але не обидва одразу. У гайді по SwiftPM це прямо проговорюється: або у вас є main.swift, або @main-тип — і це різні варіанти організації точки входу.

У межах курсу, де ми будуємо CLI-застосунок з архітектурою та модулями, частіше зручніший @main, тому що точка входу стає явним типом, у який зручно вбудувати залежності: сервіси, репозиторії тощо. Але іноді main.swift простіший для невеликих навчальних заготовок.

3. Де саме в нашому LibraryCLI зʼявляється асинхронна логіка

Давайте акуратно прив’яжемо цю ідею до нашого застосунку. У нас умовно є цикл читання команд, парсер, сервісний шар і виведення результату. Раніше все було синхронно. Тепер уявімо, що якась операція стала асинхронною: наприклад, сервіс може збагачувати книгу даними із зовнішнього джерела. Неважливо, з якого саме; мережу ми зараз не проєктуємо — важливий сам факт асинхронності.

Показати це можна так:

import Foundation

struct LibraryService {
    func resolveBookTitle(id: Int) async -> String {
        // Поки просто «ніби асинхронно».
        return "Книга #\(id)"
    }
}

Тепер питання: хто викликає resolveBookTitle? Якщо виклик синхронний, йому потрібно або стати async, або запускати задачу через Task {}. І тут ми переходимо до головної практичної дилеми: протягувати async вгору чи загортати все в Task.

4. Task { ... }: запуск асинхронної роботи із синхронного коду

Що таке Task {} простими словами

Task { ... } — це спосіб сказати: «Запустіть цю асинхронну роботу як окрему задачу». Це особливо корисно, коли ви перебуваєте в синхронній функції, де await заборонений, але вам потрібно ініціювати async-операцію.

У документації structured concurrency це називається unstructured task: задача, яка не є дочірньою в межах withTaskGroup або async let і яку можна запускати безпосередньо із синхронного коду. Під час створення Task { ... } ви отримуєте хендл задачі — значення типу Task<Success, Failure>, через яке потім можна отримати результат (await task.value) або скасувати задачу.

Ще одна важлива деталь із тієї ж моделі: задача виконуватиметься до кінця, навіть якщо ви не зберегли хендл.
Тобто «запустили й забули» технічно можливо.
Але якщо ви забули хендл, то втратили і контроль над помилками, і результат, і час життя задачі. Тому fire-and-forget залишимо для дуже рідкісних випадків.

Мініприклад: запуск із синхронної функції

Припустімо, у нас є синхронний обробник команди. Так часто буває в CLI, якщо ви не протягнули async вгору:

import Foundation

func handleShowTitleCommand(id: Int, service: LibraryService) {
    Task {
        let title = await service.resolveBookTitle(id: id)
        print("назва:", title) // назва: Книга #42
    }
}

Цей код компілюється, тому що await знаходиться всередині замикання Task { ... }, а це й є async-контекст.

Але є тонкий момент, на якому в CLI легко спіткнутися: якщо застосунок завершиться раніше, ніж задача встигне щось зробити, ви можете не побачити виведення. У GUI-застосунках це зазвичай не проблема, бо застосунок живе довше. У CLI — цілком реальна ситуація. Тому в CLI частіше намагаються робити main() async і вже з нього викликати await безпосередньо.

5. Хендл задачі: Task<Success, Failure> і очікування результату через .value

Навіщо зберігати хендл задачі

Коли ви пишете:

let task = Task { ... }

ви отримуєте обʼєкт-хендл, через який можна:

  • дочекатися результату;
  • (у майбутньому) скасувати його;
  • зрозуміти, де ви втрачаєте помилки.

У термінах structured concurrency: Task {} створює задачу і повертає її обʼєкт керування, а результат отримується через await task.value.

Приклад: запускаємо задачу в синхронному місці, а чекаємо — у main() async

Це корисний патерн, коли ви хочете запустити роботу в момент події, а чекати результат — на іншому рівні.

import Foundation

func startTitleTask(id: Int, service: LibraryService) -> Task<String, Never> {
    Task {
        await service.resolveBookTitle(id: id)
    }
}

@main
struct LibraryCLIApp {
    static func main() async {
        let service = LibraryService()

        let task = startTitleTask(id: 42, service: service)
        let title = await task.value

        print("отримано:", title) // отримано: Книга #42
    }
}

Зверніть увагу, що startTitleTask — синхронна функція, але вона повертає Task<String, Never>, який уже можна дочекатися пізніше, всередині main() async.

Якщо всередині були помилки: Task<T, Error> і try await task.value

Якщо операція всередині може викинути помилку, тип задачі змінюється, і отримання результату теж стає try await — саме в такому порядку.

import Foundation

enum DemoError: Error {
    case notFound
}

struct LibraryService {
    func resolveBookTitle(id: Int) async throws -> String {
        if id == 0 { throw DemoError.notFound }
        return "Книга #\(id)"
    }
}

@main
struct LibraryCLIApp {
    static func main() async {
        let service = LibraryService()

        let task = Task {
            try await service.resolveBookTitle(id: 0)
        }

        do {
            let title = try await task.value
            print("отримано:", title)
        } catch {
            print("помилка:", error) // помилка: notFound
        }
    }
}

Тут ви бачите одразу дві важливі речі: Task може інкапсулювати async throws-операцію, а очікування результату (task.value) — це теж очікування, тому там зʼявляється await. Це прямо випливає з моделі: Task-хендл використовують для того, щоб потім дочекатися результату задачі.

6. Що обрати в реальному коді: зробити функцію async чи використати Task {}

Зараз буде невелика таблиця здорового глузду, бо новачки часто або бояться async і загортають усе в Task, або, навпаки, позначають async майже половину проєкту й втрачають структуру.

Ситуація Що зазвичай краще Чому
Ви контролюєте сигнатуру функції й можете її змінити Зробити функцію async і викликати її через await Так чесніше: асинхронний ефект видно в інтерфейсі, а код легше читати зверху вниз
Ви в синхронному API й не можете додати async (наприклад, протокол, колбек або старий код) Запустити Task {} Це міст із sync у async
Вам потрібен результат і важливо його дочекатися Зберегти хендл і await task.value в async-контексті Інакше ви втратите контроль і можете завершити застосунок надто рано
Вам не потрібен результат, і ви точно розумієте, що робите Іноді допустимий Task { ... } без збереження Але обережно: задача все одно виконуватиметься, а помилок і результатів ви не побачите

Якщо коротко: Task {} — це не «як правильно писати async», а інструмент, який дає змогу почати async там, де інакше не можна. В ідеальному світі більшість бізнес-логіки у вас викликатиметься з main() async або з інших async-функцій безпосередньо, без зайвих обгорток.

Невелика схема: як синхронний код «стрибає» в async

Щоб у голові не залишалося відчуття, що Task — це магія, корисно уявити потік керування як просту схему:

flowchart TD
    A["Синхронна функція (await заборонено)"] --> B["Task { ... }"]
    B --> C["Async-контекст усередині Task (await дозволено)"]
    C --> D["Асинхронна операція: async/async throws"]
    D --> E["Результат усередині Task або через task.value"]

Саме тому Task {} так часто використовують на межі синхронного й async-коду: він створює async-контекст локально, не змушуючи негайно переписувати всі сигнатури навколо.

Як це виглядає в LibraryCLI: акуратний каркас main() async

Давайте зберемо каркас, який більше схожий на наш застосунок. Без мережевих деталей і без паралельності — лише запуск і очікування.

import Foundation

enum Command {
    case showTitle(id: Int)
    case exit
}

struct LibraryService {
    func resolveBookTitle(id: Int) async -> String {
        "Книга #\(id)"
    }
}

@main
struct LibraryCLIApp {
    static func main() async {
        let service = LibraryService()

        let command = Command.showTitle(id: 7)

        switch command {
        case .showTitle(let id):
            let title = await service.resolveBookTitle(id: id)
            print(title) // Книга #7

        case .exit:
            print("До зустрічі")
        }
    }
}

Ключова думка: щойно main() став async, увесь нормальний ланцюжок викликів знову читається лінійно — нам майже не потрібен Task {}. А Task залишається в арсеналі на випадок, коли ви впираєтеся в синхронні API.

7. Типові помилки

Помилка №1: думати, що Task {} повертає результат одразу.
Інтуїтивно хочеться написати let x = Task { await f() } і вважати, що x — це результат. Але x — це хендл задачі. Результат живе всередині задачі й дістається через await task.value (або try await task.value), бо очікування результату задачі — така сама точка очікування, як і будь-який інший await. Модель роботи через task handle і .value прямо описана в structured concurrency: спочатку створюємо Task, а потім дочекуємося її результату.

Помилка №2: запускати Task { ... } у CLI й одразу завершувати застосунок.
У GUI це часто проходить, бо застосунок не завершується відразу. У CLI ситуація цілком реальна: ви створили Task, функція повернулася, main завершився — процес закінчився, і ваш task не встиг нічого вивести. Якщо результат важливий, краще мати main() async і дочекатися результату безпосередньо або зберегти хендл та дочекатися .value до виходу.

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

Помилка №4: забувати, що «запустили й забули» — це теж рішення, але з наслідками.
Swift Concurrency допускає, що задача виконуватиметься до кінця навіть без збереження хендла. Це зручно для рідкісних випадків, але небезпечно як поведінка за замовчуванням: ви втрачаєте результат, втрачаєте помилки й ускладнюєте налагодження. У навчальному та продакшн-коді краще віддавати перевагу керованості: або чекаємо результат, або дуже явно показуємо, що він не потрібен.

Помилка №5: намагатися змішати main.swift і @main одночасно.
У SwiftPM-застосунках зазвичай обирають один підхід: або точку входу верхнього рівня через main.swift, або @main-структуру чи клас. Гайд по SwiftPM підкреслює, що це альтернативи: або main.swift, або @main точка входу, але не обидва одразу.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ