Не упустите эти пограничные случаи в своём коде

В 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-разработке, эти открытые уроки помогут вам быстро разобраться с основными инструментами и технологиями, которые пригодятся в первых проектах:

А для более системного развития в iOS-разработке рекомендуем ознакомиться с программой специализации "iOS Developer".

Комментарии (0)