async/await и структурная конкурентность

Что даёт async/await поверх GCD, чем async let отличается от TaskGroup и что происходит с потоком в точке приостановки.

await — это не «жди здесь», а «здесь функция может отдать поток». Блокируется логическая задача, а не поток исполнения.

«Что даёт async/await по сравнению с completion handler?»

Что на самом деле проверяют

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

Развёрнутый ответ

Колбэчная версия тащит обработку ошибок вручную: ничто не мешает не вызвать completion или вызвать дважды, и ничто не подскажет, на какой очереди он придёт.

func loadProfile(id: String, completion: @escaping (Result<Profile, Error>) -> Void) {
    api.user(id) { result in
        switch result {
        case .failure(let e): completion(.failure(e))
        case .success(let user):
            api.avatar(user.avatarID) { avatarResult in
                completion(avatarResult.map { Profile(user: user, avatar: $0) })
            }
        }
    }
}

// то же самое на async/await
func loadProfile(id: String) async throws -> Profile {
    let user = try await api.user(id)
    let avatar = try await api.avatar(user.avatarID)
    return Profile(user: user, avatar: avatar)
}

Выигрыш не косметический: ошибки идут обычным throw, работают defer и guard, компилятор гарантирует один выход, а отмена расходится по дереву задач сама.

Task и Task.detached

Task { } — единица асинхронной работы и мост из синхронного мира в асинхронный. Она наследует контекст места создания: приоритет, task-local значения и изоляцию актёра. Task.detached не наследует ничего — осознанный разрыв, нужный редко.

@MainActor
final class FeedViewModel: ObservableObject {
    @Published private(set) var posts: [Post] = []
    func reload() {
        Task {                                   // наследует MainActor и приоритет
            posts = try await api.feed()         // присваивание уже на main
        }
        Task.detached(priority: .background) {   // ничего не наследует
            await Analytics.flush()
        }
    }
}

«Что значит suspension point и теряется ли при этом поток?»

Что на самом деле проверяют

Ключевое отличие от GCD: await дешевле семафора, ведь он не держит поток.

Развёрнутый ответ

Каждое await — потенциальная точка приостановки: компилятор режет функцию на куски и сохраняет состояние в continuation. Когда задача приостанавливается, поток не блокируется: он возвращается в кооперативный пул и берёт другую работу, а после возобновления задача может продолжиться уже на другом потоке. Практические следствия:

  • Размер кооперативного пула равен числу ядер: тысяча задач не создаёт тысячу потоков, в отличие от GCD.
  • Нельзя рассчитывать на «тот же поток после await», а значит и на thread-local хранилище.
  • Состояние могло измениться, пока задача стояла на паузе — разбираем это в уроке про актёров.
  • Блокирующие вызовы (Thread.sleep, semaphore.wait) внутри async-функции выедают поток из пула.

«Чем async let отличается от TaskGroup?»

Что на самом деле проверяют

Понимание структурной конкурентности: дочерние задачи живут в области видимости родителя и не могут его пережить — родитель не вернётся, пока все дети не завершились или не отменены.

Развёрнутый ответ

async let подходит, когда число операций известно на этапе компиляции, а результаты разнотипные: задача стартует в момент объявления, await забирает результат.

func loadScreen() async throws -> Screen {
    async let profile = api.profile()      // обе задачи стартуют сразу
    async let feed = api.feed()
    return try await Screen(profile: profile, feed: feed)
}

withTaskGroup нужен, когда число задач динамическое, а тип результата один. Группа даёт и агрегацию, и общий контроль отмены.

func thumbnails(for ids: [String]) async throws -> [String: UIImage] {
    try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
        for id in ids {
            group.addTask { (id, try await loadThumbnail(id)) }
        }
        var result: [String: UIImage] = [:]
        for try await (id, image) in group { result[id] = image }
        return result
    }
}

Если в группе бросит одна задача, остальные получат отмену, а ошибка улетит наружу.

Мост из старого API и возврат на главный поток

Колбэчный API оборачивают в withCheckedThrowingContinuation. Правило жёсткое: resume вызывается ровно один раз — ноль вызовов навсегда подвешивает задачу, два роняют приложение.

func location() async throws -> CLLocation {
    try await withCheckedThrowingContinuation { continuation in
        locationManager.requestLocation { result in
            continuation.resume(with: result)     // ровно один раз
        }
    }
}

Вместо DispatchQueue.main.async в новой модели пишут @MainActor: аннотация на типе, методе или замыкании гарантирует выполнение на главном акторе, и это проверяет компилятор. А Sendable — маркер того, что значение безопасно передавать между областями изоляции. Компилятор ругается на захват не-Sendable типа (класса с изменяемым состоянием), потому что иначе две задачи получили бы на него ссылку и устроили data race. Лечится структурой-значением, актёром или final class с иммутабельными полями.

Типичные ошибки кандидатов

  • Говорят «await блокирует поток» — блокируется задача, поток уходит в пул.
  • Считают, что два подряд идущих try await выполняются параллельно; параллельность даёт только async let, группа или отдельные Task.
  • Пишут Task.detached по умолчанию и теряют приоритет, изоляцию и отмену.
  • Забывают, что async let надо дождаться: невостребованная задача отменяется на выходе из области видимости.
  • Вызывают continuation.resume дважды или ни разу.
  • Глушат предупреждения о Sendable через @unchecked Sendable без реальной синхронизации.

Как ответить кратко

async/await превращает цепочку колбэков в линейный код с обычным throw, гарантированным единственным выходом и встроенной отменой. Плюс поток не блокируется: в точке приостановки он возвращается в пул.

async let — для фиксированного числа разнотипных операций: стартуют сразу, забираются через await. TaskGroup — для динамического числа однотипных задач с агрегацией и общей отменой. Оба варианта структурные: дочерние задачи не переживают родителя.

Suspension point — место, где задача может отдать поток. Состояние сохраняется в continuation, поток уходит выполнять другую работу, после возобновления он может оказаться другим. Поэтому тысячи async-задач дешевле десятков заблокированных GCD-потоков.

Проверьте себя
1. Что происходит с потоком в точке приостановки (suspension point)?
AПоток блокируется до возобновления задачи
BПоток завершается, а после возобновления создаётся новый
CПоток возвращается в кооперативный пул и берёт другую работу
DПоток переходит в главную очередь и ждёт там
2. Когда уместнее withTaskGroup, а не async let?
AКогда число параллельных задач известно заранее, а типы результатов разные
BКогда число задач определяется в рантайме, а результаты однотипные
CКогда нужно запустить задачу, переживающую родительскую
DКогда работу надо выполнить строго последовательно
3. Чем Task { } отличается от Task.detached { }?
ATask { } всегда выполняется на главном потоке, а detached — в фоне
BTask { } наследует приоритет, task-local значения и изоляцию актёра, а detached — нет
CTask.detached нельзя отменить
DРазницы нет, detached — устаревший синоним