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-потоков.