Від збирання сміття до ARC: як розвивалося керування пам’яттю в Objective-C і чому ARC – не збирач сміття

Від збирання сміття до ARC: як розвивалося керування пам’яттю в Objective-C і чому ARC – не збирач сміття

Чи знали ви, що Apple використовувала збирання сміття? Чому від нього відмовилися на користь ARC

Скомпілюйте будь-який файл Swift за допомогою swiftc -emit-ir і знайдіть у виводі такий рядок: "Objective-C Garbage Collection". Він там є. Swift ніколи не підтримував збирання сміття. Цей рядок – залишок минулого, свідчення того, що колись Apple обрала інший шлях.

У цій статті ми розберемо:

  1. Що таке ARC і як він насправді працює всередині
  2. Як компілятор Swift вставляє виклики retain/release (на реальних прикладах)
  3. Чому у виводі компілятора досі можуть траплятися згадки про Objective-C Garbage Collection
  4. Що таке Garbage Collection, як Apple реалізувала цей механізм і чому від нього відмовилася
  5. Зрозуміле порівняння: збирання сміття та ARC
  6. Підсумки

Усі приклади навмисно прості. Де можливо, ми використовуватимемо Swift, а для пояснення історичного контексту звертатимемося до Objective-C.


1. То що таке ARC?

Компілятор вставив у ваш код два виклики функцій. Ви їх не писали. Подивімося, де вони з’явилися.

Їх вставляє Automatic Reference Counting (ARC) – під час компіляції, а не під час виконання. Компілятор аналізує володіння об’єктами, вставляє й оптимізує виклики retain та release. Ці операції виконуються під час роботи програми. ARC – це не збирач сміття, який відстежує досяжність об’єктів.

Як сказано в документації Apple:

«ARC автоматично керує пам’яттю ваших застосунків, відстежуючи кількість посилань на кожен об’єкт».

Простий приклад ARC у Swift

class Person {
    let name: String
    init(name: String) {
        self.name = name
        print("Person \(name) initialized")
    }
    deinit {
        print("Person \(name) deinitialized")
    }
}

func testARC() {
    let person = Person(name: "Alice")
}

testARC()

Вивід:

Person Alice initialized
Person Alice deinitialized

Що відбувається насправді?

Хоча ви не писали retain чи release, компілятор їх додав. Концептуально ARC перетворює ваш код приблизно на таке (спрощений псевдокод):

allocate Person
retain Person
release Person

Головне: операції ARC розміщуються й оптимізуються під час компіляції; лічильники посилань оновлюються під час виконання.


1.1 Взаємні посилання та weak

Розгляньмо класичний приклад взаємних посилань зі слабким посиланням, яке запобігає циклу сильних посилань.

class Owner {
    let name: String
    var pet: Pet?
    init(name: String) { self.name = name }
    deinit { print("Owner deinit") }
}

class Pet {
    let name: String
    weak var owner: Owner?
    init(name: String) { self.name = name }
    deinit { print("Pet deinit") }
}

func testCycle() {
    let owner = Owner(name: "Bob")
    let pet = Pet(name: "Dog")
    owner.pet = pet
    pet.owner = owner
}

testCycle()

Вивід:

Owner deinit
Pet deinit

Тут ARC:

  • Вставляє retain для сильних посилань
  • Не утримує об’єкт лише через те, що його записано в weak
  • Автоматично вставляє release відповідно до правил володіння й часу життя

Звільнення об’єктів пов’язане з викликами release, а не з очікуванням циклу збирання сміття. Це не гарантує деініціалізації на закривній дужці: точний час життя об’єкта може змінюватися в межах дозволених оптимізацій. Дивіться ARC in Swift: Basics and beyond від Apple.


2. Досліджуємо ARC за допомогою swiftc -emit-ir

Скомпілюймо файл Swift і подивімося на його проміжне представлення (IR).

swiftc -emit-ir ARCExample.swift

У згенерованому виводі IR можна знайти такі рядки:

...
!"Objective-C Garbage Collection", ...
...

Важливе уточнення ⚠️

Це не означає, що Swift або ARC використовує Garbage Collection.

Цей рядок існує для сумісності з середовищем виконання Objective-C. Історично середовище виконання Objective-C підтримувало кілька моделей керування пам’яттю:

  • Ручні виклики retain/release (MRC)
  • Збирання сміття (лише macOS, застарілий механізм)
  • ARC

У середовищі виконання досі є прапорці та символи, що описують ці режими.


2.1 Ручний підрахунок посилань (MRC): основа ARC

До появи ARC розробники Objective-C використовували ручний підрахунок посилань (MRC).

Person *p = [[Person alloc] init]; // retain count = +1
[p retain];                        // +1
[p release];                       // -1
[p release];                       // dealloc

За MRC розробники повністю відповідали за баланс викликів retain, release та autorelease.

Чи спирається ARC на ручний підрахунок посилань?

✅ Так – і концептуально, і технічно.

В Objective-C ARC побудовано поверх того самого механізму retain/release середовища виконання, який використовує MRC. Різниця в тому, хто записує ці виклики:

  • MRC: розробник записує їх вручну
  • ARC: компілятор вставляє їх автоматично

ARC не запроваджує нової моделі керування пам’яттю. Він автоматизує наявну.

Тому код Objective-C, згенерований ARC, досі викликає:

  • objc_retain
  • objc_release
  • objc_storeStrong

Базовий механізм підрахунку посилань залишається незмінним.


2.2 Як працює ARC: досліджуємо SIL (Swift Intermediate Language)

Щоб справді зрозуміти ARC, потрібно зазирнути в SIL (Swift Intermediate Language).

📘 Документація SIL

SIL знаходиться між:

Swift source code
   ↓
SIL (after ARC analysis)
   ↓
LLVM IR
   ↓
Machine code

Тестовий приклад для аналізу ARC

class A {
    let id: Int
    init(id: Int) { self.id = id }
    deinit { print("A deinit") }
}

class B {
    var a: A?
    deinit { print("B deinit") }
}

func createObjects() {
    let a = A(id: 1)
    let b = B()
    b.a = a
}

createObjects()

Генеруємо SIL

swiftc -emit-sil ARCExample.swift

Ви побачите приблизно такий вивід SIL:

...

// A.__allocating_init(id:)
// Isolation: unspecified
sil hidden [exact_self_class] @$s10ARCExample1AC2idACSi_tcfC : $@convention(method) (Int, @thick A.Type) -> @owned A {
// %0 "id"                                        // user: %4
// %1 "$metatype"
bb0(%0 : $Int, %1 : $@thick A.Type):
  %2 = alloc_ref $A                               // user: %4
  // function_ref A.init(id:)
  %3 = function_ref @$s10ARCExample1AC2idACSi_tcfc : $@convention(method) (Int, @owned A) -> @owned A // user: %4
  %4 = apply %3(%0, %2) : $@convention(method) (Int, @owned A) -> @owned A // user: %5
  return %4                                       // id: %5
} // end sil function '$s10ARCExample1AC2idACSi_tcfC'

// B.a.getter
// Isolation: unspecified
sil hidden [transparent] @$s10ARCExample1BC1aAA1ACSgvg : $@convention(method) (@guaranteed B) -> @owned Optional<A> {
// %0 "self"                                      // users: %2, %1
bb0(%0 : $B):
  debug_value %0, let, name "self", argno 1       // id: %1
  %2 = ref_element_addr %0, #B.a                  // user: %3
  %3 = begin_access [read] [dynamic] %2           // users: %4, %6
  %4 = load %3                                    // users: %7, %5
  retain_value %4                                 // id: %5
  end_access %3                                   // id: %6
  return %4                                       // id: %7
} // end sil function '$s10ARCExample1BC1aAA1ACSgvg'
...

// createObjects()
// Isolation: unspecified
sil hidden @$s10ARCExample13createObjectsyyF : $@convention(thin) () -> () {
bb0:
  %0 = metatype $@thick A.Type                    // user: %4
  %1 = integer_literal $Builtin.Int64, 1          // user: %2
  %2 = struct $Int (%1)                           // user: %4
  // function_ref A.__allocating_init(id:)
  %3 = function_ref @$s10ARCExample1AC2idACSi_tcfC : $@convention(method) (Int, @thick A.Type) -> @owned A // user: %4
  %4 = apply %3(%2, %0) : $@convention(method) (Int, @thick A.Type) -> @owned A // users: %15, %11, %10, %5
  debug_value %4, let, name "a"                   // id: %5
  %6 = metatype $@thick B.Type                    // user: %8
  // function_ref B.__allocating_init()
  %7 = function_ref @$s10ARCExample1BCACycfC : $@convention(method) (@thick B.Type) -> @owned B // user: %8
  %8 = apply %7(%6) : $@convention(method) (@thick B.Type) -> @owned B // users: %14, %9, %13, %12
  debug_value %8, let, name "b"                   // id: %9
  strong_retain %4                                // id: %10
  %11 = enum $Optional<A>, #Optional.some!enumelt, %4 // user: %13
  %12 = class_method %8, #B.a!setter : (B) -> (A?) -> (), $@convention(method) (@owned Optional<A>, @guaranteed B) -> () // user: %13
  %13 = apply %12(%11, %8) : $@convention(method) (@owned Optional<A>, @guaranteed B) -> ()
  strong_release %8                               // id: %14
  strong_release %4                               // id: %15
  %16 = tuple ()                                  // user: %17
  return %16                                      // id: %17
} // end sil function '$s10ARCExample13createObjectsyyF'

Спрощено для наочності:

%0 = alloc_ref $A
..
> %4 = load %0
> retain_value %4 
> return %4 
..
strong_retain %0
...
strong_release %0

Що тут відбувається?

  1. alloc_ref $A
    • Виділяється пам’ять для екземпляра A
  2. strong_retain
    • ARC вставляє retain, оскільки на a є сильне посилання
  3. strong_release
    • Завершує володіння a; місце цієї операції визначають правила часу життя, а не безумовне правило кінця області видимості

Ці інструкції роблять операції ARC явними в цьому виводі SIL. Подальша оптимізація ще може перемістити або вилучити операції; це не остаточна послідовність для виконуваного файлу. Документація з оптимізації ARC у Swift також описує оптимізацію ARC на рівні LLVM.


Сильні та слабкі посилання в SIL

Якщо змінити B, щоб він використовував слабке посилання:

class B {
    weak var a: A?
}

SIL зміниться:

// B.a.getter
// Isolation: unspecified
sil hidden [transparent] @$s10ARCExample1BC1aAA1ACSgvg : $@convention(method) (@guaranteed B) -> @owned Optional<A> {
// %0 "self"                                      // users: %2, %1
bb0(%0 : $B):
  debug_value %0, let, name "self", argno 1       // id: %1
  %2 = ref_element_addr %0, #B.a                  // user: %3
  %3 = begin_access [read] [dynamic] %2           // users: %5, %4
  %4 = load_weak %3                               // user: %6
  end_access %3                                   // id: %5
  return %4                                       // id: %6
} // end sil function '$s10ARCExample1BC1aAA1ACSgvg'

Спрощено для наочності:

%0 = alloc_ref $A
..
> %4 load_weak %0
> return %4 
// no retain for weak assignment
..

strong_release %0

ARC:

  • Вставляє retain для сильних посилань
  • Не утримує об’єкт лише через те, що його записано в weak
  • Вставляє виклики release відповідно до правил володіння й часу життя

Слабке посилання не володіє об’єктом, але його читання теж потребує роботи. Довідник інструкцій SIL визначає, що load_weak створює сильне посилання в результаті, якщо об’єкт ще існує. Наведений вище getter повертає optional із володінням навіть без окремої інструкції retain.


3. Що таке збирання сміття?

Збирання сміття (Garbage Collection, GC) тут означає збирання через відстеження досяжності: система під час виконання знаходить недосяжні об’єкти. Цикл збирання:

  1. Починається з набору кореневих посилань
  2. Сканує граф об’єктів
  3. Визначає недосяжні об’єкти
  4. Звільняє їх

Apple запровадила GC для Objective-C в OS X 10.5 й оголосила його застарілим в OS X 10.8. Це не означало негайного вилучення.

📘 Офіційна документація GC (архів)

Як працював GC від Apple

Apple реалізувала консервативний збирач сміття з поколіннями об’єктів, який працював у власному потоці. Документація його архітектури прямо вказує, що цикл збирання ніколи не зупиняв усі потоки одночасно; окремі потоки могли ненадовго призупинятися:

  • Без гарантій щодо того, коли звільняться об’єкти
  • Пам’ять поверталася порціями
  • Були можливі паузи в роботі UI

Apple постачала цей збирач для застосунків Mac. В iOS він ніколи не був доступний.

📘 Огляд керування пам’яттю в Cocoa


3.1 Чому Apple відмовилася від збирання сміття

Apple офіційно оголосила GC застарілим в OS X 10.8 і рекомендувала перейти на ARC. Згодом в оголошенні 2015 року вона припинила підтримку GC для нових застосунків та оновлень, які подають до Mac App Store. Це правило розповсюдження, а не дата вилучення з середовища виконання.

Важливі компроміси

  1. Час звільнення пам’яті
    • Збирач визначає, коли звільнити недосяжні об’єкти
  2. Робота під час виконання
    • Відстеження досяжності та збирання потребують роботи; ARC також має витрати на операції з лічильниками посилань
  3. Обмеження взаємодії
    • Наявний код має дотримуватися правил обраної моделі керування пам’яттю
  4. Підрахунок посилань за допомогою компілятора
    • ARC автоматизує retain/release без циклу збирання через відстеження досяжності

4. Збирання сміття та ARC

Тут порівнюється колишній збирач Objective-C від Apple з ARC, а не оцінюються всі реалізації GC.

Характеристика Збирання сміття ARC
Тип Збирач, що відстежує досяжність під час виконання Підрахунок посилань під час виконання, яким керує компілятор
Звільнення пам’яті Під час циклу збирання Залежить від лічильника посилань; точний час життя визначають правила мови
Продуктивність Робота збирача; можливі паузи окремих потоків Витрати на підрахунок посилань і деініціалізацію; без циклу відстеження досяжності
Енергоефективність Залежить від збирача й навантаження Залежить від кількості операцій із посиланнями й навантаження
Підтримка мобільних пристроїв ❌ Збирач Apple ніколи не був доступний в iOS ✅ Використовується у Swift та Objective-C на iOS
Контроль з боку розробника Кореневі посилання та зв’язки strong/weak Зв’язки володіння strong/weak
Взаємодія з C Можуть знадобитися виділення пам’яті й кореневі посилання з урахуванням GC Володіння об’єктами Core Foundation потребує явного керування

5. Погляд з боку Objective-C

Щоб зрозуміти, наскільки великим покращенням став ARC, корисно подивитися, що розробники робили до його появи.

До ARC розробники Objective-C писали:

Person *p = [[Person alloc] init];
[p release];

З GC:

Person *p = [[Person alloc] init];
// no release

З ARC (виглядає так само, як із GC, але за суттю це зовсім інший механізм):

Person *p = [[Person alloc] init];
// compiler inserts retain/release

ARC запропонував автоматичне вставлення retain/release на основі підрахунку посилань. Він прибрав потребу писати виклики вручну, а не їхню вартість під час виконання.


Чому SIL має значення

SIL доводить, що:

  • Операції ARC видно під час компіляції
  • SIL показує операції володіння до етапу LLVM; подальша оптимізація ще може їх змінити
  • ARC не відстежує граф об’єктів під час виконання для пошуку недосяжних об’єктів

Це принципово відрізняється від збирання сміття.


5.1 Поширені міфи про ARC і збирання сміття

За роки навколо ARC і збирання сміття сформувалося кілька стійких міфів. Розберімо найпоширеніші.

Міф 1: «ARC – це просто інша назва збирання сміття»

❌ Неправда.

ARC – не збирач сміття, який відстежує досяжність. Компілятор вставляє й оптимізує операції retain і release під час компіляції, але самі ці операції, облік слабких посилань і деініціалізація відбуваються під час роботи програми. ARC обходиться без циклів збирання через відстеження досяжності, але керування пам’яттю все одно має свою вартість.

Натомість збирання сміття – це система, що працює під час виконання й періодично аналізує досяжність об’єктів.

Міф 2: «ARC автоматично запобігає всім витокам пам’яті»

❌ Неправда.

ARC запобігає багатьом витокам, але не циклам сильних посилань. Розробникам усе одно потрібно уважно проєктувати графи об’єктів і за потреби використовувати weak або unowned.

Міф 3: «Збирання сміття звільняє пам’ять негайно»

❌ Неправда.

Об’єкти під керуванням збирача сміття звільняються коли він запускається, а не в момент, коли вони стають недосяжними. Через це використання пам’яті й продуктивність менш передбачувані.

Міф 4: «Колись Swift спирався на збирання сміття Objective-C»

❌ Неправда.

Swift ніколи не підтримував збирання сміття. Від самого початку Swift проєктували навколо ARC.

6. Висновок

ARC замінив збирач Apple, який відстежував досяжність, підрахунком посилань під керуванням компілятора. Звільнення об’єктів залежить від володіння, а не від циклів збирання, але retain/release, робота зі слабкими посиланнями й деініціалізація все одно мають витрати під час виконання.

Те, що у виводі компілятора досі можна побачити ”Objective-C Garbage Collection”, – лише слід шляху, яким Apple вирішила не йти, а не ознака того, що GC досі використовується.

Але ARC не ідеальний. Компілятор вставляє retain та release. Проблему циклів сильних посилань це не розв’язує. Два об’єкти, які утримують один одного сильними посиланнями, ніколи не звільняться: ARC не може виявити такий цикл під час компіляції.

Це наступна проблема. Річ не в обмеженні компілятора, а у вимозі до проєктування, що потребує явних рішень щодо володіння: weak, unowned і розуміння того, який об’єкт не повинен утримувати інший.


Додаткові матеріали

Востаннє оновлено

На цій сторінці