Retain Cycle — суть, причины возникновения и устранение в разработке приложений

Автор: IT Sectr Опубликовано: 2026-03-29 Время чтения: 8 мин

Retain Cycle (циклическая ссылка) — ситуация в ARC, когда два или более объектов ссылаются друг на друга через strong-ссылки, образуя замкнутый цикл. По данным Apple Memory Management Guide, 2026, retain cycle блокирует освобождение всех объектов в цикле, так как каждый имеет retain count ≥ 1. В отличие от утечки памяти в GC, retain cycle гарантированно держит объекты живыми, пока жив хотя бы один внешний участник цикла — и даже после потери всех внешних ссылок, если цикл изолирован.

Главное

  • Retain Cycle — замкнутая цепь strong-ссылок, при которой объекты не могут быть освобождены ARC
  • Причина — два (или более) объекта держат strong-ссылку друг на друга, обнуление retain count невозможно
  • Последствия — утечка памяти: объекты остаются в памяти навсегда, растёт потребление RAM
  • Решение — замена одной из strong-ссылок в цикле на weak или unowned
  • Диагностика — Xcode Memory Debugger, Instruments Leaks, Debug Memory Graph

Что такое 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 разработке

Рассмотрим классические сценарии retain cycle, с которыми сталкивается каждый iOS-разработчик. Понимание этих паттернов — основа для написания безопасного кода с ARC.

Parent-Child с делегатом

Классический сценарий: родительский объект (например, UIViewController) создаёт дочерний и становится его делегатом. Если оба используют strong-ссылки, возникает retain cycle. Решение — делегат должен быть weak.

swift
// ОШИБКА: 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

NSTimer — классический источник retain cycle. Таймер удерживает target (обычно self), а target удерживает таймер через свойство. Даже если таймер одноразовый, он не освободится до invalidate. Решение: всегда вызывать timer.invalidate() в deinit или viewDidDisappear.

Слоистые архитектуры

В архитектурах с каскадным владением (координаторы, роутеры) часто возникают многошаговые циклы: Coordinator → ViewController → ViewModel → Coordinator (через callback). Каждая strong-ссылка в цепочке должна быть осознанно выбрана — одна weak-ссылка в любом звене разрывает цикл.

Retain Cycle в замыканиях Swift

Замыкания (closures) в Swift захватывают внешние переменные по strong-ссылке. Если замыкание хранится как свойство объекта (например, completion handler) и захватывает self, образуется retain cycle: self → closure → self.

Это самый частый источник retain cycle в современной Swift-разработке. Он возникает неявно — разработчик может не заметить захват self в замыкании, особенно при использовании сокращённого синтаксиса без явного self.

swift
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 в замыканиях

unowned self — альтернатива weak self, когда self гарантированно живёт дольше замыкания. Пример: синхронное замыкание, выполняющееся немедленно (sorted, filter). В таких случаях self точно жив, и unowned безопасен. Однако unowned crashит при обращении к освобождённому объекту — поэтому weak считается безопасным выбором по умолчанию.

Как обнаружить retain cycle: инструменты диагностики

Обнаружение retain cycle на ранней стадии — критически важно для производительности приложения. Рассмотрим основные инструменты и методики выявления циклических ссылок в iOS разработке.

Xcode Memory Debugger

Xcode Memory Debugger (Debug Memory Graph) — визуальный инструмент, показывающий граф объектов в памяти с их ссылками. Retain cycle отображается как замкнутая цепь strong-стрелок. Для запуска: нажмите кнопку Debug Memory Graph в панели Debug area во время выполнения приложения. Каждый объект показан с типом, адресом и списком ссылок.

Instruments Leaks

Instruments Leaks — профайлер для автоматического обнаружения утечек. Записывает аллокации и анализирует граф ссылок в реальном времени. Обнаруживает не только retain cycle, но и забытые ссылки, неосвобождённые ViewController и другие утечки. Leaks указывает точный объект и цепочку удержания.

Логирование deinit

Простейший способ — добавить print в deinit каждого ключевого класса. Если deinit не вызывается при ожидаемом уничтожении объекта — есть retain cycle. Этот метод не требует инструментов и эффективен для начальной диагностики.

ИнструментТипКогда применять
Memory DebuggerВизуальный графРучная проверка после навигации
Instruments LeaksАвтоматический анализРегрессионное тестирование, CI
deinit printРучное логированиеРазработка, code review
Malloc ScribbleRuntime-флагОтладка use-after-free

Рекомендуемый подход: использовать deinit-логирование на этапе разработки, Memory Debugger — при ручном тестировании, Instruments Leaks — в CI/CD пайплайне для автоматического регрессионного контроля утечек.

Профилактика retain cycle и best practices

Предотвратить retain cycle проще, чем исправлять его в продакшне. Несколько правил, которые снижают риск циклических ссылок до минимума.

Правило weak delegate

Все делегаты и dataSource должны быть weak. Это правило встроено в UIKit: все протоколы делегатов в Apple SDK объявлены с weak-свойствами (UITableView.delegate, UICollectionView.dataSource). Для своих протоколов используйте weak var delegate: MyDelegate? и наследуйте протокол от AnyObject.

Capture list в замыканиях

Любое замыкание, которое хранится как свойство (completion handler, callback) и захватывает self, должно использовать [weak self] в capture list. Исключение — замыкания, выполняющиеся немедленно и не сохраняемые (sorted, map, filter). Для них unowned self безопасен.

Проверка архитектуры

В сложных архитектурах (VIPER, Coordinators, Redux) отслеживайте направление strong-ссылок. Владелец держит strong-ссылку на подчинённого, но подчинённый должен ссылаться на владельца только через weak или unowned. Однонаправленный поток данных (unidirectional data flow) упрощает контроль ссылок.

swift
// Пример: проверка с логгированием 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 отличается от утечки памяти в GC?

Retain cycle — специфичная проблема ARC, где замкнутый круг strong-ссылок блокирует освобождение. В GC сборщик анализирует достижимость из корня (root set), а не счётчик ссылок — поэтому циклы не являются утечкой. В ARC же любой изолированный цикл — гарантированная утечка.

Как weak ссылка разрывает retain cycle?

Weak ссылка не увеличивает retain count объекта. Если заменить одну из strong-ссылок в цикле на weak, retain count каждого объекта сможет обнулиться. После освобождения объекта weak-ссылка автоматически устанавливается в nil, предотвращая обращение к мёртвой памяти.

Может ли retain cycle состоять из трёх и более объектов?

Да, retain cycle может включать любое количество объектов: A → B → C → A. Для освобождения достаточно разорвать одно звено в цикле — заменить любую strong-ссылку на weak или unowned. Инструменты показывают весь граф, а не только пары объектов.

Почему GCD DispatchWorkItem не создаёт retain cycle?

GCD (Grand Central Dispatch) не хранит замыкание после выполнения. DispatchWorkItem выполняется и освобождается, даже если замыкание захватывает self. Retain cycle возникает только когда замыкание хранится как свойство (completion handler в классе), а не когда передаётся в очередь.

Какие типы retain cycle не обнаруживаются Instruments?

Instruments Leaks не всегда находит временные retain cycle (существующие секунды) и циклические ссылки в C/C++ объектах через bridge. Для полной проверки используйте Memory Debugger вручную + deinit-логирование всех ключевых объектов в сцене.

Итоги

  • Retain Cycle — замкнутая цепь strong-ссылок, блокирующая освобождение объектов в ARC
  • Причины — делегаты с strong-ссылкой, замыкания с захватом self, двусторонние parent-child связи
  • Решение — замена одной strong-ссылки на weak или unowned разрывает цикл
  • Замыкания — хранимые completion handler всегда должны использовать [weak self]
  • Делегаты — всегда weak; протокол делегата должен быть унаследован от AnyObject
  • Диагностика — Xcode Memory Debugger, Instruments Leaks, логирование deinit
  • Профилактика — однонаправленный поток данных, weak delegate, capture list, базовый класс с deinit

Мы разработаем мобильное приложение под ключ

IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.

Обсудить проект

Читайте также