JavaRush /Курсы /Swift SELF /Ошибки и отмена в TaskGroup

Ошибки и отмена в TaskGroup

Swift SELF
70 уровень , 0 лекция
Открыта

1. Полузаписанное состояние и политика обработки ошибок

Когда вы впервые начинаете параллелить задачи, голова радуется: «О, теперь оно будет быстрее!». А потом приходит реальность и спрашивает: «А что будет, если третья задача упала, седьмую отменили, а первая уже успела что-то записать?». Именно это и называют «полузаписанным» состоянием: часть изменений применена, часть — нет, а итог напоминает книгу, у которой половина страниц напечатана, а другая половина — только оглавление.

В CLI‑приложении вроде нашего LibraryCLI это особенно неприятно. Например, команда fetch-many может сходить за данными по нескольким id, а потом записать их в локальное хранилище. Если в середине пачки случилась ошибка или отмена, мы рискуем получить «полубазу»: какие-то книги обновились, какие-то нет, индекс поиска пересобран наполовину, а JSON‑файл сохранён так, что в нём правда только половина данных.

Здесь важная мораль (и немного самоиронии): проблема не в том, что Swift Concurrency плохой. Проблема в том, что побочные эффекты (запись в репозиторий, в файл, обновление общего состояния) не любят, когда их делают «по чуть-чуть» из параллельных задач.

Две политики: fail-fast и partial success — их нельзя «не выбрать»

Обычно хочется «и рыбку съесть, и чтобы база была целая». Но в batch‑операциях вам почти всегда нужно заранее решить, какая политика ошибки у вас сегодня в меню. Иначе код получится противоречивым: где-то мы ведём себя как fail-fast, а где-то — как partial success, и потом никто (включая автора кода через неделю) не понимает, что считается правильным результатом.

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

Политика Смысл Типичный инструмент
Fail-fast Любая ошибка делает результат всей операции ошибкой withThrowingTaskGroup и try await group.next()
Partial success Ошибка одной задачи не мешает собрать успехи других withTaskGroup, а каждая задача возвращает Result (ошибка как значение)

В прошлой лекции мы уже делали partial success. Здесь фокус на второй «опасной» половине дня: ошибки, отмена и целостность состояния, особенно в fail-fast сценариях, где очень легко случайно оставить систему в «полусделанном» виде.

2. withThrowingTaskGroup: где именно проявляется ошибка

У throwing‑группы есть простая, но коварная особенность: дочерние задачи могут начать выполняться сразу после addTask, но ошибка становится «видимой» в тот момент, когда родитель пытается получить результат. Поэтому чтение результатов делается через try await group.next(), и именно там может произойти бросок ошибки.

Сначала сделаем мини‑модель «как будто сеть», но без реальной сети, чтобы не отвлекаться:

import Foundation

struct Book {
    let id: Int
    let title: String
}

enum FetchError: Error {
    case notFound(Int)
}

func fetchBook(id: Int) async throws -> Book {
    try await Task.sleep(nanoseconds: UInt64(id) * 20_000_000) // имитация задержки
    if id == 13 { throw FetchError.notFound(id) }
    return Book(id: id, title: "Book #\(id)")
}

Теперь классический fail-fast batch:

import Foundation

func fetchAllOrNothing(ids: [Int]) async throws -> [Book] {
    try await withThrowingTaskGroup(of: Book.self, returning: [Book].self) { group in
        for id in ids {
            group.addTask { try await fetchBook(id: id) }
        }

        var books: [Book] = []
        while let book = try await group.next() { // здесь может "вылезти" ошибка
            books.append(book)
        }
        return books
    }
}

Важный нюанс: если из withThrowingTaskGroup «наружу» вылетела ошибка, группа отменяет оставшиеся задачи и дожидается их завершения перед тем, как пробросить ошибку выше. Это часть «структурной» модели: дочерние задачи не остаются сиротами, они обязаны закончить жизнь внутри области видимости группы.

Звучит безопасно… пока вы не начинаете делать побочные эффекты внутри дочерних задач.

3. Отмена в Swift Concurrency и group.cancelAll()

Почему cancelAll() — не «кнопка стоп», а кооперативная отмена

Когда мы говорим «отмена», новички часто ожидают поведение в стиле: «Нажал кнопку — и всё мгновенно выключилось». Но в Swift отмена кооперативная: задача должна дойти до точки, где она может заметить отмену (обычно это await‑точки или явные проверки), и только тогда корректно завершиться.

Для TaskGroup важно помнить, что группа может оказаться отменённой тремя базовыми способами: если из body выбросили ошибку, если отменили родительскую задачу, или если мы сами вызвали group.cancelAll(). Это не «редкие случаи», это штатные сценарии, которые надо уметь читать глазами.

Чтобы отмена сработала быстро, дочерняя операция должна «сотрудничать». Простейший пример — проверить отмену до и после ожиданий:

import Foundation

func fetchBookCancellable(id: Int) async throws -> Book {
    try Task.checkCancellation()                  // бросит CancellationError, если отменили
    try await Task.sleep(nanoseconds: 80_000_000) // sleep при отмене тоже бросает CancellationError
    try Task.checkCancellation()
    return Book(id: id, title: "Book #\(id)")
}

Обратите внимание на мелочь, которая ломает много нервов: Task.sleep(...) в Swift — throwing, и при отмене бросает CancellationError. Если вы делаете try await Task.sleep(...), вы уже участвуете в «протоколе отмены», даже если не писали ни одной проверки Task.isCancelled.

Когда и зачем отменять пачку задач

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

Вот минимальный пример: «верни первую книгу, которая успела загрузиться» (да, немного странная бизнес‑логика, зато отлично показывает механику):

import Foundation

func firstFinishedBook(ids: [Int]) async -> Book? {
    await withTaskGroup(of: Book.self, returning: Book?.self) { group in
        for id in ids {
            group.addTask { try await fetchBookCancellable(id: id) }
        }

        let first = await group.next()
        group.cancelAll() // просим остальных остановиться
        return first
    }
}

Здесь важно понимать две вещи.

Во‑первых, cancelAll() — это запрос отмены: он помечает задачи как отменённые, но если внутри задач нет await и нет проверок отмены, они могут «добежать до финиша» всё равно.

Во‑вторых, отмена пачки не должна оставлять побочных эффектов, иначе вы получите странное состояние: «мы вроде отменили, но половина задач уже успела что-то записать».

4. Правило целостности: дочерние задачи считают, родитель применяет

Если вы запомните из этой лекции одну мысль — пусть будет эта: не выполняйте side effects внутри дочерних задач группы. Пусть дочерние задачи добывают данные (или вычисляют изменения), а родительский код уже решает, что и когда применять.

Это напрямую связано со свойством TaskGroup: группа гарантирует, что все дочерние задачи завершатся до выхода из области видимости, но она не гарантирует, что ваш файл/репозиторий/индекс не будет «потрёпан» параллельными записями.

Представим наш LibraryCLI на уровне идеи. Команда fetch-many делает примерно такое:

flowchart TD
    A[IDs из команды] --> B[TaskGroup: параллельно получить данные]
    B --> C[Собрать результаты в памяти]
    C --> D{Ошибки? отмена?}
    D -->|да| E[Ничего не применять / вернуть ошибку]
    D -->|нет| F[Последовательно применить изменения]
    F --> G[Одна запись на диск / одно save]

Сделаем «изменение» явным типом. Это помогает мозгу: вместо «мы что-то мутируем где-то» у нас появляется объект «что именно хотим применить».

import Foundation

struct BookUpdate {
    let id: Int
    let book: Book
}

Теперь «параллельный расчёт апдейтов»:

import Foundation

func computeUpdates(ids: [Int]) async throws -> [BookUpdate] {
    try await withThrowingTaskGroup(of: BookUpdate.self, returning: [BookUpdate].self) { group in
        for id in ids {
            group.addTask {
                let book = try await fetchBookCancellable(id: id)
                return BookUpdate(id: id, book: book)
            }
        }

        var updates: [BookUpdate] = []
        while let u = try await group.next() {
            updates.append(u)
        }
        return updates
    }
}

И отдельно — «последовательное применение». Для лекции сделаем простой репозиторий в памяти:

import Foundation

final class InMemoryLibraryRepository {
    private var storage: [Int: Book] = [:]

    func apply(_ updates: [BookUpdate]) {
        for u in updates {
            storage[u.id] = u.book
        }
    }
}

И «оркестрация» (то самое «собрать → проверить → применить»):

import Foundation

func fetchMany(ids: [Int], repo: InMemoryLibraryRepository) async throws {
    let updates = try await computeUpdates(ids: ids) // параллельная часть
    repo.apply(updates)                              // последовательная часть
}

Почему это спасает от «полузаписанного» состояния? Потому что если computeUpdates упадёт с ошибкой, мы вообще не дойдём до repo.apply. Fail-fast превращается в «ничего не применили» — а это намного легче объяснить пользователю и намного легче поддерживать, чем «применили 37%».

5. Как не спутать CancellationError с «настоящей» ошибкой

Когда вы делаете batch‑операцию, в продакшене очень быстро появляется необходимость отличать «отменили» от «сломалось». Отмена — это часто нормальный сценарий: пользователь прервал команду, таймаут сработал выше по стеку, или мы сами решили остановиться через group.cancelAll(). А вот FetchError.notFound(13) — уже предмет разговора: это проблема данных.

Мини‑пример обработки на верхнем уровне (условно, внутри команды CLI):

import Foundation

func runFetchMany(ids: [Int], repo: InMemoryLibraryRepository) async {
    do {
        try await fetchMany(ids: ids, repo: repo)
        print("Готово: обновили \(ids.count) книг") // примерный текст
    } catch is CancellationError {
        print("Операция отменена")                  // CancellationError — это “нормально”
    } catch {
        print("Ошибка: \(error)")                   // прочие ошибки — это “сломалось”
    }
}

С точки зрения модели structured concurrency, CancellationError — это штатный способ сообщить «задачу попросили остановиться». Это особенно связано с тем, что Task.sleep бросает CancellationError при отмене, поэтому вы будете видеть его чаще, чем ожидаете, если у вас есть таймауты или явная отмена.

6. Чего не делать внутри TaskGroup

Есть набор действий, которые почти гарантированно приводят к боли, если делать их в дочерних задачах. И боль тут не философская, а очень практическая: «почему JSON испортился?» или «почему индекс не соответствует данным?».

Самый типичный анти‑паттерн выглядит так: «получили данные → сразу записали в репозиторий прямо в задаче». Даже если это «иногда работает», это делает поведение непредсказуемым при ошибках и отменах. А ещё такой код часто становится трудно тестировать: вы проверяете не результат функции, а промежуточные эффекты.

Чтобы было наглядно, вот пример, который выглядит соблазнительно, но концептуально неверен (и в строгих режимах компилятора Swift 6 такие штуки ещё и могут не пройти проверки конкурентного доступа):

import Foundation

func wrongIdea(ids: [Int], repo: InMemoryLibraryRepository) async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        for id in ids {
            group.addTask {
                let book = try await fetchBookCancellable(id: id)
                // repo.apply([BookUpdate(...)])  // ❌ side effect внутри дочерней задачи
            }
        }
        while let _ = try await group.next() { }
    }
}

Даже если вынести apply в отдельный метод, сама идея «пишем в общую систему из параллельных задач» ломает принцип: сначала собрали, потом одним аккуратным шагом применили.

7. Типичные ошибки

Ошибка №1: ожидать, что group.cancelAll() мгновенно «убивает» задачи.
cancelAll() не делает магический «kill -9». Он помечает задачи отменёнными, а дальше задача должна сама заметить это на await‑точке или через проверку (Task.isCancelled, Task.checkCancellation()). Если внутри операции нет точек приостановки и проверок, она может закончиться как ни в чём не бывало, и это будет не баг Swift, а ваша несбывшаяся мечта о мгновенной телепатии.

Ошибка №2: путать fail-fast и partial success, а потом удивляться, что «то ошибки пропадают, то всё падает».
Если вы используете withThrowingTaskGroup, то вы как бы подписываетесь под режимом «ошибка — это контроль потока». Если же вы хотели собрать максимум результатов, то броски ошибок надо превращать в Result внутри дочерних задач (это делали в прошлой лекции). Миксовать эти подходы в одном и том же сценарии обычно означает, что вы ещё не решили, чего хотите от продукта.

Ошибка №3: забыть try у group.next() в throwing‑группе.
В withThrowingTaskGroup чтение результата выглядит как try await group.next(), потому что именно в этот момент может проявиться ошибка дочерней задачи. Если вы «по привычке» напишете await group.next(), компилятор, конечно, вас остановит, но проблема глубже: важно понимать не только синтаксис, но и смысл — ошибка всплывает на границе «родитель ждёт результат».

Ошибка №4: делать запись в файл/репозиторий внутри дочерних задач, а потом не понимать, почему база стала «полуживой».
Это классическая причина «полузаписанного» состояния. Как только вы делаете side effect в дочерней задаче, вы теряете контроль над тем, какая часть пачки успела примениться до ошибки или отмены. Лечится дисциплиной: дочерние задачи возвращают данные или «план изменений», а родитель применяет изменения последовательно, одним понятным этапом.

Ошибка №5: считать CancellationError «настоящей ошибкой» и показывать его пользователю как катастрофу.
Отмена часто является нормальным исходом: пользователь передумал, таймаут сработал, вы сами остановили пачку через group.cancelAll(). В таких сценариях лучше отделять CancellationError от остальных ошибок и формулировать сообщение мягче. Тем более, что даже обычный Task.sleep бросает CancellationError при отмене, то есть вы встретите его чаще, чем кажется.

1
Задача
Swift SELF, 70 уровень, 0 лекция
Недоступна
Список имён
Список имён
1
Задача
Swift SELF, 70 уровень, 0 лекция
Недоступна
Первый ответ
Первый ответ
1
Задача
Swift SELF, 70 уровень, 0 лекция
Недоступна
Атомарное обновление
Атомарное обновление
1
Задача
Swift SELF, 70 уровень, 0 лекция
Недоступна
Отмена загрузки
Отмена загрузки
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ