Программирование SwiftКонкурентностьРазработчик приложений на Swift

Остаётся ли TaskGroup пригодной для добавления задач после выхода из области withTaskGroup?

Остаётся ли TaskGroup пригодной для добавления задач после выхода из области withTaskGroup?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Нет. TaskGroup существует только внутри замыкания withTaskGroup или withThrowingTaskGroup; после выхода из этой области добавлять в неё задачи нельзя, а её дочерние задачи не могут продолжать работу независимо от группы.

При завершении области Swift дожидается завершения всех дочерних задач. Если нужен независимый жизненный цикл, следует использовать отдельный Task, понимая, что это уже не структурированная конкурентность.

Исторический контекст

Structured concurrency появилась как способ связать жизненный цикл дочерних задач с областью выполнения родителя. До такого подхода легко было запустить фоновую работу, потерять её дескриптор и получить неявные утечки задач, неуправляемую отмену или ошибки, о которых никто не узнал.

TaskGroup решает эту проблему через лексическую область: родитель создаёт дочерние задачи, собирает их результаты и не выходит из области, пока дочерние задачи не завершены. Это делает зависимости между задачами явными.

Постановка проблемы

Предположим, функция принимает TaskGroup, добавляет в неё задачи и пытается сохранить группу для последующего добавления работы. Такой дизайн противоречит контракту API: группа не является долгоживущим объектом или очередью задач.

Если бы группа могла переживать свою область, было бы неясно, кто отвечает за ожидание, отмену и обработку ошибок дочерних задач. Кроме того, после возврата из withTaskGroup функция уже не могла бы гарантировать, что все запущенные операции завершены.

Подробное решение

withTaskGroup передаёт группе временный доступ внутри своего замыкания. Вызов addTask допустим только пока эта область активна. Возврат группы из замыкания, сохранение её для последующего использования или передача в независимую задачу нарушает её ограниченный жизненный цикл и не поддерживается Swift.

func loadValues() async -> [Int] { await withTaskGroup(of: Int.self, returning: [Int].self) { group in group.addTask { 1 } group.addTask { 2 } var values: [Int] = [] for await value in group { values.append(value) } return values } }

В примере результаты обрабатываются внутри области группы. После выхода из withTaskGroup дочерние задачи завершены, а сама группа больше не используется.

Это отличается от Task, дескриптор которой можно сохранить и передать дальше. Независимая задача может пережить вызывающую функцию, но за её отмену, ожидание и обработку ошибки разработчик отвечает явно. Такой вариант уместен для долгоживущей работы, однако он теряет преимущества структурированной иерархии.

Для фиксированного числа параллельных операций чаще подходит async let. Для динамического числа операций — TaskGroup. Если работа должна существовать независимо от текущей функции, используется неструктурированная задача, а не попытка продлить жизнь группы.

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

Ситуация из практики

Сервис загружает страницы поиска: число страниц становится известно только после первого ответа. Разработчик хочет сохранить TaskGroup в свойстве сервиса и добавлять новые страницы по мере поступления данных.

Вариант с сохранением группы неправильный: группа привязана к текущему вызову и не предназначена для межфункционального обмена. Вариант с async let неудобен, потому что число задач динамическое. Вариант с отдельными Task технически возможен, но требует самостоятельно хранить дескрипторы, отменять их при закрытии экрана и собирать ошибки.

Оптимальное решение — держать весь динамический fan-out внутри одной функции с TaskGroup: добавлять задачи по мере необходимости, обрабатывать результаты через for await, а наружу возвращать уже собранный результат. Если загрузка должна переживать экран и иметь собственный жизненный цикл, лучше создать отдельный объект-координатор, который хранит дескрипторы Task и явно управляет их отменой.

Что кандидаты часто упускают

  1. Вопрос: Дожидается ли withTaskGroup завершения дочерних задач, если родитель досрочно достигает return?

    Ответ: Да. Возврат из замыкания не обрывает дочерние задачи. Перед фактическим выходом из области Swift обеспечивает завершение дочерних задач; родительская функция не может вернуть управление, оставив их бесконтрольно выполняться. Поэтому ранний return не является способом запустить фоновую работу через группу.

  2. Вопрос: Чем сохранение Task отличается от сохранения результата работы TaskGroup?

    Ответ: Task — это дескриптор отдельной неструктурированной задачи, который можно сохранить, ожидать позже или отменить. Результат TaskGroup — обычное значение, собранное до выхода из структурированной области; сама группа наружу не передаётся.

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

  3. Вопрос: Можно ли добавить новую дочернюю задачу в группу из уже запущенной дочерней задачи?

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

    Однако область группы всё равно не завершится, пока такие дочерние задачи не закончатся. Поэтому рекурсивное добавление должно иметь конечное условие; иначе функция может надолго задержать выход из withTaskGroup или создать чрезмерное количество задач.