Swift 6.2 Concurrency in Practice: Default to MainActor, Escape on Purpose
Swift 6.0 zamienił wyścigi danych (data races) w błąd kompilacji i przez rok kazał za to wszystkim płacić. Kontroler ścisłej współbieżności zamieniał zwykły kod interfejsu użytkownika w ścianę naruszeń Sendable i błędów „main actor-isolated property can not be referenced from a nonisolated context”. Diagnoza była trafna (ten kod naprawdę mógł powodować wyścig), ale ich liczba zagrzebała sygnał i wiele zespołów albo zostało w trybie Swift 5, albo posypywało kod adnotacjami @MainActor, aż błędy cichły.
Swift 6.2 zmienia domyślne ustawienie, a nie reguły. Gwarancja bezpieczeństwa wobec wyścigów danych jest taka sama; zmieniło się to, od czego zaczyna kompilator. Przyjmij nowe ustawienia domyślne, a większość tej ściany znika, ponieważ kompilator zakłada teraz to, co i tak było prawdą w Twojej aplikacji: większość kodu działa na głównym aktorze i zostawiasz go tam celowo, w nazwanych miejscach. To jest model, który wysyłam w aplikacjach z rodziny 9411. Oto jak to działa i sześć błędów, które wciąż gryzą, gdy już to włączysz.
TL;DR
- SE-0466 pozwala domyślnie ustawić cały moduł na
@MainActor. UstawSWIFT_DEFAULT_ACTOR_ISOLATION = MainActori przestań adnotować każdy widok, model i view-model2. - SE-0461 sprawia, że nieizolowane funkcje
asyncdziałają na aktorze wywołującego domyślnie (nonisolated(nonsending)), więc wywołanie kodu asynchronicznego nie wymusza już przeskoku aktora ani towarzyszących mu błędówSendablena granicy3. @concurrentto wyjście awaryjne. Oznacz funkcję jako@concurrent, aby zepchnąć ciężką pracę procesora (dekodowanie, przetwarzanie obrazów) na wątek w tle — świadomie i widocznie4.- Xcode 26 włącza oba ustawienia domyślne dla nowych projektów. Przełącznik zbiorczy to
SWIFT_APPROACHABLE_CONCURRENCY = YES5. - Ten model odwraca ciężar dowodu: zamiast udowadniać, że każdy wiersz można bezpiecznie uruchomić poza głównym wątkiem, trzymasz wszystko na głównym i udowadniasz te nieliczne miejsca, które opuszczasz.
- Sześć konkretnych błędów przetrwa zmianę. Wszystkie sześć mają jednolinijkowe poprawki, gdy tylko dostrzeżesz wzorzec.
Odwrócenie i dlaczego ma znaczenie
Stary model (Swift 6.0): kod jest nieizolowany, dopóki go nie zizolujesz. Każdy typ, który dotykał stanu interfejsu użytkownika, potrzebował @MainActor, każde wywołanie asynchroniczne przekraczające granicę izolacji potrzebowało zgodności z Sendable, a kompilator zgłaszał każdą lukę. W aplikacji, w której 95 procent kodu i tak działa na głównym wątku, spędzałeś czas na adnotowaniu tych 95 procent, by opisać fakt, który nigdy nie budził wątpliwości.
Nowy model (Swift 6.2): kod jest na głównym aktorze, dopóki go nie opuścisz. SE-0466 pozwala zadeklarować izolację głównego aktora jako domyślną dla modułu, więc widok, jego model i jego funkcje pomocnicze są wszystkie @MainActor bez ani jednej adnotacji2. SE-0461 usuwa następnie drugi podatek: nieizolowana funkcja async działa teraz na tym aktorze, który ją wywołał, zamiast przeskakiwać do globalnego egzekutora, więc oczekiwanie na nią nie przeciąga Cię przez granicę izolacji ani nie wymaga Sendable od wszystkiego w zasięgu3.
Model myślowy pasuje do tego, jak aplikacje interfejsu użytkownika faktycznie się zachowują. Główny wątek jako domyślny to nie kompromis; to prawda o aplikacji, której stanem są jej widoki. Współbieżność staje się wyjątkiem, po który sięgasz — nazwanym i zawartym — a nie warunkiem otoczenia, przed którym bronisz się w każdym wierszu. Zadanie kompilatora odwraca się z „udowodnij, że to można bezpiecznie uruchomić współbieżnie” na „powiedziałeś, że to działa współbieżnie, więc udowodnij, że jest bezpieczne”, a drugie pytanie zadawane jest w znacznie mniejszej liczbie miejsc.
Włączanie tego
Dwa ustawienia kompilacji, oba w domyślnych ustawieniach Xcode 26 dla nowych projektów i oba warte jawnego ustawienia w istniejącym projekcie5:
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
SWIFT_APPROACHABLE_CONCURRENCY = YES
Pierwsze to SE-0466: moduł domyślnie ustawia izolację głównego aktora. Drugie to przełącznik zbiorczy, który włącza zestaw funkcji przystępnej współbieżności (approachable-concurrency), w tym zachowanie SE-0461, w którym uruchamia wywołujący. W pakiecie Swift te same ustawienia domyślne ustawiasz przez swiftSettings z odpowiednimi flagami nadchodzących funkcji, a nie przez ustawienie kompilacji Xcode6.
Przełącz oba w istniejącym projekcie, a liczba błędów spada gwałtownie, ponieważ większość tego, co kontroler zgłaszał, to był kod głównego wątku, którego wcześniej nie potrafił uznać za kod głównego wątku. To, co zostaje, to krótka lista prawdziwych przypadków granicznych. Warto znać je z imienia, bo każdy z nich to miejsce, w którym Twój kod naprawdę opuszcza głównego aktora, a poprawka polega na powiedzeniu tego precyzyjnie.
Sześć błędów, które przetrwają zmianę
Oto błędy ścisłej współbieżności, które wciąż pojawiają się przy SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, zebrane z migracji aplikacji z rodziny 941 do tego modelu1. Każdy to prawdziwa granica, a nie fałszywy alarm — dlatego poprawką jest precyzyjna adnotacja, a nie wyciszenie.
1. Czyste funkcje na typach Sendable. Czysta metoda na wyliczeniu Sendable (konstruktor URL, formater) dziedziczy izolację głównego aktora przy domyślnym ustawieniu modułu, a następnie zgłasza błąd, gdy jest wywoływana z nieizolowanego kontekstu: „Call to main actor-isolated instance method in a synchronous nonisolated context.” Metoda nie dotyka żadnego stanu, więc izolowanie jej do głównego aktora jest błędne. Oznacz ją jako nonisolated7:
nonisolated func searchURL(for query: String) -> URL? { ... }
2. Statyczne singletony w parametrach domyślnych. static let shared = Foo() jest izolowane do głównego aktora przy domyślnym ustawieniu, ale wartości parametrów domyślnych są ewaluowane w kontekście wywołującego, który często jest nieizolowany: „Main actor-isolated static property ‘shared’ can not be referenced from a nonisolated context.” Uczyń składową statyczną nonisolated. Jeśli typ jest Sendable (lub @unchecked Sendable, ponieważ sam strzeżesz jego stanu), nie potrzebujesz żadnego niebezpiecznego kwalifikatora:
final class KeychainProxySecretStore: @unchecked Sendable {
nonisolated static let shared = KeychainProxySecretStore()
}
3. Niezmienne stałe podstawowe. Ten sam problem parametru domyślnego dotyka zwykłą stałą: static let defaultInterval przywoływaną z nieizolowanego argumentu domyślnego. Poprawka jest identyczna, a stała jest trywialnie bezpieczna do współdzielenia:
nonisolated static let defaultInterval: TimeInterval = 15 * 60
4. Ciało Task odczytujące przechwycone self. Zewnętrzne domknięcie przechwytuje [weak self]; wewnątrz Task { @MainActor in self?.foo() } odczytuje ten przechwycony opcjonał: „Reference to captured var ‘self’ in concurrently-executing code.” Task odczytuje powiązanie var z otaczającego zasięgu współbieżnie, a to jest wyścig. Przechwyć self ponownie na granicy Task, tak aby Task posiadał niezmienne powiązanie:
NotificationCenter.default.addObserver(...) { [weak self] _ in
Task { @MainActor [weak self] in
self?.value = next
}
}
5. Wywołania zwrotne KVO odczytujące stan głównego aktora. Wywołanie zwrotne webView.observe(\.canGoBack) { wv, _ in ... } jest @Sendable, a zatem nieizolowane, ale WKWebView.canGoBack jest izolowane do głównego aktora: „Main actor-isolated property ‘canGoBack’ can not be referenced from a Sendable closure.” KVO dostarcza synchronicznie na wątku, który zmodyfikował wartość, a stan nawigacji WKWebView zmienia się wyłącznie na głównym wątku, więc odczyt jest poprawny. Potwierdź to przez MainActor.assumeIsolated, co całkowicie usuwa przeskok Task i pozostaje synchroniczne8:
let pushNav: @Sendable (WKWebView?) -> Void = { [weak self] webView in
MainActor.assumeIsolated {
guard let self else { return }
// safe to read webView?.canGoBack synchronously
}
}
assumeIsolated to obietnica dla kompilatora, a nie pytanie. Używaj go tylko tam, gdzie niezmiennik środowiska wykonawczego faktycznie zachodzi (udokumentowane wywołanie zwrotne na głównym wątku), ponieważ błędna obietnica to awaria, a nie ostrzeżenie.
6. Ciężka praca, która nie powinna być na głównym wątku. Tego kontroler nie zgłosi, a jest to najważniejsze, by wyłapać samemu. Przy domyślnym ustawieniu głównego aktora synchroniczna metoda obciążająca procesor (dekodowanie dużego ładunku JSON, zmiana rozmiaru obrazu) działa na głównym aktorze i powoduje zacinanie interfejsu. Domyślne ustawienie trzyma Cię na głównym wątku; @concurrent to sposób, by go opuścić celowo4:
@concurrent
func decodeLargePayload(_ data: Data) async throws -> Report {
try JSONDecoder().decode(Report.self, from: data)
}
@concurrent przenosi funkcję do globalnego egzekutora i z założenia wyklucza się wzajemnie z @MainActor, niestandardowym aktorem globalnym oraz nonisolated(nonsending): funkcja albo działa tam, gdzie jest jej wywołujący, albo celowo od niego ucieka, nigdy niejednoznacznie4. Cała dyscyplina, której wymaga nowy model, mieści się w tym wzorcu. Zostań na głównym wątku dla wszystkiego, co dotyka interfejsu użytkownika, i sięgaj po @concurrent tylko dla tej pracy, która wymiernie potrzebuje wątku w tle.
Kiedy ustawienia domyślne są dla Ciebie złe
Główny aktor jako domyślny pasuje do aplikacji: kod SwiftUI i UIKit jest w przeważającej mierze kodem głównego wątku, a ustawienie domyślne odpowiada rzeczywistości. Pasuje gorzej w dwóch przypadkach, a udawanie inaczej marnuje Twój czas.
- Cel będący biblioteką lub frameworkiem bez interfejsu użytkownika. Warstwa sieciowa, parser czy silnik danych nie mają powodu domyślnie ustawiać głównego aktora, a zrobienie tego wymusza
@concurrentlubnonisolatedna niemal wszystkim. PozostawSWIFT_DEFAULT_ACTOR_ISOLATIONnieustawione dla tych celów i izoluj świadomie, po staremu. - System współbieżny mocno oparty na aktorach. Jeśli Twój projekt naprawdę uruchamia wiele rzeczy równolegle (prawdziwy potok, a nie aplikacja z kilkoma zadaniami w tle), domyślne ustawienie głównego aktora będzie z Tobą walczyć. Chcesz jawnych aktorów i nieizolowanego kodu, a domyślne ustawienie SE-0466 jest złym punktem wyjścia.
Dla aplikacji jednak decyzja jest prosta: włącz oba ustawienia, pozwól liczbie błędów zapaść się i potraktuj tę garstkę, która zostanie, jako mapę dokładnie tych miejsc, w których Twój kod opuszcza główny wątek. Tę mapę warto mieć. Stary model dawał tysiąc ostrzeżeń i żadnej mapy; nowy daje sześć uczciwych granic i ustawienie domyślne, które wreszcie odpowiada temu, jak aplikacja działa.
Ostatnia uwaga o szumie kontra sygnale. SourceKit będzie pokazywać błędy indeksu między plikami („Cannot find type X in scope”, „No such module”) wewnątrz edytora, gdy Xcode przebudowuje swój indeks, zwłaszcza zaraz po ponownym wygenerowaniu projektu. To są artefakty indeksu, a nie błędy współbieżności. Jeśli xcodebuild zgłasza BUILD SUCCEEDED, model współbieżności jest spełniony, a edytor po prostu nadrabia zaległości1. Gonienie za duchami indeksu to najszybszy sposób, by zmarnować popołudnie na migracji, która już zadziałała.
-
Produkcyjny kod autora w aplikacjach iOS z rodziny 941 (Ki, Return, Get Bananas), wszystkie wysyłane z
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActoriSWIFT_APPROACHABLE_CONCURRENCY = YES. Sześć poniższych wzorców błędów i poprawek stanowiło kompletny zestaw czyszczenia ścisłej współbieżności dla Ki 1.0.0. ↩↩↩ -
Swift Evolution, SE-0466: Control default actor isolation inference. Pozwala modułowi domyślnie ustawić izolację
@MainActor, więc cele interfejsu użytkownika i aplikacji działają na głównym aktorze, chyba że kod zrezygnuje z tego przez@concurrentlub jawnego aktora. ↩↩ -
Swift Evolution, SE-0461: Run nonisolated async functions on the caller’s actor by default. Nieizolowana funkcja
asyncdomyślnie przyjmujenonisolated(nonsending), działając na aktorze wywołującego zamiast przeskakiwać do globalnego egzekutora, co usuwa wymaganiaSendableprzekraczania granic, które towarzyszyły przeskokowi. ↩↩ -
Swift Evolution, SE-0461 wprowadza atrybut
@concurrent, by włączyć funkcję do działania na globalnym egzekutorze (wątku w tle).@concurrentinonisolated(nonsending)to dwa przeciwstawne tryby izolacji nieizolowanej funkcji asynchronicznej: funkcja albo działa tam, gdzie jest jej wywołujący, albo celowo od niego ucieka.@concurrentnie można łączyć z@MainActorani z niestandardowym aktorem globalnym. ↩↩↩ -
SWIFT_APPROACHABLE_CONCURRENCYto zbiorcze ustawienie kompilacji Xcode, które włącza nadchodzące funkcje przystępnej współbieżności (w tym zachowanie SE-0461), aSWIFT_DEFAULT_ACTOR_ISOLATIONwybiera domyślną izolację modułu. Nowe projekty Xcode 26 włączają oba z domyślnym ustawieniem głównego aktora. Udokumentowane u Donny’ego Walsa, “Exploring concurrency changes in Swift 6.2”, oraz Paula Hudsona, “What’s new in Swift 6.2”, oba odniesione do leżących u podstaw propozycji SE-0461 i SE-0466. ↩↩ -
Swift, Swift Concurrency Migration Guide, „Enabling Complete Concurrency Checking” i konfiguracja trybu języka. W pakiecie Swift domyślna izolacja i funkcje przystępnej współbieżności ustawiane są przez flagi nadchodzących funkcji
swiftSettings, a nie przez ustawienie kompilacji Xcode. ↩ -
Swift, Migration Guide: global actor isolation and
nonisolated. Metody typu@MainActordziedziczą izolację głównego aktora;nonisolatedwyłącza metodę z tego, co jest poprawne dla czystych funkcji, które nie dotykają żadnego izolowanego stanu. ↩ -
Apple Developer,
MainActor.assumeIsolated(_:). Stwierdza, że bieżące wykonanie jest już na głównym aktorze i uruchamia domknięcie synchronicznie bez przeskoku aktora. Asercja zatrzymuje się w środowisku wykonawczym, jeśli niezmiennik nie zachodzi, więc jest poprawna tylko tam, gdzie wywołujący jest gwarantowany na głównym wątku. ↩