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 точка входу, але не обидва одразу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ