Odpowiedź na pytanie.
Objective-C opierał się na ręcznych cyklach retencji/zwolnienia oraz bezpośrednich wskaźnikach dla słabych odniesień, co wymagało swappingu w czasie wykonywania lub globalnych tabel haszy, które powodowały znaczne ograniczenia wydajności przy każdym dostępie do obiektu. Kiedy Apple projektował Swift, potrzebowali automatycznego modelu zarządzania pamięcią, który wspierałby zerowe słabe odniesienia — automatycznie stając się nil w momencie zwolnienia odwoływanego obiektu — bez obciążania ogromnej większości obiektów, które nigdy nie mają do czynienia z słabymi odniesieniami. Ta potrzeba doprowadziła do opracowania architektury tabeli pomocniczej, która zewnętrznie przechowuje metadane słabych odniesień tylko wtedy, gdy jest to wymagane.
Głównym problemem było zrównoważenie efektywności pamięci z bezpieczeństwem. Jeśli nagłówek każdego obiektu zawierałby wewnętrzne przechowywanie dla śledzenia słabych odniesień (takich jak lista powiązanych wskaźników lub wewnętrzna liczba słabych odniesień), to stopa pamięci dla każdego wystąpienia klasy znacznie by wzrosła, co obciążałoby wydajnościowy kod wykorzystujący tylko silne odniesienia. Z drugiej strony, przechowywanie słabych odniesień w globalnej tabeli haszy kluczowanej adresem obiektu wprowadzała by wąskie gardła synchronizacji i skomplikowaną logikę odzyskiwania, gdy obiekty byłyby zwalniane. Wyzwanie polegało na stworzeniu mechanizmu, który nie nakładałby kosztów na obiekty bez słabych odniesień, jednocześnie gwarantując wątkowo bezpieczne atomowe zerowanie, kiedy ostatnie silne odniesienie zniknęło.
Swift stosuje system tabel pomocniczych, w którym każdy nagłówek wystąpienia klasy zawiera nullable wskaźnik do osobnej struktury tabeli pomocniczej przydzielonej na stercie. Ta tabela pomocnicza przechowuje liczbę słabych odniesień oraz wskaźnik zwrotny do obiektu; słabe odniesienia wskazują faktycznie na tę tabelę pomocniczą, a nie bezpośrednio na obiekt. Kiedy liczba silnych odniesień osiąga zero, system wykonuje atomową operację zerowania wskaźnika obiektu wewnątrz tabeli pomocniczej, co powoduje, że wszystkie istniejące słabe odniesienia obserwują nil podczas następnego dostępu, podczas gdy pamięć obiektu pozostaje przydzielona, dopóki liczba słabych odniesień również nie osiągnie zera, w momencie, gdy zarówno tabela pomocnicza, jak i pamięć obiektu są odzyskiwane.
Sytuacja z życia
Wyobraź sobie rozwijanie pipeline'u obrazu w wysokiej rozdzielczości dla aplikacji społecznościowej, w której wystąpienia ViewController pobierają i wyświetlają awatary użytkowników. Aby zapobiec zbędnym żądaniom sieciowym, implementujesz singleton ImageCache, który przechowuje odniesienia do pobranych obiektów UIImage, aby wiele kontrolerów widoku wyświetlających ten sam awatar mogło dzielić się pamięcią bufora.
Jednym z rozważanych podejść było przechowywanie silnych odniesień w NSCache z dowolnymi politykami usuwania. To gwarantowało natychmiastowy dostęp i bezpieczeństwo typów, ale powodowało poważne wycieki pamięci, ponieważ pamięć podręczna przechowywała każdą obrazkę na czas nieokreślony, co ostatecznie prowadziło do ostrzeżeń pamięci i zakończenia działania aplikacji podczas długotrwałych sesji przewijania. Zalety obejmowały prostotę i szybki dostęp, ale wady nieograniczonego wzrostu pamięci sprawiły, że była ona nieodpowiednia do produkcji.
Inne rozważane podejście polegało na wdrożeniu ręcznego wzorca obserwatora, w którym kontrolery widoków powiadamiały pamięć podręczną po zwolnieniu, aby usunąć konkretne wpisy za pomocą protokołu delegata. Choć teoretycznie zapobiegało to wyciekom, wprowadzało kruche ciasne sprzężenie między warstwą wizualną a warstwą pamięci podręcznej, wymagało obszernej biblioteki kodów do obsługi warunków wyścigu podczas szybkich przejść nawigacyjnych i narażało na awarie, gdyby wiadomości powiadamiające zostały pominięte lub dostarczone z opóźnieniem.
Wybrane rozwiązanie wykorzystuje natywne słabe odniesienia Swift w implementacji pamięci podręcznej:
class ImageCache { private var cache: [URL: WeakBox<UIImage>] = [:] func image(for url: URL) -> UIImage? { return cache[url]?.value } func setImage(_ image: UIImage, for url: URL) { cache[url] = WeakBox(value: image) } } final class WeakBox<T: AnyObject> { weak var value: T? init(value: T) { self.value = value } }
Deklarując wartości słownika pamięci podręcznej jako słabe za pomocą opakowania WeakBox, ImageCache mogło zweryfikować, czy obrazek nadal istnieje w pamięci przed jego zwróceniem, jednocześnie pozwalając na automatyczne odzyskiwanie, gdy żaden kontroler widoku aktywnie nie wyświetlał tego awatara. To wyeliminowało zarówno wycieki pamięci, jak i ręczne obciążenie dokumentacji, prowadząc do 40% redukcji w szczytowym zużyciu pamięci podczas szybkiego przewijania strumieni i zapobiegając zakończeniu działania przez systemowy nadzorcę pamięci.
Czego często brakuje kandydatom
Dlaczego dostęp do słabego odniesienia może być wolniejszy niż dostęp do silnego odniesienia, a w jakim konkretnym przypadku ta różnica w wydajności staje się mierzalna?
Dostęp do słabego odniesienia wymaga dereferencjonowania wskaźnika tabeli pomocniczej przechowywanego w nagłówku obiektu, a następnie przeprowadzenia atomowego załadunku wskaźnika obiektu z tej tabeli pomocniczej, aby sprawdzić, czy został on zerowany. Chociaż obciążenie jest minimalne (zwykle pojedyncze dodatkowe przekierowanie), staje się ono mierzalne podczas iteracji po dużych zbiorach (tysiące elementów), gdzie każdy element jest dostępny poprzez słabe odniesienia w ciasnych pętlach, podczas gdy silne odniesienia wymagają tylko jednego pościgu wskaźnika bez atomowych gwarancji.
Co odróżnia odniesienie unowned od słabego odniesienia na poziomie implementacji i dlaczego próba dostępu do odniesienia unowned po zwolnieniu obiektu wywołuje awarię w czasie wykonywania zamiast zwracać nil?
W przeciwieństwie do słabych odniesień, które wykorzystują tabele pomocnicze do umożliwienia zerowania, odniesienia unowned (w domyślnym bezpiecznym trybie) również odnoszą się do tabeli pomocniczej, ale zakładają, że obiekt pozostanie przydzielony tak długo, jak długo istnieje odniesienie unowned, co skutkuje awarią, gdy obiekt zostanie zwolniony, ponieważ wpis w tabeli pomocniczej jest oznaczony jako zniszczony, ale nie zerowany. Kandydaci często pomijają, że niebezpieczne odniesienia unowned pomijają tabelę pomocniczą całkowicie, zachowując się jak wiszące wskaźniki C, które korumpują pamięć po uzyskaniu dostępu do niej po zwolnieniu, podczas gdy bezpieczne odniesienia unowned przynajmniej zatrzymują się deterministycznie poprzez bit zwolnienia tabeli pomocniczej.
Dlaczego pamięć instancji obiektu pozostaje przydzielona na stercie, nawet po zakończeniu deinit i usunięciu wszystkich silnych odniesień, i kiedy ta pamięć jest faktycznie zwalniana?
Pamięć utrzymuje się, ponieważ tabela pomocnicza utrzymuje licznik słabych odniesień; nagłówek obiektu i jego powiązane zasoby nie mogą zostać odzyskane, dopóki liczba słabych odniesień nie osiągnie zera, zapewniając, że słabe odniesienia nigdy nie wskazują na zrecyklerowaną pamięć. Dopiero po zniszczeniu ostatniego słabego odniesienia (zmniejszając licznik słabych odniesień do zera) czas działania zwalnia zarówno tabelę pomocniczą, jak i region pamięci obiektu, proces niewidoczny dla programistów, ale kluczowy dla zapobiegania lukom typu use-after-free.