Не упустите эти пограничные случаи в своём коде
В Swift для структурированной конкуренции используются async let
и группы задач (task group). Хотя обе конструкции позволяют запускать параллельные операции, они по-разному управляют жизненным циклом задач. Сегодня мы разберём эти различия на примерах.
Примечание: Я предполагаю, что вы знакомы с базовыми концепциями Swift Concurrency. Если не знакомы — загляните в официальную документацию, там всё неплохо расписано.
async let
С помощью async let
можно запустить несколько задач, которые будут выполняться одновременно.
func fetchData() async {
async let first = fetchPart1()
async let second = fetchPart2()
async let third = fetchPart3()
let result = await (first, second, third)
print(result)
}
Хотя в коде это не указано явно, здесь создаются три новые дочерние задачи, по одной для каждого async let
.
async let
может работать как с синхронными, так и с асинхронными функциями:
func fetchPart1() -> Int { ... }
func fetchPart2() async -> Int { ... }
Кроме того, async let
может работать с любыми выражениями:
async let number = 123
async let str = "Hello World!"
Как это работает? Под капотом async let
оборачивает инициализатор в отдельную задачу, которая выполняется параллельно. Задача стартует сразу после того, как интерпретатор натыкается на async let
.
async let first = fetchPart1()
async let second = fetchPart2()
async let number = 123
async let str = "Hello World!"
// Преобразуется в нечто вроде этого:
let first = ChildTask {
fetchPart1()
}
let second = ChildTask {
await fetchPart2()
}
let number = ChildTask {
123
}
let str = ChildTask {
"Hello World!"
}
Жизненный цикл async let
привязан к локальной области, в которой оно создаётся, например, функциям, замыканиям или блокам do/catch. Когда выполнение выходит из этой области — либо нормально, либо из-за ошибки — все задачи, созданные с помощью async let
, будут неявно отменены и дожидаться завершения.
⚠️: Даже если вы не вызываете явно await
для async let
, await
всё равно сработает в конце локальной области. Это означает, что группа async let
всегда выполняется до тех пор, пока не завершится её самая длительная дочерняя задача.
⚠️: Учтите, что отмена задачи не останавливает её, она лишь помечает, что результаты больше не будут нужны. Swift Concurrency использует кооперативную модель отмены. Каждая задача проверяет, была ли она отменена в нужные моменты выполнения (с помощью Task.isCancelled
или Task.checkCancellation())
, и реагирует на отмену соответствующим образом. В зависимости от того, что делает задача, обычно это означает одно из трёх:
Выброс ошибки, например,
CancellationError
Возврат nil или пустой коллекции
Возврат частично выполненной работы
Ещё один важный момент, который стоит отметить, можно увидеть здесь:
async let result1 = task1()
async let result2 = task2()
let results = await (result1, result2)
Обратите внимание — это не сразу бросается в глаза, но хотя дочерние задачи, созданные с помощью async let
, выполняются параллельно, их ожидание в кортеже (tuple) происходит последовательно. Swift выполняет элементы кортежа слева направо, следуя правилам вычисления выражений.
Чтобы было понятнее, все следующие выражения эквивалентны по порядку последовательного ожидания каждого результата:
await (result1, result2)
// или
(await result1, await result2)
// или
await result1
await result2
// порядок ожидания будет одинаковым
Это может привести к запутанному поведению, когда дочерние задачи выбрасывают ошибки, но мы обсудим это позже в разделе о пограничных случаях распространения ошибок.
Загляните в Swift Evolution proposal по async let
— там хорошо расписано, почему не существует async var
и зачем запретили передавать async let
в сбегающие замыкания.
TaskGroup
Если вам нужно создать динамическое количество параллельных задач, то лучше использовать группу задач (task group):
func fetchData(count: Int) async {
var results = [String]()
await withTaskGroup(of: String.self) { group in
for index in 0..<count {
group.addTask {
await self.fetchPart(index)
}
}
for await result in group {
results.append(result)
}
}
print(results)
}
Объект group внутри замыкания на самом деле представляет собой AsyncSequence. Поэтому вместо for await
можно использовать другой способ итерации с методом .next()
, который дает тот же результат:
while let result = await group.next() {
results.append(result)
}
Жизненный цикл task group ограничен телом замыкания в withTaskGroup
. Но логика работы немного сложнее, чем с async let
:
Если выполнение выходит из замыкания нормально, не ожидая дочерние задачи, они будут неявно ожидаемы (но не отменены).
Если выполнение выходит из замыкания из-за ошибки, дочерние задачи будут и отменены, и дожидаться завершения (awaited).
Например, если мы уберём часть с for await
в коде группы задач, выполнение не просто перейдёт к печати результатов (которые, очевидно, будут пустыми). Вместо этого оно сначала будет ожидать завершения всех дочерних задач до тех пор, пока каждая из них не завершится.
Примечание: Важное отличие от async let
— это порядок, в котором результаты ожидаются. В отличие от async let
, где порядок зависит от кода, группа задач использует подход «первый завершился → первый обработан», основываясь на том, как работает AsyncSequence
.
Пограничные случаи жизненного цикла
Мы кратко рассмотрели логику жизненного цикла для неявной отмены и ожидания структурированных задач.
Жизненный цикл async let
привязан к локальной области, где он создан, такой как функция, замыкание или блок do/catch
. Когда выполнение выходит из этой области — либо нормально, либо из-за ошибки — все задачи, созданные с помощью async let
, будут неявно отменены и ожидаемы.
Жизненный цикл группы задач привязан к замыканию внутри функции withTaskGroup
. Но логика работы немного сложнее, чем с async let
:
Если выполнение выходит из замыкания, не дождавшись дочерних задач, они будут неявно ожидаемы (но не отменены).
Если выполнение выходит из замыкания из-за ошибки, дочерние задачи будут и отменены, и ожидаемы.
Звучит немного замороченно? Давайте рассмотрим на примерах.
Примечание: Не дожидаться завершения структурированных задач — не всегда хорошая идея. Даже если вы хотите создать параллельные операции "fire and forget", не заботясь о результатах, структурированные задачи могут не работать так, как вы ожидаете. Помните, что как async let
, так и группы задач имеют неявное ожидание, что означает, что вам всегда нужно будет подождать завершения самой длительной дочерней задачи перед тем, как продолжить. Это делает настоящий "fire and forget" невозможным, если только вы не обернёте их в несвязанную родительскую задачу или не воспользуетесь полностью неструктурированным подходом.
Что будет, если мы не ожидаем дочерние задачи и выходим из локальной области нормально?
async let
Когда мы выходим из локальной области нормально, не ожидая дочерние задачи, задачи async let
будут неявно отменены и неявно ожидаемы:
func fast() async {
print("fast started")
do {
/// Если текущая задача отменяется до завершения,
/// функция Task.sleep выбросит `CancellationError`.
try await Task.sleep(nanoseconds: 5_000_000_000)
} catch {
print("fast cancelled", error)
}
print("fast ended")
}
func slow() async {
print("slow started")
do {
try await Task.sleep(nanoseconds: 10_000_000_000)
} catch {
print("slow cancelled", error)
}
print("slow ended")
}
func go() async {
async let f = fast()
async let s = slow()
print("leaving local scope")
}
// Печатает:
// leaving local scope
// fast started
// slow started
// slow cancelled CancellationError()
// slow ended
// fast cancelled CancellationError()
// fast ended
Имейте в виду, что задачи будут неявно отменяться и ожидаться в обратном порядке, начиная с последней определённой задачи async let
. В этом примере сначала будет неявно отменена и ожидаться задача slow
, а затем задача fast
также будет неявно отменена и ожидаться.
Если последняя задача async let
будет долго завершаться и не будет должным образом обрабатывать отмену, предыдущие задачи не будут отменены до завершения первой, что повлияет на общее время завершения группы. Например, если мы сделаем задачу slow «ещё более медленной» и проигнорируем отмену для неё:
func slow() async {
print("slow started")
do {
try await Task.sleep(nanoseconds: 10_000_000_000)
} catch {
print("slow cancelled", error)
}
sleep(10) // sleep проигнорирует отмену
print("slow ended")
}
// Функции fast и go одинаковые
// Печатает:
// leaving local scope
// fast started
// slow started
// slow cancelled CancellationError()
// fast ended // после 5 секунд
// slow ended // после 10 секунд
Как видно, fast
завершилась раньше, чем slow
, поэтому её даже не стали отменять.
Task group
Когда мы выходим из замыкания Task group
нормально, не ожидая дочерние задачи, все дочерние задачи будут неявно дожидаться завершения, но не отменятся:
await withTaskGroup(of: Void.self) { group in
group.addTask {
await fast()
}
group.addTask {
await slow()
}
print("leaving task group closure")
}
// функции fast и slow такие же
// Печатает:
// leaving task group closure
// fast started
// slow started
// fast ended // после 5 секунд
// slow ended // после 20 секунд
А что, если при этом случится ошибка, и мы покинем область без ожидания?
Примечание: Помните, что если дочерняя задача не ожидается явно, ошибка, возникшая внутри дочерней задачи, не будет передана наружу и не попадёт в блок catch. (Правило: не ожидается явно → не передаётся). Группа задач будет только неявно ожидать свои дочерние задачи в этом случае (без отмены), как и раньше — когда ошибки не было.
Если дочерние задачи не ожидаются явно, но внутри локальной области async let
или замыкания TaskGroup
каким-то образом выбрасывается ошибка, и выполнение выходит из этой области, не перехватив её — все дочерние задачи будут неявно отменены и дожидаются завершения, после чего ошибка будет передана наружу.
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await fast()
}
group.addTask {
try await slow()
}
print("leaving task group closure")
throw TestError()
}
// Печатает:
// leaving task group closure
// fast started
// fast cancelled CancellationError()
// fast ended
// slow started
// slow cancelled CancellationError()
// slow ended
// external catch TestError() // ошибка поймана вне замыкания группы задач
В отличие от async let
, где задачи отменяются и дожидаются в обратном порядке объявления, для группы задач:
не существует определённого порядка для отмены и ожидания (случайный порядок).
Сначала она неявно отменяет все дочерние задачи, а затем неявно ожидает их.
Мы можем это проверить на следующем примере:
func fast() async throws {
print("fast started")
do {
try await Task.sleep(nanoseconds: 5_000_000_000)
} catch {
print("fast cancelled", error)
}
sleep(5) // this sleep will ignore cancellation
print("fast ended")
}
func slow() async throws {
print("slow started")
do {
try await Task.sleep(nanoseconds: 10_000_000_000)
} catch {
print("slow cancelled", error)
}
sleep(10) // этот sleep проигнорирует отмену
print("slow ended")
}
// код с withThrowingTaskGroup такой же, как в предыдущем примере
// Печатает:
// leaving task group closure
// slow started
// fast started
// fast cancelled CancellationError()
// slow cancelled CancellationError()
// fast ended // через 5 секунд
// slow ended // через 10 секунд
// external catch TestError() // ошибка поймана вне замыкания группы задач
Как видно, сначала неявно отменяются обе задачи fast
и slow
, а затем они неявно ожидаются.
Что если мы будем ожидать дочерние задачи и ошибка обрабатывается локально?
async let
Задачи async let
будут неявно отменены и неявно ожидаемы.
Предположим, что мы выбрасываем ошибку в fast
и slow
:
func fast() async throws {
print("fast started")
do {
try await Task.sleep(nanoseconds: 5_000_000_000)
} catch {
print("fast cancelled", error)
}
print("fast ended")
throw TestError1() // <- ЗДЕСЬ
}
func slow() async throws {
print("slow started")
do {
try await Task.sleep(nanoseconds: 10_000_000_000)
} catch {
print("slow cancelled", error)
}
print("slow ended")
throw TestError2() // <- ЗДЕСЬ
}
async let f = fast()
async let s = slow()
do {
try await (f, s)
} catch {
print("caught error locally", error)
}
print("leaving local scope")
// Печатает:
// fast started
// slow started
// fast ended // через 5 секунд
// caught error locally TestError1()
// leaving local scope
// slow cancelled CancellationError()
// slow ended
Обратите внимание: ошибка TestError2
из slow
не была поймана. Почему? Потому что fast
ждали первым, и он упал с ошибкой. После этого блок do
завершился, и slow
остался без await
.
Порядок неявной отмены и ожидания такой же, как и раньше для async let
— в обратном порядке относительно их объявления.
Помните, что порядок ожидания может напрямую повлиять на порядок распространения ошибок. Если мы сначала будем ожидать задачу slow
, ошибка TestError2
будет передана, хотя fast
закончилась раньше и тоже упала с ошибкой — её просто не ждали:
try await (s, f) // изменили порядок ожиданий
// Prints:
// fast started
// slow started
// fast ended // через 5 секунд
// slow ended // через 10 секунд
// caught error locally TestError2()
// leaving local scope
Task group
Задачи группы задач будут неявно ожидаемы, но не отменены.
Допустим, снова обе задачи — fast
и slow
— выбросят ошибки.
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await fast()
}
group.addTask {
try await slow()
}
do {
for try await result in group {
print("Received: \(result)")
}
} catch {
print("caught error locally", error)
}
print("leaving task group closure")
}
// fast и slow те же, что и в предыдущем примере
// Печатает:
// fast started
// slow started
// fast ended // через 5 секунд
// caught error locally TestError1()
// leaving task group closure
// slow ended // через 10 секунд
Когда fast выбрасывает TestError1
, мы ловим его локально, и после того как мы покидаем замыкание группы задач, slow
будет неявно ожидаем. Хотя позже slow
выбрасывает ошибку TestError2
, мы не поймаем её (поскольку она больше не ожидается → не передана).
А что, если мы всё-таки ждём задачи, а ошибка уходит наружу?
async let
Задачи async let будут неявно отменены и неявно ожидаемы.
Давайте уберём локальный блок do/catch
, чтобы проверить этот случай:
async let f = fast()
async let s = slow()
try await (f, s)
print("leaving local scope")
// fast и slow такие же, как в предыдущем примере
// Печатает:
// slow started
// fast started
// fast ended // через 5 секунд
// slow cancelled CancellationError()
// slow ended
// extenral catch TestError1()
После того как fast
выбрасывает TestError1
, выполнение переходит к внешнему блоку catch
, и slow
так и остаётся без await
. Задача slow
неявно отменяется и неявно ожидается. Ошибка TestError1
передаётся наружу и ловится внешним блоком catch
.
Task group
Задачи группы задач будут неявно отменены и неявно ожидаемы.
Давайте уберём локальный блок do/catch
, чтобы проверить этот случай:
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await fast()
}
group.addTask {
try await slow()
}
for try await result in group { // убрали do/catch
print("Received: \\(result)")
}
print("leaving task group closure")
}
// fast и slow такие же, как в предыдущем примере
// Печатает:
// slow started
// fast started
// fast ended // через 5 секунд
// slow cancelled CancellationError()
// slow ended
// external catch TestError1()
После того как fast
выбрасывает TestError1
, выполнение переходит во внешний блок catch
, а slow
остаётся без await
. Задача slow
при этом неявно отменяется и дожидается завершения. Ошибка TestError1
передаётся и перехватывается за пределами замыкания группы задач.
Заключение
Примечание: Если в вашем коде есть логика распространения ошибок, TaskGroup
почти всегда будет надёжнее, чем async let
. Логика «первый выброшен, первый пойман» в группе задач более предсказуема и не зависит от порядка ожидания, в отличие от async let
, где распространение ошибок может зависеть от порядка ожидания. Группы задач более подходят для случаев, когда вы хотите реализовать подход «падать сразу, как только что-то пошло не так», в отличие от async let
.
Как вы могли заметить, было много случаев, которые нужно было учесть. Надеюсь, ваш мозг ещё не закипел. К счастью, логика этих случаев проще, чем кажется на первый взгляд. Чтобы сделать это более наглядным и легче для понимания, мы можем построить диаграмму.

Удачи в покорении Swift Concurrency — дальше будет только интереснее. Увидимся в следующих статьях!
Если вы новичок в iOS-разработке, эти открытые уроки помогут вам быстро разобраться с основными инструментами и технологиями, которые пригодятся в первых проектах:
21 июля в 20:00 — Пишем приложение с MapKit
Как создать простое приложение на SwiftUI с картами, работать с MapKit и использовать UIViewRepresentable для интеграции с UIKit.7 августа в 20:00 — Лучшие практики для виджетов iOS 2025
Разработка виджетов и Live Activity, новые подходы в iOS 2025.13 августа в 20:00 — От первого HTTP-запроса к собственному сетевому слою в Swift
Основы сетевого взаимодействия в iOS, корректная обработка запросов и ответов, построение модульного сетевого слоя для приложения.
А для более системного развития в iOS-разработке рекомендуем ознакомиться с программой специализации "iOS Developer".