Retain Cycle (циклическая ссылка) — ситуация в ARC, когда два или более объектов ссылаются друг на друга через strong-ссылки, образуя замкнутый цикл. По данным Apple Memory Management Guide, 2026, retain cycle блокирует освобождение всех объектов в цикле, так как каждый имеет retain count ≥ 1. В отличие от утечки памяти в GC, retain cycle гарантированно держит объекты живыми, пока жив хотя бы один внешний участник цикла — и даже после потери всех внешних ссылок, если цикл изолирован.
Главное
Retain Cycle — это ситуация, в которой два или более объекта владеют друг другом через strong-ссылки, создавая замкнутый граф зависимостей. ARC не может освободить ни один из этих объектов, потому что retain count каждого всегда ≥ 1: объект A держит B, B держит A, и их счётчики никогда не обнуляются.
Проблема возникает исключительно в системах с подсчётом ссылок (ARC, MRR). В Garbage Collection сборщик определяет недостижимость по графу ссылок из корневого набора — циклы не являются препятствием. В ARC же цикл эквивалентен утечке, так как детерминированное освобождение по счётчику не может разрешить круговую зависимость.
По данным WWDC 2012 Session 406, retain cycle — самая распространённая причина утечек памяти в Objective-C и Swift приложениях. Типичные сценарии: parent-child отношения с делегатами, замыкания, захватывающие self, и слоистые архитектуры с двусторонними связями.
Рассмотрим классические сценарии retain cycle, с которыми сталкивается каждый iOS-разработчик. Понимание этих паттернов — основа для написания безопасного кода с ARC.
Классический сценарий: родительский объект (например, UIViewController) создаёт дочерний и становится его делегатом. Если оба используют strong-ссылки, возникает retain cycle. Решение — делегат должен быть weak.
// ОШИБКА: retain cycle через strong delegate
protocol ChildDelegate: AnyObject { }
class ParentVC: UIViewController, ChildDelegate {
var child: ChildVC?
func showChild() {
child = ChildVC()
child?.delegate = self // Parent → Child (strong)
} // Child → Parent (strong через delegate)
} // ⚠️ Retain cycle!
class ChildVC: UIViewController {
var delegate: ChildDelegate? // ❌ strong по умолчанию
}
// ИСПРАВЛЕНИЕ: weak delegate
class ChildVC: UIViewController {
weak var delegate: ChildDelegate? // ✅ weak — не удерживает
}
В примере ParentVC держит strong-ссылку на ChildVC через свойство child. ChildVC держит strong-ссылку на ParentVC через delegate. Цикл замкнут. Исправление: weak var delegate — ссылка не увеличивает retain count, и ParentVC может освободиться.
NSTimer — классический источник retain cycle. Таймер удерживает target (обычно self), а target удерживает таймер через свойство. Даже если таймер одноразовый, он не освободится до invalidate. Решение: всегда вызывать timer.invalidate() в deinit или viewDidDisappear.
В архитектурах с каскадным владением (координаторы, роутеры) часто возникают многошаговые циклы: Coordinator → ViewController → ViewModel → Coordinator (через callback). Каждая strong-ссылка в цепочке должна быть осознанно выбрана — одна weak-ссылка в любом звене разрывает цикл.
Замыкания (closures) в Swift захватывают внешние переменные по strong-ссылке. Если замыкание хранится как свойство объекта (например, completion handler) и захватывает self, образуется retain cycle: self → closure → self.
Это самый частый источник retain cycle в современной Swift-разработке. Он возникает неявно — разработчик может не заметить захват self в замыкании, особенно при использовании сокращённого синтаксиса без явного self.
class DownloadService {
var onComplete: ((Data) -> Void)?
var result: Data?
func startDownload() {
// ❌ Retain cycle: self → onComplete → self
onComplete = { data in
self.result = data
self.notifyUI()
}
// ✅ Исправление: capture list с weak self
onComplete = { [weak self] data in
guard let self else { return }
self.result = data
self.notifyUI()
}
}
func notifyUI() { }
}
Capture list [weak self] создаёт слабую ссылку на self внутри замыкания. Если DownloadService освобождается до выполнения замыкания, self становится nil, и код безопасно выходит через guard. Это стандартный паттерн для асинхронных замыканий в Swift — его следует применять всегда, когда замыкание хранится как свойство.
unowned self — альтернатива weak self, когда self гарантированно живёт дольше замыкания. Пример: синхронное замыкание, выполняющееся немедленно (sorted, filter). В таких случаях self точно жив, и unowned безопасен. Однако unowned crashит при обращении к освобождённому объекту — поэтому weak считается безопасным выбором по умолчанию.
Обнаружение retain cycle на ранней стадии — критически важно для производительности приложения. Рассмотрим основные инструменты и методики выявления циклических ссылок в iOS разработке.
Xcode Memory Debugger (Debug Memory Graph) — визуальный инструмент, показывающий граф объектов в памяти с их ссылками. Retain cycle отображается как замкнутая цепь strong-стрелок. Для запуска: нажмите кнопку Debug Memory Graph в панели Debug area во время выполнения приложения. Каждый объект показан с типом, адресом и списком ссылок.
Instruments Leaks — профайлер для автоматического обнаружения утечек. Записывает аллокации и анализирует граф ссылок в реальном времени. Обнаруживает не только retain cycle, но и забытые ссылки, неосвобождённые ViewController и другие утечки. Leaks указывает точный объект и цепочку удержания.
Простейший способ — добавить print в deinit каждого ключевого класса. Если deinit не вызывается при ожидаемом уничтожении объекта — есть retain cycle. Этот метод не требует инструментов и эффективен для начальной диагностики.
| Инструмент | Тип | Когда применять |
|---|---|---|
| Memory Debugger | Визуальный граф | Ручная проверка после навигации |
| Instruments Leaks | Автоматический анализ | Регрессионное тестирование, CI |
| deinit print | Ручное логирование | Разработка, code review |
| Malloc Scribble | Runtime-флаг | Отладка use-after-free |
Рекомендуемый подход: использовать deinit-логирование на этапе разработки, Memory Debugger — при ручном тестировании, Instruments Leaks — в CI/CD пайплайне для автоматического регрессионного контроля утечек.
Предотвратить retain cycle проще, чем исправлять его в продакшне. Несколько правил, которые снижают риск циклических ссылок до минимума.
Все делегаты и dataSource должны быть weak. Это правило встроено в UIKit: все протоколы делегатов в Apple SDK объявлены с weak-свойствами (UITableView.delegate, UICollectionView.dataSource). Для своих протоколов используйте weak var delegate: MyDelegate? и наследуйте протокол от AnyObject.
Любое замыкание, которое хранится как свойство (completion handler, callback) и захватывает self, должно использовать [weak self] в capture list. Исключение — замыкания, выполняющиеся немедленно и не сохраняемые (sorted, map, filter). Для них unowned self безопасен.
В сложных архитектурах (VIPER, Coordinators, Redux) отслеживайте направление strong-ссылок. Владелец держит strong-ссылку на подчинённого, но подчинённый должен ссылаться на владельца только через weak или unowned. Однонаправленный поток данных (unidirectional data flow) упрощает контроль ссылок.
// Пример: проверка с логгированием deinit
class BaseViewController: UIViewController {
deinit {
print("✅ \(type(of: self)) deallocated")
}
}
// Использование: все ViewController наследуют BaseViewController
class ProfileVC: BaseViewController {
var viewModel: ProfileViewModel?
var onLogout: (() -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
onLogout = { [weak self] in
self?.dismiss(animated: true)
}
}
}
// При закрытии ProfileVC ожидаем "✅ ProfileVC deallocated" в консоли
Базовый класс с логированием deinit даёт мгновенную обратную связь. Если сообщение не появилось при ожидаемом закрытии экрана — в этом классе есть retain cycle. Добавьте эту практику в шаблон проекта для всех ViewController.
Часто задаваемые вопросы
Retain cycle — специфичная проблема ARC, где замкнутый круг strong-ссылок блокирует освобождение. В GC сборщик анализирует достижимость из корня (root set), а не счётчик ссылок — поэтому циклы не являются утечкой. В ARC же любой изолированный цикл — гарантированная утечка.
Weak ссылка не увеличивает retain count объекта. Если заменить одну из strong-ссылок в цикле на weak, retain count каждого объекта сможет обнулиться. После освобождения объекта weak-ссылка автоматически устанавливается в nil, предотвращая обращение к мёртвой памяти.
Да, retain cycle может включать любое количество объектов: A → B → C → A. Для освобождения достаточно разорвать одно звено в цикле — заменить любую strong-ссылку на weak или unowned. Инструменты показывают весь граф, а не только пары объектов.
GCD (Grand Central Dispatch) не хранит замыкание после выполнения. DispatchWorkItem выполняется и освобождается, даже если замыкание захватывает self. Retain cycle возникает только когда замыкание хранится как свойство (completion handler в классе), а не когда передаётся в очередь.
Instruments Leaks не всегда находит временные retain cycle (существующие секунды) и циклические ссылки в C/C++ объектах через bridge. Для полной проверки используйте Memory Debugger вручную + deinit-логирование всех ключевых объектов в сцене.
Итоги
Мы разработаем мобильное приложение под ключ
IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также