Когда одного await мало
Представьте, что вам нужно узнать курс валюты и прогноз погоды, чтобы решить, стоит ли ехать за покупками. Это две независимые задачи: прогноз погоды никак не зависит от курса валюты. Если делать их по очереди — сначала дождаться курса, потом дождаться погоды — вы потратите время обеих операций подряд, хотя могли бы запустить их одновременно и подождать только один раз, пока не будут готовы обе.
Именно для такой ситуации в Swift есть два инструмента: async let для фиксированного, заранее известного числа параллельных операций, и TaskGroup — для случая, когда число операций заранее неизвестно, например зависит от размера массива.
async let: запустить и не ждать сразу
Когда вы вызываете обычную асинхронную функцию через await, выполнение текущей функции останавливается до получения результата. async let работает иначе: он запускает асинхронную операцию немедленно, но не останавливает выполнение — значение можно забрать позже, отдельным await, ровно в тот момент, когда оно действительно понадобится.
func loadUserName() async -> String {
try? await Task.sleep(for: .milliseconds(10))
return "Ирина"
}
func loadUserScore() async -> Int {
try? await Task.sleep(for: .milliseconds(10))
return 87
}
func loadProfile() async {
async let name = loadUserName()
async let score = loadUserScore()
// обе функции уже выполняются параллельно к этому моменту
print("\(await name): \(await score) очков")
}
Строки с async let не приостанавливают loadProfile() — они лишь объявляют, что значение появится позже, и запускают вычисление в фоне. Приостановка происходит только на строке с print, где стоят два await: к этому моменту обе операции уже какое-то время выполнялись параллельно, поэтому суммарное ожидание примерно равно самой долгой из них, а не сумме обеих.
Если вы объявили async let, но так и не обратились к значению через await до конца области видимости, компилятор сообщит об ошибке — значение обязано быть использовано.
TaskGroup: когда число задач неизвестно заранее
async let подходит, только если вы заранее, на этапе написания кода, знаете, сколько будет параллельных операций — по одной переменной на каждую. Но что делать, если нужно обработать параллельно элементы массива, длина которого известна только во время выполнения? Здесь на помощь приходит withTaskGroup.
func square(_ n: Int) async -> Int {
try? await Task.sleep(for: .milliseconds(10))
return n * n
}
func sumOfSquares(_ numbers: [Int]) async -> Int {
var total = 0
await withTaskGroup(of: Int.self) { group in
for n in numbers {
group.addTask {
await square(n)
}
}
for await result in group {
total += result
}
}
return total
}
withTaskGroup(of:) получает тип результата каждой дочерней задачи и замыкание, внутри которого вы наполняете группу через group.addTask — по одному вызову на каждую параллельную операцию. Все добавленные задачи стартуют одновременно (насколько это позволяет система). Дальше for await result in group перебирает результаты по мере их готовности.
Здесь важная особенность: результаты приходят в порядке завершения задач, а не в порядке их добавления. Если задача с индексом 3 завершится раньше задачи с индексом 0, вы получите её результат первой. Это нормально для суммы — порядок слагаемых не важен. Но если вам нужен результат именно в исходном порядке, придётся отдельно сохранять пару «индекс, значение» и сортировать после сбора, либо использовать другой подход.
Ошибка внутри группы
Если дочерние операции могут завершиться ошибкой, вместо withTaskGroup используется withThrowingTaskGroup. Как только одна из задач бросает ошибку, эта ошибка всплывает наружу при следующем обращении к группе — например, на очередной итерации for await или на завершающем await. Остальные ещё не завершённые задачи при этом автоматически помечаются как отменённые: группа не станет дожидаться их результата, если ошибка уже произошла.
func riskyDouble(_ n: Int) async throws -> Int {
if n < 0 {
throw NSError(domain: "demo", code: 1)
}
return n * 2
}
func doubledAllOrNothing(_ numbers: [Int]) async throws -> Int {
try await withThrowingTaskGroup(of: Int.self) { group in
for n in numbers {
group.addTask { try await riskyDouble(n) }
}
var total = 0
for try await value in group {
total += value
}
return total
}
}
Обратите внимание на try await перед value in group: именно на этой строке может вылететь ошибка одной из задач, и её нужно ловить или пробрасывать выше, как обычную ошибку из throws-функции.
Частые ошибки
- Использовать
async letвнутри цикла, ожидая, что количество переменных подстроится под данные — так не работает,async letсоздаёт ровно одну переменную на одну написанную строку кода. Для переменного числа задач нуженTaskGroup. - Полагаться на то, что результаты из
TaskGroupпридут в том же порядке, в каком вы вызывалиgroup.addTask. Порядок определяется временем завершения, а не временем добавления. - Забыть, что значение
async letнужно обязательно забрать черезawait— иначе будет ошибка компиляции о неиспользуемой асинхронной переменной. - Думать, что
async letзапускает код в отдельном потоке в духе ручной многопоточности — на самом деле это та же кооперативная модель конкурентности, что и у обычногоawait, просто с более ранним стартом. - В
withThrowingTaskGroupожидать, что все ошибки соберутся в один список. На деле наружу пробрасывается первая пойманная ошибка, а незавершённые задачи просто отменяются.
Резюме
async letзапускает асинхронную операцию сразу же, но приостанавливает выполнение только в момент явногоawait— подходит для заранее известного числа параллельных операций.withTaskGroupиwithThrowingTaskGroupнужны, когда число параллельных задач заранее неизвестно, например зависит от размера коллекции.- Задачи добавляются вызовом
group.addTask, а результаты читаются черезfor await result in group— в порядке завершения, а не добавления. - Ошибка в дочерней задаче
withThrowingTaskGroupвсплывает при следующем обращении к группе и отменяет остальные незавершённые задачи.
Проверьте себя
4 вопроса
Сумма двух квадратов параллельно
Дана уже готовая асинхронная функция square(_:), которая с небольшой задержкой возвращает квадрат числа. Напишите функцию sumOfSquares(_:_:), которая вычисляет квадраты двух переданных чисел параллельно через async let и возвращает их сумму.
Не вызывайте square последовательно через два обычных await подряд — используйте именно async let, чтобы обе операции стартовали одновременно.
Суммарная длина слов через TaskGroup
Дана уже готовая асинхронная функция delayedLength(_:), которая с небольшой задержкой возвращает длину строки в символах. Напишите функцию totalLength(_:), которая принимает массив строк произвольной длины (в том числе пустой массив) и возвращает суммарную длину всех строк, вычисленную параллельно через withTaskGroup.
Число строк в массиве заранее неизвестно, поэтому async let здесь не подойдёт — используйте группу задач и добавляйте по одной дочерней задаче на каждую строку в цикле.