Функция запускает обычную Task и сразу возвращает управление: кто отвечает за её отмену?

Функция запускает обычную Task и сразу возвращает управление: кто отвечает за её отмену?

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

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

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

Обычно владелец сохраняет Task-дескриптор, вызывает у него cancel() при необходимости и гарантирует, что работа действительно реагирует на отмену.

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

Структурированная конкурентность появилась, чтобы время жизни дочерних задач следовало структуре программы: async let и task group связаны с областью видимости родителя. Это упрощает ожидание завершения, распространение ошибок и контроль ресурсов.

Но некоторые задачи должны жить независимо от текущего вызова: например, наблюдение за событиями, загрузка данных после действия пользователя или мост между callback-API и async/await. Для таких случаев Swift предоставляет обычный Task, однако вместе с гибкостью разработчик получает ответственность за её жизненный цикл.

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

Если функция создаёт задачу и не сохраняет её дескриптор, приложение теряет удобный способ явно отменить работу. Задача может продолжать сетевой запрос, обработку данных или удерживать захваченные объекты, даже когда экран уже закрыт.

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

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

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

Отмена в Swift кооперативная. Вызов cancel() устанавливает состояние отмены, а выполняемый код должен периодически проверять его или вызывать отменяемые операции. Если код игнорирует отмену и выполняет долгий синхронный цикл, задача не остановится немедленно.

Практический шаблон — хранить дескриптор у объекта-владельца и отменять предыдущую операцию перед запуском новой:

func startWork() -> Task<Void, Never> { Task { while !Task.isCancelled { await Task.yield() } } } let task = startWork() task.cancel()

В примере функция возвращает дескриптор, поэтому вызывающий код может управлять задачей. Task.yield() даёт планировщику возможность переключиться, а проверка Task.isCancelled делает цикл восприимчивым к отмене.

Если работа логически принадлежит текущей операции, предпочтительнее async let или task group: они автоматически связывают дочерние задачи с родительским контекстом. Обычная Task уместна, когда независимый жизненный цикл нужен намеренно; тогда этот жизненный цикл следует явно описать и реализовать.

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

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

Второй вариант — хранить все дескрипторы в массиве и ждать их завершения. Он позволяет собрать результаты, но усложняет отмену, увеличивает число одновременно работающих запросов и не решает проблему при частом вводе.

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

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

  1. Достаточно ли просто не хранить дескриптор задачи, чтобы она завершилась?

Нет. Потеря ссылки на дескриптор не означает отмену задачи. Выполнение продолжается согласно планированию Swift Concurrency, пока задача не завершится сама или не будет отменена другим доступным способом.

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

  1. Гарантирует ли вызов cancel() немедленное прекращение обычной Task?

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

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

  1. Когда обычную Task следует заменить структурированной конкурентностью?

Если дочерняя работа должна завершиться до выхода из операции, а её ошибки и отмена должны быть связаны с родителем, лучше использовать async let или task group. В этом случае компилятор и модель структурированной конкурентности помогают не оставить незавершённую работу без владельца.

Обычная Task оправдана для независимой работы, например длительного наблюдения или операции, жизненный цикл которой управляется объектом сервиса. Компромисс заключается в том, что разработчик сам отвечает за сохранение дескриптора, отмену, обработку результата и освобождение связанных ресурсов.