Odpowiedź na pytanie.
Przed Swift 5.5, współbieżność opierała się na Grand Central Dispatch (GCD) i ręcznym zarządzaniu wątkami, co często prowadziło do wyścigów danych i uszkodzenia pamięci z powodu niechronionego dzielonego stanu mutowalnego. Swift wprowadził zorganizowaną współbieżność z Actorami, aby zapewnić gwarancje izolacji, ale kompilator potrzebował mechanizmu, aby zapewnić, że wartości przekazywane między tymi izolowanymi domenami były z natury bezpieczne dla wątków. Doprowadziło to do powstania protokołu Sendable, który oznacza typy jako bezpieczne do udostępnienia na zewnętrznych granicach współbieżności, poprzez egzekwowanie semantyki wartości lub wewnętrznej synchronizacji na poziomie typu.
Gdy Actor otrzymuje wartość z zewnątrz swojej domeny izolacji, ta wartość może potencjalnie być typem referencyjnym współdzielonym z innymi kontekstami wykonawczymi, co pozwala na jednoczesne modyfikacje, które naruszają bezpieczeństwo pamięci. Tradycyjne podejścia polegają na blokadach czasu wykonywania lub mutexach, aby chronić krytyczne sekcje, ale wprowadzają one dodatkowe obciążenie, ryzyko zakleszczeń i są podatne na błędy ludzkie podczas implementacji. Wyzwanie polegało na zaprojektowaniu abstrahowania o zerowym koszcie, które statycznie weryfikuje bezpieczeństwo wątków w czasie kompilacji, jednocześnie utrzymując charakterystyki wydajności i ergonomii Swift.
Kompilator Swift wymusza zgodność z Sendable dla wszystkich typów przekazywanych przez granice Actor, wykorzystując analizę statyczną do weryfikacji bezpieczeństwa bez dodatkowych obciążeń w czasie wykonywania. Typy wartości, takie jak struct i enum, są domyślnie Sendable, ponieważ wykazują semantykę wartości i wykorzystują optymalizacje copy-on-write w celu zapobiegania dzielonemu stanowi mutowalnemu. W przypadku typów referencyjnych (class), kompilator wymaga eksplicytnej zgodności z Sendable, co narzuca, że klasa musi być final i zawierać tylko właściwości Sendable, skutecznie gwarantując niezmienny lub wewnętrznie zsynchronizowany stan, który nie może być zepsuty przez współbieżny dostęp.
// Implicitnie Sendable struct struct UserData: Sendable { let id: UUID let score: Int } // Explicitnie Sendable final class z niezmiennym stanem final class Configuration: Sendable { let apiEndpoint: String let timeout: Duration init(endpoint: String, timeout: Duration) { self.apiEndpoint = endpoint self.timeout = timeout } } actor DataProcessor { func process(_ data: UserData) async { // Bezpieczne: UserData jest Sendable print("Przetwarzanie \(data.id)") } }
Podczas projektowania aplikacji do handlu finansami w czasie rzeczywistym, nasz zespół wdrożył PriceFeedActor, odpowiedzialnego za agregowanie danych rynkowych z wielu połączeń WebSocket, które musiały odbierać przetworzone ładunki JSON od NetworkManager, działającego na wątku w tle. Początkowo użyliśmy klasy typu referencyjnego MarketData, aby uniknąć kopiowania dużych zbiorów danych podczas aktualizacji z wysoką częstotliwością, ale kompilator Swift uniemożliwił nam bezpośrednie przekazywanie tych obiektów do Actor, ponieważ brakowało im zgodności z Sendable i zawierały mutowalne słowniki do keszowania obliczeń. To zmusiło nas do przeprojektowania naszego modelu danych, aby utrzymać gwarancje izolacji Actor bez poświęcania wydajności wymaganej dla decyzji handlowych poniżej milisekundy.
Przekształciliśmy MarketData w struct, zawierający prywatne przechowywanie dla dużych buforów bajtowych i wykorzystaliśmy mechanizmy copy-on-write w Swift za pomocą ManagedBuffer, aby dzielić podstawowe przechowywanie, dopóki nie wystąpiła mutacja. To podejście automatycznie zapewniało domyślną zgodność z Sendable, zapewniając bezpieczeństwo w czasie kompilacji, minimalizując jednocześnie duplikację pamięci podczas operacji intensywnie wykorzystujących odczyt. Jednak złożoność wdrażania manualnej logiki copy-on-write wprowadziła obciążenie konserwacyjne, a my ryzykowaliśmy degradację wydajności, jeśli automatyczne zachowanie kopiowania wywołane by zostało nieoczekiwanie podczas operacji zapisu na ścieżce krytycznej.
Zachowaliśmy typ referencyjny MarketData, ale przekształciliśmy go w final class z wyłącznie stałymi let i głęboko niezmiennymi właściwościami Sendable, co pozwoliło nam dzielić pojedynczą instancję tylko do odczytu między wieloma Actorami bez wyścigów danych. To zachowało wydajność semantyki referencji dla dużych zbiorów danych i całkowicie wyeliminowało obciążenie związane z kopiowaniem, ale wymagało przekształcenia naszej strategii keszowania, aby używać mutowalnego stanu izolowanego w Actorach, zamiast wewnętrznych mutacji klas. Zmiana architektoniczna wymagała znacznej refaktoryzacji naszego poziomu keszowania, aby przenieść mutowalny stan do dedykowanych Actorów, co zwiększyło złożoność kodu, ale zapewniło ścisłe gwarancje izolacji.
Jako tymczasowe rozwiązanie dla klas mostków Objective-C, które nie mogły zostać natychmiast przekształcone, oznaczyliśmy je jako @unchecked Sendable, aby stłumić ostrzeżenia kompilatora, jednocześnie ręcznie weryfikując bezpieczeństwo wątków za pomocą wewnętrznych blokad. To umożliwiło szybkie przejście na nowy model Actor, ale praktycznie wyłączyło statyczne gwarancje Swift i ponownie wprowadziło ryzyko wyścigów danych w czasie wykonywania, jeśli nasza logika synchronizacji zawierała błędy. W związku z tym ograniczyliśmy to podejście jedynie do niekrytycznej infrastruktury logowania, unikając jego użycia dla produkcyjnych danych finansowych, gdzie bezpieczeństwo miało kluczowe znaczenie.
Przyjęliśmy podejście struct dla danych przesyłanych o wysokiej częstotliwości, wykorzystując zoptymalizowane projekty z copy-on-write, jednocześnie rezerwując podejście z niezmiennymi class dla statycznych obiektów konfiguracyjnych, do których dostęp ma wielu Actorów jednocześnie. To podejście hybrydowe wyeliminowało wszystkie awarie wyścigów danych wykryte podczas testów obciążeniowych, redukując nasze zgłoszenia błędów związanych z współbieżnością o 94% w porównaniu do poprzedniej architektury opartej na GCD. Kontrole zgodności Sendable w czasie kompilacji wykryły trzy potencjalne warunki wyścigu podczas rozwoju, które mogłyby spowodować sporadyczne awarie produkcyjne w poprzednim systemie z ręcznym blokowaniem.
Dlaczego typ zgodny z Sendable nadal nie kompiluje się, gdy jest wychwytywany przez zamknięcie przekazywane do asynchronicznego zadania, i w jaki sposób atrybut @Sendable na zamknięciach rozwiązuje tę niejednoznaczność?
Podczas gdy typ może być Sendable, zamknięcia w Swift domyślnie wychwytują zmienne przez referencję, co może pozwalać na późniejsze mutacje wychwyconej zmiennej po tym, jak zamknięcie zostało wysłane do innego Actor. Atrybut zamknięcia @Sendable ogranicza wychwyt do wartości Sendable i egzekwuje, aby zamknięcie samo nie opuszczało niebezpiecznie domku współbieżnego. To zapewnia, że zamknięcie i cały jego wychwycony stan zachowują gwarancje izolacji przez granice Actorów, zapobiegając wprowadzeniu wyścigów danych przez mutowalne listy wychwytywania w operacjach asynchronicznych.
Jak ścisłe sprawdzanie współbieżności w Swift 6 wpływa na niejawnie importowane nagłówki Objective-C, i jakie mechanizmy umożliwiają dalszą interoperacyjność z starszymi frameworkami, które nie mają adnotacji Sendable?
Swift 6 wprowadza ścisłe sprawdzanie współbieżności, które traktuje większość typów Objective-C jako nie-Sendable domyślnie z powodu ich niemożności dostarczenia statycznych gwarancji bezpieczeństwa. Deweloperzy muszą korzystać z instrukcji importu @preconcurrency, aby stopniowo przyjmować kontrole bezpieczeństwa lub ręcznie adnotować nagłówki Objective-C makrami SWIFT_SENDABLE. Te adnotacje pozwalają kompilatorowi odróżnić obiekty starsze bezpieczne dla wątków od tych, które wymagają granic izolacji, umożliwiając interoperacyjność bez kompromisu dla bezpieczeństwa czystego kodu Swift.
Jaka jest fundamentalna różnica między metodami nonisolated w Actorze a typami Sendable, i kiedy wywołanie metody nonisolated na mutowalnej instancji klasy wprowadza niezdefiniowane zachowanie?
Nonisolated metody pozwalają na synchronizowany dostęp do danych Actor z zewnątrz jego kontekstu izolacji, ale wykonują się na wykonawcy wywołującego, a nie na seryjnym wykonawcy Actor. Wymaga to, aby metoda nie uzyskiwała dostępu do mutowalnego stanu Actor, ponieważ wykonanie tego działania naruszyłoby gwarancje izolacji Actor. Gdy odnosi się do mutowalnego typu referencyjnego, który nie jest Sendable, nonisolated metody mogą wprowadzić warunki wyścigu, jeśli uzyskują dostęp do wspólnego stanu mutowalnego bez odpowiedniej synchronizacji, co prowadzi do uszkodzenia pamięci lub niezdefiniowanego zachowania.