Какая особенность подсчёта ссылок мешает ARC автоматически освобождать недостижимый цикл сильных ссылок?

Какая особенность подсчёта ссылок мешает ARC автоматически освобождать недостижимый цикл сильных ссылок?

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

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

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

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

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

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

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

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

final class Node { var next: Node? deinit { print("deinit") } } var first: Node? = Node() var second: Node? = Node() first?.next = second second?.next = first first = nil second = nil

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

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

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

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

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

Выбор weak безопаснее, но требует optional-доступа: после уничтожения объекта ссылка автоматически станет nil. unowned не добавляет дополнительной проверки на каждом обращении и выражает более сильный инвариант, однако ошибка в предположении о времени жизни приводит к аварийному завершению.

Важно, что не каждый граф с несколькими ссылками является циклом. Цикл возникает именно тогда, когда существует замкнутый путь из сильных ссылок, например A → B → A. Одной не владеющей ссылки на таком пути достаточно, чтобы убрать взаимное удержание.

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

В модели экрана контроллер сильно владеет моделью данных, а модель хранит callback для уведомления контроллера. Если callback сильно захватывает контроллер, возникает цикл: контроллер владеет моделью, модель владеет callback, callback владеет контроллером.

Варианты решения:

  • сделать захват контроллера weak — безопасно при независимом завершении экрана, но callback может ничего не сделать, если контроллер уже уничтожен;
  • сделать захват unowned — не требует optional-обработки, но корректен только при гарантии, что callback никогда не будет вызван после уничтожения контроллера;
  • явно очищать callback при завершении работы — сохраняет сильную ссылку, но требует дисциплины и надёжного жизненного цикла.

Для обычного асинхронного callback был бы выбран weak-захват: операция может завершиться после ухода экрана, поэтому callback должен безопасно стать бездействующим. Результат — контроллер и связанная модель освобождаются после исчезновения внешних владельцев, а позднее событие не вызывает обращение к уничтоженному объекту.

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

  1. Достаточно ли сделать слабой только одну ссылку в цикле?

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

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

  1. Обнаружит ли ARC цикл из трёх или большего числа объектов?

Нет. Размер цикла значения не имеет. Если существует замкнутый путь сильных ссылок, например A → B → C → A, каждый объект продолжает иметь сильного владельца, поэтому обычное уменьшение внешних ссылок не приводит к освобождению.

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

  1. Может ли цикл возникнуть без явных ссылок между двумя свойствами объектов?

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

Поэтому при анализе утечки нужно искать не только пары свойств owner и child, но и все долгоживущие замыкания и обратные callbacks. Если замыкание не должно владеть объектом, применяют список захвата с weak или unowned, выбирая вариант по гарантии времени жизни.