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 с top‑level кодом;
- тип, помеченный @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 описывает, что top‑level код тоже может делать await: он выполняется как задача, и её завершение завершает программу.
Выглядит это примерно так:
import Foundation
let message = await loadWelcomeMessage()
print(message)
func loadWelcomeMessage() async -> String {
"Добро пожаловать в LibraryCLI!"
}
Но тут есть практический нюанс, особенно важный для SwiftPM‑проекта: обычно вы выбираете либо main.swift, либо @main‑entrypoint, но не оба сразу. В гайде по SwiftPM это прямо проговаривается: либо у вас есть main.swift, либо @main‑тип — и это разные варианты организации точки входа.
В рамках курса, где мы строим CLI‑приложение с архитектурой и модулями, чаще удобнее @main, потому что точка входа становится явным типом, в который удобно «вкрутить» зависимости (сервисы, репозитории и т.д.). Но иногда main.swift проще для небольших учебных заготовок.
3. Где именно в нашем LibraryCLI появляется async‑логика
Давайте аккуратно привяжем идею к нашему приложению. У нас (условно) есть цикл чтения команд, парсер, сервисный слой и вывод результата. Раньше всё было синхронно. Теперь представим, что какая-то операция стала асинхронной: например, сервис может «обогащать» книгу данными из внешнего источника (неважно какого; сеть мы сейчас не проектируем — важен факт асинхронности).
Мы можем выразить это так:
import Foundation
struct LibraryService {
func resolveBookTitle(id: Int) async -> String {
// Пока просто “как будто асинхронно”.
return "Book #\(id)"
}
}
Теперь вопрос: кто вызывает resolveBookTitle? Если вызывающий код синхронный, ему нужно либо стать async, либо запускать задачу через Task {}. И вот тут мы переходим к главной практической дилемме: «протаскивать async вверх» или «оборачивать в Task».
4. Task { ... }: запуск async‑работы из синхронного кода
Что такое Task {} на человеческом языке
Task { ... } — это способ сказать: «Запусти вот эту асинхронную работу как задачу». Это особенно полезно, когда вы находитесь в синхронной функции (где await запрещён), но вам нужно инициировать async‑операцию.
В документации structured concurrency это называется unstructured task: задача, которая не является дочерней задачей внутри withTaskGroup или async let, и которую можно запускать более свободно — в том числе из синхронного кода. При создании Task { ... } вы получаете «хэндл задачи» (task handle): значение типа 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:", title) // title: Book #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("resolved:", title) // resolved: Book #42
}
}
Заметьте, что startTitleTask — синхронная функция, но она возвращает Task<String, Never>, который уже можно await‑ить позже, внутри 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 "Book #\(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("resolved:", title)
} catch {
print("error:", error) // error: notFound
}
}
}
Здесь вы видите сразу две важные вещи: Task может инкапсулировать async throws‑операцию, а ожидание результата (task.value) — это тоже ожидание, поэтому там появляется await. Это напрямую следует из модели: Task‑хэндл используется, чтобы потом «await result of the task».
6. Что выбрать в реальном коде: сделать функцию async или использовать Task {}
Сейчас будет небольшая «таблица здравого смысла», потому что новички часто либо боятся async и оборачивают всё в Task, либо наоборот начинают помечать async половину проекта и теряют структуру.
| Ситуация | Что обычно лучше | Почему |
|---|---|---|
| Вы контролируете сигнатуру функции и можете менять её | Сделать функцию async и вызывать через await | Так честнее: async‑эффект виден в интерфейсе, и код проще читать сверху вниз |
| Вы в синхронном 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‑контекст «локально», не заставляя немедленно переписывать все сигнатуры вокруг.
Как это выглядит в LibraryCLI: аккуратный скелет main() async
Давайте соберём более «похожий на наше приложение» каркас. Без сетевых деталей и без параллельности — только запуск и ожидание.
import Foundation
enum Command {
case showTitle(id: Int)
case exit
}
struct LibraryService {
func resolveBookTitle(id: Int) async -> String {
"Book #\(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) // Book #7
case .exit:
print("bye")
}
}
}
Ключевая мысль: как только 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, потом await‑им её результат.
Ошибка №2: запускать Task { ... } в CLI и сразу завершать программу.
В GUI это часто «прокатывает», потому что приложение не завершается. В CLI вполне реально: вы создали Task, функция вернулась, main закончился — процесс завершился, и ваш task не успел ничего вывести. Если результат важен, лучше иметь main() async и дождаться результата напрямую, либо сохранить хэндл и await‑нуть .value до выхода.
Ошибка №3: делать лишний Task там, где можно просто протащить async.
Новички иногда начинают писать Task { ... } вообще везде, потому что это «решает проблему await». Но тогда у вас пропадает ясность: где именно программа ждёт, где завершается, где ловятся ошибки. Если вы контролируете сигнатуру функции — чаще проще и чище сделать её async и вызывать с await.
Ошибка №4: забывать, что «запустили и забыли» — это тоже решение, но с последствиями.
Swift Concurrency допускает, что задача выполняется до конца даже без сохранения хэндла. Это удобно для редких случаев, но опасно как дефолт: вы теряете результат, теряете ошибки и усложняете отладку. В учебном и прод‑коде лучше предпочитать управляемость: либо ждём результат, либо очень явно показываем, что результат не нужен.
Ошибка №5: пытаться смешать main.swift и @main одновременно.
В SwiftPM‑приложениях обычно выбирают один подход: либо top‑level entrypoint через main.swift, либо @main‑структура/класс. Гайд по SwiftPM подчёркивает, что это альтернативы: либо main.swift, либо @main entrypoint, но не оба сразу.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ