Ewolucja Swifta w kierunku jawnej własności pamięci rozpoczęła się wprowadzeniem ARC (Automatyczne Zliczanie Odniesień), które automatycznie zarządza pamięcią, wstawiając operacje zatrzymywania, zwalniania i kopiowania w czasie kompilacji. Chociaż ARC zapewnia bezpieczeństwo pamięci, wprowadza narzut czasowy, który może stać się zbyt duży w dziedzinach krytycznych dla wydajności, takich jak systemy czasu rzeczywistego czy przetwarzanie danych o wysokiej częstotliwości. Aby to rozwiązać, Swift 5.9 wprowadził modyfikatory własności parametrów—specyficznie borrowing, consuming i istniejący inout—które zapewniają jawne umowy dotyczące cykli życia wartości i mutowalności.
Fundamentalny problem wynika z domyślnych semantyk kopiowania Swifta: podczas przekazywania instancji klasy lub typu wartości zawierającego pamięć przydzieloną na stercie (jak Array czy String), kompilator zazwyczaj generuje wywołanie zatrzymania, aby upewnić się, że wywoływany ma silne odniesienie na czas trwania wywołania. W przypadku typów wartości może to wywołać logikę COW (Copy-on-Write), jeśli liczba odniesień jest większa niż jeden. To implicite kopiowanie zapewnia bezpieczeństwo, ale tworzy przewidywalne klify wydajności w ciasnych pętlach lub kontekstach równoległych, gdzie wymagana jest deterministyczna latencja.
Rozwiązanie wykorzystuje semantykę transferu własności: parametr borrowing wskazuje, że wywoływany otrzymuje tymczasowe, niemutowalne odniesienie bez przejmowania własności, co pozwala kompilatorowi całkowicie pominąć pary zatrzymań/zwolnień. Parametr consuming wskazuje, że wywołujący przekazuje własność wywoływanemu, który staje się odpowiedzialny za zniszczenie wartości lub dalszy transfer, ponownie unikając wywołań zatrzymań poprzez traktowanie operacji jako przejście. W przypadku typów wartości consuming pozwala na przejścia bitowe bez kopiowania podstawowych buforów, podczas gdy borrowing zapobiega wyzwalaniu COW, gwarantując dostęp tylko do odczytu.
import Foundation final class AudioBuffer { var data: [Float] init(size: Int) { data = Array(repeating: 0.0, count: size) } } // Domyślnie: Zatrzymanie przy wejściu, zwolnienie przy wyjściu func processDefault(_ buffer: AudioBuffer) -> Float { return buffer.data.reduce(0, +) } // Borrowing: Brak ruchu ARC, niemutowalne odniesienie func processBorrowing(_ buffer: borrowing AudioBuffer) -> Float { return buffer.data.reduce(0, +) } // Consuming: Transfer własności, brak zatrzymania, wywoływany zarządza cyklem życia func processConsuming(_ buffer: consuming AudioBuffer) -> [Float] { return buffer.data // Transfer własności wewnętrznych danych lub samego bufora } // Użycie demonstrujące semantykę przejścia var buffer = AudioBuffer(size: 1024) let sum = processBorrowing(buffer) // Brak zatrzymania processConsuming(buffer) // Przejście, bufor nie jest już ważny tutaj
Nasz zespół opracował silnik syntezy audio w czasie rzeczywistym dla iOS, w którym wywołanie renderowania audio działa na dedykowanym wątku o wysokim priorytecie. System zaczął doświadczać okresowych zaników audio (glitches) podczas złożonych łańcuchów filtrów, co profilowanie ujawniło jako spowodowane ruchem zatrzymań/zwolnień ARC przy przechodzeniu buforów próbek między węzłami przetwarzania. Ten narzut naruszył ścisłe ograniczenie czasu rzeczywistego, że wywołanie musi zakończyć się w ciągu 3 milisekund, aby uniknąć słyszalnych artefaktów.
Pierwszym rozważanym rozwiązaniem było konvertowanie wszystkich buforów audio na UnsafeMutablePointer<Float> w celu ręcznego zarządzania pamięcią. To podejście całkowicie wyeliminowałoby ARC poprzez traktowanie buforów jako surowych wskaźników C. Jednakże, zalety zerowego narzutu zostały przyćmione przez istotne wady: kod stał się niebezpieczny dla pamięci, podatny na błędy po użyciu po zwolnieniu, i trudny do utrzymania w zespole o mieszanych poziomach doświadczenia.
Drugie rozwiązanie obejmowało użycie Unmanaged<T> do ręcznego kontrolowania liczby odniesień, owijając instancje klas i używając takeRetainedValue() oraz passRetained() w specyficznych granicach. Choć to zachowało pewne bezpieczeństwo typów, wady obejmowały ekstremalną rozległość i ryzyko nierównowagi liczby odniesień prowadzące do wycieków lub awarii. Wymagało to także starannej audytu każdej ścieżki kodu, co sprawiało, że baza kodu była delikatna na refaktoryzację.
Trzecie rozwiązanie przyjęło modyfikatory własności Swifta 5.9, refaktoryzując potok audio do użycia borrowing AudioBuffer dla operacji filtrów tylko do odczytu oraz consuming AudioBuffer przy przechodzeniu własności bufora między asynchronicznymi etapami. Zaletami były zerowy koszt abstrakcji z pełnym egzekwowaniem bezpieczeństwa przez kompilator: borrowing wyeliminowało wywołania zatrzymań przy odczytach filtrów, podczas gdy consuming pozwoliło na semantykę przejścia między etapami potoku bez kopiowania dużych danych audio. Jedyną wadą było wymogi aktualizacji do Xcode 15 i przeprojektowanie niektórych interfejsów zorientowanych na protokół, które nie mogły łatwo wyrazić ograniczeń własności.
Wybaliśmy trzecie rozwiązanie, ponieważ zapewniało potrzebne cechy wydajnościowe, nie rezygnując z bezpieczeństwa pamięci ani nie wymagając niebezpiecznych wzorców kodu. Stosując borrowing do gorącej ścieżki wywołania audio, zredukowaliśmy ruch ARC do zera w wątku czasu rzeczywistego, jednocześnie zachowując gwarancje bezpieczeństwa typów Swifta. Wzorzec consuming uprościł naszą implementację bufora ringowego, jawnie przekazując własność od wątku producenta do konsumenta bez kosztownych operacji kopiowania.
Rezultatem było całkowite wyeliminowanie zaników audio, co doprowadziło do zmniejszenia średniego zużycia CPU w wątku audio z 45% do 28% podczas szczytowych obciążeń przetwarzania. Baza kodu pozostała w pełni bezpieczna dla pamięci, a błędy kompilacji wykryły kilka potencjalnych błędów cyklu życia podczas refaktoryzacji, które mogłyby zakończyć się awariami w przypadku podejścia UnsafeMutablePointer. Dodatkowo, jawne adnotacje własności służyły jako dokumentacja dla umowy API, czyniąc kod bardziej łatwym do utrzymania dla przyszłych deweloperów.
Dlaczego zastosowanie borrowing do parametru typu wartości zapobiega wyzwalaniu Copy-on-Write (COW), gdy podstawowa pamięć jest współdzielona, i jak to różni się od inout?
Gdy typ wartości z użyciem COW (takim jak Array czy Dictionary) jest przekazywany przez borrowing, kompilator gwarantuje, że wywoływany nie może mutować wartości przez to powiązanie. Ponieważ mutacja jest niemożliwa, Swift może przekazać wartość przez odniesienie bez sprawdzania liczby odniesień czy kopiowania bufora, nawet jeśli istnieją inne odniesienia. W przeciwieństwie do tego, inout pozwala na mutację, zmuszając kompilator do weryfikacji, że liczba odniesień wynosi jeden przed zapisem; jeśli nie, uruchamia kosztowne kopiowanie, aby zachować semantykę wartości dla innych odniesień.
W jakich konkretnych warunkach kompilator odrzuci przekazanie parametru consuming i jak operator consume to rozwiązuje?
Kompilator odrzuca przekazanie argumentu do parametru consuming, jeśli argument nie jest ostatecznym użyciem tej wartości (tj. istnieją późniejsze dostępności, które naruszyłyby Prawo Ekskluzywności). Dla typów, które nie mogą być kopiowane, jest to twardy błąd, ponieważ nie można zduplikować wartości, aby zaspokoić zarówno konsumpcję, jak i późniejsze użycie. Operator consume wyraźnie zaznacza koniec cyklu życia wartości w danym punkcie, mówiąc kompilatorowi, aby traktował to miejsce jako ostateczne użycie, co pozwala na przeprowadzenie operacji przejścia, unieważniając oryginalne powiązanie dla kolejnego kodu.
Jak modyfikatory własności parametrów interagują z tabelami świadków protokołów przy użyciu funkcji generycznych w porównaniu z typami egzystencjalnymi, i jakie ograniczenie uniemożliwia ich użycie w wymaganiach protokołów?
Modyfikatory własności, takie jak borrowing i consuming, są w pełni wspierane w funkcjach generycznych (np. func process<T: AudioProtocol>(_ buffer: borrowing T)), w których kompilator generuje specjalizowany kod lub używa tabel świadków, które respektują umowę o własności. Jednak wymagania protokołów same (od Swifta 5.10) nie mogą deklarować modyfikatorów własności w swoich metodach; nie można napisać protocol P { func method(_ x: consuming Self) } ponieważ kontenery egzystencjalne (any P) używają dynamicznego wywołania, które obecnie nie ma metadanych do rozróżnienia między semantyką pożyczania a konsumpcją. To zmusza programistów do korzystania z ograniczeń generycznych (<T: P>) zamiast typów egzystencjalnych przy pracy z typami wyłącznie do przejścia lub przy optymalizacji zachowania ARC poprzez własność.