При запуске долгоживущей Task, чьё замыкание захватывает self, когда ARC сможет уничтожить контроллер?

При запуске долгоживущей Task, чьё замыкание захватывает self, когда ARC сможет уничтожить контроллер?

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

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

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

Если задача не завершается, такой цикл удерживает контроллер в памяти. Отмена задачи сама по себе не гарантирует немедленного разрыва цикла: отмена в Swift Concurrency кооперативная, и задача должна обработать состояние отмены и завершиться.

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

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

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

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

Рассмотрим типичный сценарий:

final class Controller { var task: Task<Void, Never>? func start() { task = Task { while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) self.refresh() } } } func refresh() {} deinit { task?.cancel() } }

После запуска start() свойство task сильно удерживает задачу. Задача удерживает своё замыкание, а замыкание сильно захватывает self. Поэтому присваивание внешней ссылки на Controller в nil не приводит к вызову deinit.

Попытка отменить задачу в deinit не помогает: deinit не наступает, пока цикл не разорван. Это делает особенно опасными бесконечные циклы, ожидание внешнего события и операции, которые игнорируют отмену.

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

Захват self в замыкании Task обычно является сильным захватом. Срок жизни контроллера в таком случае связан не с областью видимости метода start, а с временем жизни замыкания задачи.

Чтобы цикл не возникал, можно захватывать self слабо. Однако важно проверять self внутри повторяющейся операции, а не один раз перед бесконечным циклом: превращение weak-ссылки в локальную сильную ссылку вне цикла удержит контроллер до завершения всей задачи.

Надёжная схема обычно включает два условия:

  • задача регулярно проверяет Task.isCancelled;
  • владелец задачи явно отменяет её до потери последней сильной ссылки на себя.

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

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

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

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

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

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

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

  1. Разрывает ли cancel() задачу и освобождает ли захваченный self немедленно?

Нет. cancel() только устанавливает признак отмены и может отменить поддерживаемые операции ожидания. Код задачи обязан проверять этот признак или корректно реагировать на отменяемые операции; пока тело задачи не завершилось, её замыкание может продолжать удерживать self.

  1. Достаточно ли написать [weak self], чтобы исключить удержание контроллера?

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

  1. Что произойдёт, если дескриптор Task вообще не сохранять в контроллере?

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