Memory Leaks vs. ARC. Uważaj na retain cycles.

Nie znam osoby, której by nie ucieszyło wprowadzenie ARCa w 2011 roku przez Apple. Ułatwiło to znacząco tworzenie stabilnych pamięciowo aplikacji na iOS. Niestety nie zwolniło z myślenia i nadal można popełnić błędy, które doprowadzą do wycieków pamięci w aplikacji. Dziwne, prawda? A jednak to prawda. W tym poście pokażę na co trzeba uważać.

Od momentu wprowadzenia ARCa spotkałem wiele osób programujących w Objective-C, które nadal nie wiedzą jaka jest rożnica między ARC (Automatic Reference Counting) a GC (Garbage Collection). Bardzo chciałbym, aby programiści byli bardziej świadomi, ale niestety, w przypadku wielu rozmów o pracę, które prowadziłem, jakieś 50% osób miało problem z poprawną odpowiedzią na to pytanie :(. Różnice między tymi 2 możliwościami są zasadnicze i z różnic tych wynika również to, czemu trzeba sprostać pisząc poprawne aplikacje, które nie mają wycieków pamięci.

Mechanizm Garbage Collection zwalnia nieużywaną i niepotrzebnie zajętą pamięć w czasie działania aplikacji na podstawie analizy dokonywanej w wątku w tle. Mechanizm ten (w teorii przynajmniej, bo praktyka zależy od konkretnej implementacji) potrafi rozpoznać cykle i zwolnić pamięć, jeśli dana grupa obiektów nie jest już używana, ale posiada referencje do siebie, np. Obiekt A ma referencję do obiektu B, obiekt B ma referencję do obiektu A, ale żaden z nich nie jest już osiągalny z aplikacji.

ARC jest mechanizmem dostarczanym przez kompilator, co oznacza, że odpowiednie instrukcje rezerwujące i zwalniające pamięć są wkompilowane w kod maszynowy. Dlatego też już na etapie pisania programów, programista musi być tego świadom i musi umiejętnie rozpoznawać przypadek przedstawiony powyżej, tak by nie dopuścić do tzw. retain cycles.

Ktoś powie, że przecież takie przypadki się bardzo rzadko zdarząją… Ja powiem, że wcale nie tak rzadko. Wystarczy nieco nieuwagi i rozproszenia i nawet proste rzeczy można zepsuć.

Przykład: implementacja węzła struktury danych typu drzewo.

@interface Node {
}
@property (nonatomic, retain) Node *parent;
@property (nonatomic, retain) NSMutableArray *children;
 
@end
 
@implementation Node
@synthesize parent, children;
 
... // pomijam kod inicjalizatora, który zaciemniłby jedynie obraz całości
- (void)addChild:(Node *)newChild {
    newChild.parent = self;
    [self.children addObject:newChild];
}
 
@end;

Czy aby na pewno wszystko jest w porządku z powyższym kodem? Sprawdź kod i potem popatrz na poniższy przykład.

@interface Node {
}
@property (nonatomic, assign) Node *parent;
@property (nonatomic, retain) NSMutableArray *children;
 
@end
 
@implementation Node
@synthesize parent, children;
 
... // pomijam kod inicjalizatora, który zaciemniłby jedynie obraz całości
- (void)addChild:(Node *)newChild {
    newChild.parent = self;
    [self.children addObject:newChild];
}
 
@end;

Jaka jest różnica? Ważna 🙂 Klasa Node (węzeł) trzyma wskaźnik do swojego rodzica, ale też wskaźnik do tablicy ze swoimi dziećmi. Jak wiadomo, kolekcje w Objective-C retainują obiekty, które się do nich dodaje.

Mając w pamięci powyższe 2 opcje, popatrzmy na wykorzystanie klasy Node na tym fragmencie kodu:

...
Node* a = [[Node alloc] init]; // retain count = 1
Node* b = [[Node alloc] init]; // retain count = 1
 
[a addChild:b]; // retain count dla a = ?, retain count dla b = ?
 
b = nil; // retain count = ?
a = nil; // retain count = ?
...

Mamy 2 węzły a i b. Ustawiamy dla nich relacje rodzic-dziecko, a następnie przypisujemy nil w miejsce naszych zmiennych.

Dla przypadku:

@property (nonatomic, retain) Node *parent;

w momencie przypisania dziecka do rodzica

[a addChild:b];

zwiększa się retainCount zmiennej a (b staje się właścicielem a), oraz zwiększa się retainCount zmiennej b: tablica a.children staje się właścicielem obiektu b. Czyli możemy już dopisać ile wynosi retain count dla zmiennych:

[a addChild:b]; // retain count dla a = 2, retain count dla b = 2

Zatem po wykonaniu się instrukcji:

b = nil; // retain count = 1
a = nil; // retain count = 1

Zostajemy z pustymi wskaźnikami, wyciekiem pamięci i pięknym cyklem, który ten wyciek spowodował.

Retain Cycle - @property (retain) *parent

Natomiast dla przypadku :

@property (nonatomic, assign) Node *parent;

odpowiednio liczniki referencji będą miały następujące wartości:

[a addChild:b]; // retain count dla a = 1, retain count dla b = 2

Po wykonaniu się instrukcji

b = nil; // retain count = 1
a = nil; // retain count = 0

W momencie powrotu licznika referencji dla zmiennej a do wartości 0, zostaną wykonane instrukcje, które spowodują usunięcie wskazywanego obiektu z pamięci, zatem tablica children również zostanie usunięta. Przy dealokacji tablicy, zostanie również zmniejszony do 0 retain count zmiennej b. Cykli brak, wycieków pamięci brak. Wszystko jest w porządku.

Retain Cycle - @property (assign) *parent

Jak widać prosta zmiana retain na assign w definicji obiekty ma kolosalne znaczenie. Zwracaj uwagę na swój kod, na definicję property, tak aby nie spowodować powstawania cykli. ARC nie radzi sobie z cyklami, bo nie działa w runtimie jako oddzielny proces, tylko odpowiednie instrukcje są dodawane w czasie kompilacji. Tylko GC może być na tyle inteligentny, aby takie cykle znaleźć.

A teraz przeanalizujmy przykład z klasą i blokiem. Jest to przykład, który wbrew pozorom nie jest niczym czego nie można by spotkać w większości projektów. Mamy więc klasę, która jest właścicielem bloku kodu, oraz blok kodu, w którym odwołujemy się do naszej klasy. Brzmi znajomo? 🙂 Zobaczmy nieco kodu

typedef void (^OwnedBlock)(void); // definicja typu naszego testowego bloku kodu
 
@interface BlockOwner {
}
 
- (void)method; // metoda z blokiem kodu
- (void)method2; // inna metoda wołana w bloku
@end
 
@implementation BlockOwner
 
- (void)method {
  OwnedBlock = ^{
    // OwnedBlock staje się właścicielem obiektu BlockOwner, który jest również właścicielem bloku
    // mamy retain cycle
    [self method2];
  }
}
 
- (void)method2 {
  NSLog(@"Method 2");
}
 
@end

W powyższym kodzie, blok należy do obiektu klasy BlockOwner i jednocześnie ten obiekt należy do bloku. Bloki kodu zwiększają retain count obiektów do których się odwołują.

Retain Cycle: obiekt i blok

Jak to zmienić? Jak to usprawnić? Rozwiązanie jest proste. Musimy sprawić, aby nie wskaźnik self zamienić na coś, czemu retain count nie zostanie zwiększony.

Retain Cycle: obiekt i blok - poprawna struktura

Czyli metoda -(void)method; powinna wyglądać tak:

- (void)method {
 __block BlockOwner* this = self; // ustawiamy wskaźnik na self, w sposób który nie spowoduje zwiększenie retain counta
 OwnedBlock = ^{
   [this method2];
 }
}

Typ __block sprawia, że wskaźnik this, nie będzie otrzymywał od kompilatora instrukcji zwiększenia swojego retain count.

Powyższe 2 przykłady mają na celu pokazanie, jak bardzo trzeba uważać pisząc aplikacje. Trzeba być świadomym tego, co się robi. Bezmyślność może prowadzić do poważnych wad aplikacji, losowych i trudnych do wykrycia crashy i odwrotu użytkowników od Twojego produktu.

Pamiętaj! Uważaj na retain cycles w swojej aplikacji. Używanie ARCa nie zwalnia z myślenia i dobrego projektowania kodu. No i przede wszystkim używaj Profilera i sprawdzaj od czasu do czasu swoją aplikację, czy jednak nie masz wycieków pamięci.

A jeśli jeszcze Ci mało, to polecam lekturę tego wątku.