Swift Actors: 6 pułapek, na które złapią się nawet doświadczeni deweloperzy
Kiedy Apple wprowadził aktorów w Swift 5.5, obiecywał nam koniec z wyścigmai danych. "Po prostu zamień klasę na actor i problem rozwiązany" - tak brzmiała narracja. Rzeczywistość okazała się znacznie bardziej złożona.
Po serii doświadczeń z aktorami, zebrałem listę pułapek, na które regularnie łapią się nawet seniorzy. Niektóre z nich są słabo udokumentowane, a inne aktywnie wprowadzają w błąd.
1. Reentrancy: aktor nie jest kolejką seryjną
To prawdopodobnie najbardziej niedoceniana pułapka. Większość deweloperów myśli o aktorze jak o klasie z wbudowaną DispatchQueue(label: "serial"). To błąd.
Aktor gwarantuje tylko jedno: w danym momencie wykonuje się tylko jeden fragment kodu. Ale między punktami await może obsłużyć zupełnie inne wywołanie.
actor BankAccount {
var balance: Int = 1000
func withdraw(_ amount: Int) async -> Bool {
// Sprawdzamy saldo
guard balance >= amount else { return false }
// ⚠️ SUSPENSION POINT - tu aktor może obsłużyć inne wywołanie
await authorizeTransaction()
// Po powrocie saldo mogło się zmienić!
balance -= amount // Może zejść poniżej zera
return true
}
private func authorizeTransaction() async {
try? await Task.sleep(for: .milliseconds(100))
}
}
Jeśli dwa taski wywołają withdraw(800) niemal jednocześnie:
- Task A: sprawdza
balance >= 800→ true - Task A: czeka na
authorizeTransaction() - Task B: wchodzi do aktora, sprawdza
balance >= 800→ true (wciąż 1000!) - Task B: czeka na
authorizeTransaction() - Task A: wraca, odejmuje 800 → balance = 200
- Task B: wraca, odejmuje 800 → balance = -600 💥
Dlaczego tak to zaprojektowano?
Apple wybrał reentrancy świadomie, żeby uniknąć deadlocków. Wyobraź sobie, że dwaj aktorzy wzajemnie na siebie czekają - bez reentrancy masz klasyczny deadlock. Z reentrancy masz… subtelne bugi stanu.
Rozwiązanie: wzorzec Task cache
Zamiast sprawdzać stan przed await, zapisz Task przed pierwszym suspension point:
actor BankAccount {
var balance: Int = 1000
private var pendingWithdrawals: [UUID: Task<Bool, Never>] = [:]
func withdraw(_ amount: Int, id: UUID = UUID()) async -> Bool {
// Jeśli już przetwarzamy tę transakcję, czekaj na wynik
if let existing = pendingWithdrawals[id] {
return await existing.value
}
// Sprawdź synchronicznie PRZED jakimkolwiek await
guard balance >= amount else { return false }
// Rezerwuj środki synchronicznie
balance -= amount
let task = Task {
await authorizeTransaction()
return true
}
pendingWithdrawals[id] = task
let result = await task.value
pendingWithdrawals[id] = nil
if !result {
balance += amount // Rollback
}
return result
}
}
Kluczowa zmiana: mutacja stanu dzieje się synchronicznie, przed pierwszym await.
Uwaga: To jeden z możliwych wzorców rozwiązania reentrancy - nie jedyny i nie zawsze najlepszy. Alternatywy to m.in. podział na actor + pure async service, optimistic locking, czy w niektórych przypadkach
nonisolated+ lock. Wybór zależy od konkretnego use case'u.
2. Actor hopping: ukryty zabójca wydajności
Każde przejście między aktorami to potencjalny context switch. W pętlach to może być katastrofa.
actor Database {
func loadUser(id: Int) -> User {
// Ciężka operacja
User(id: id)
}
}
@MainActor
class DataModel {
let database = Database()
var users: [User] = []
func loadUsers() async {
for i in 1...100 {
// ❌ 200 context switchów!
let user = await database.loadUser(id: i)
users.append(user)
}
}
}
Każda iteracja to:
- Hop z MainActor do Database actor,
- Hop z Database actor z powrotem do MainActor.
Przy 100 iteracjach masz 200 przeskoków. Apple w WWDC 2021 ("Swift Concurrency: Behind the Scenes") pokazywał, jak to wygląda na CPU - wzorzec "piły" z ciągłymi przerwami.
Rozwiązanie: batching
actor Database {
func loadUsers(ids: [Int]) -> [User] {
ids.map { User(id: $0) }
}
}
@MainActor
class DataModel {
let database = Database()
var users: [User] = []
func loadUsers() async {
let ids = Array(1...100)
// ✅ Jeden hop tam, jeden z powrotem
let newUsers = await database.loadUsers(ids: ids)
users.append(contentsOf: newUsers)
}
}
Kiedy to naprawdę boli?
Hopping między aktorami w cooperative pool jest tani. Problem pojawia się przy hoppingu do/z MainActor, bo main thread jest poza cooperative pool i wymaga prawdziwego context switcha.
Reguła: jeśli robisz więcej niż 10 hopów do MainActor w jednej operacji, prawdopodobnie coś jest nie tak z architekturą.
3. @MainActor: fałszywe poczucie bezpieczeństwa
To pułapka, która złapała setki deweloperów po przejściu na Swift 6. Anotacja @MainActor nie zawsze gwarantuje wykonanie na main thread.
@MainActor
class ViewModel {
var data: String = ""
func updateData() {
// W Swift 5: może NIE być na main thread!
data = "updated"
}
}
// Gdzieś w kodzie...
DispatchQueue.global().async {
let vm = ViewModel()
vm.updateData() // ⚠️ Wykonuje się na background thread!
}
Dlaczego tak się dzieje?
Trzeba rozróżnić dwie rzeczy:
- Izolacja do MainActor -
@MainActorgwarantuje, że dostęp do stanu jest izolowany do MainActor (a MainActor jest powiązany z głównym wątkiem), - Wymuszenie async boundary - ale ta gwarancja działa tylko wtedy, gdy wywołanie przechodzi przez granicę izolacji (async boundary).
Problem pojawia się, gdy kod omija tę granicę - szczególnie przy interakcji z legacy APIs z Objective-C. Callback z frameworka Apple nie "wie" o Swift Concurrency i wywołuje twoją metodę bezpośrednio, bez przejścia przez async boundary.
Innymi słowy: @MainActor to compile-time contract, który kompilator egzekwuje tylko tam, gdzie "widzi" całą ścieżkę wywołania. Legacy APIs są dla niego czarną skrzynką.
Swift 6 language mode łapie większość tych przypadków, ale nie wszystkie - szczególnie przy interakcji z Objective-C.
Przypadki, gdzie @MainActor zawodzi
1. Callbacki z frameworków Apple:
@MainActor
class BiometricManager {
var isAuthenticated = false
func authenticate() {
let context = LAContext()
context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Login"
) { success, _ in
// ❌ Ten callback ZAWSZE jest na background thread!
self.isAuthenticated = success // Data race!
}
}
}
2. Delegate pattern z Objective-C:
@MainActor
class LocationHandler: NSObject, CLLocationManagerDelegate {
var lastLocation: CLLocation?
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
// ❌ Może być wywołane z dowolnego wątku!
lastLocation = locations.last
}
}
Rozwiązanie: explicit dispatch
@MainActor
class BiometricManager {
var isAuthenticated = false
func authenticate() async {
let context = LAContext()
// Używaj async/await zamiast callbacków
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Login"
)
isAuthenticated = success // ✅ Teraz na MainActor
} catch {
isAuthenticated = false
}
}
}
Dla delegatów bez async API:
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
Task { @MainActor in
lastLocation = locations.last // ✅ Explicit hop
}
}
4. Sendable: kompilator nie złapie wszystkiego
Sendable to protokół oznaczający typy bezpieczne do przekazywania między izolowanymi domenami. Problem w tym, że kompilator często przepuszcza niebezpieczny kod.
class UnsafeCache {
var items: [String: Data] = [:] // Mutable state, not thread-safe
}
actor DataProcessor {
func process(cache: UnsafeCache) async {
// ⚠️ W Swift 5 to kompiluje się bez ostrzeżeń!
cache.items["key"] = Data()
}
}
@unchecked Sendable: broń obosieczna
Wielu deweloperów, żeby uciszyć kompilator, dodaje @unchecked Sendable:
extension UnsafeCache: @unchecked Sendable {}
To mówi kompilatorowi: "zaufaj mi, wiem co robię". Problem w tym, że najczęściej nie wiesz.
Przypadki, gdzie @unchecked Sendable jest uzasadnione:
- Typy immutable z technicznego powodu oznaczone jako mutable (np. lazy initialization),
- Typy z wewnętrzną synchronizacją (lock, atomic),
- Singleton inicjalizowany raz przy starcie.
Przypadki, gdzie NIE używać @unchecked Sendable:
- "Żeby się skompilowało",
- Klasy z mutable state bez synchronizacji,
- Typy, których nie kontrolujesz.
Lepsze rozwiązanie: refaktoring na actor
// Zamiast
class UnsafeCache: @unchecked Sendable {
var items: [String: Data] = [:]
}
// Użyj
actor SafeCache {
var items: [String: Data] = [:]
func get(_ key: String) -> Data? { items[key] }
func set(_ key: String, _ value: Data) { items[key] = value }
}
5. nonisolated: nie znaczy "thread-safe"
Słowo nonisolated oznacza tylko tyle, że metoda/właściwość nie wymaga izolacji aktora. Nie oznacza, że jest thread-safe.
actor Counter {
private var count = 0
nonisolated var description: String {
// ❌ Nie możesz tu użyć count!
"Counter instance" // OK, bo nie dotykamy stanu
}
nonisolated func badIdea() {
// ❌ BŁĄD KOMPILACJI: Actor-isolated property 'count'
// can not be referenced from a non-isolated context
print(count)
}
}
Typowy błąd: nonisolated dla conformance
actor Wallet: CustomStringConvertible {
let name: String
var balance: Double = 0
// Musi być nonisolated dla protokołu
nonisolated var description: String {
// ❌ NIE ZADZIAŁA:
// "\(name): \(balance)"
// ✅ Tylko immutable state:
name
}
}
nonisolated w Swift 6.2 z MainActor by default
W nowym modelu z flagą MainActorIsolationByDefault, nonisolated nabiera nowego znaczenia: oznacza "dziedziczy izolację wywołującego".
// Z MainActorIsolationByDefault = true
class DataManager {
// Domyślnie @MainActor
func processOnMain() { }
// Dziedziczy kontekst wywołującego
nonisolated func processAnywhere() { }
// Explicit: uruchom na background
@concurrent
func processInBackground() async { }
}
To zmiana paradygmatu - nonisolated przestaje oznaczać "bez izolacji", a zaczyna oznaczać "elastyczna izolacja".
6. Aktor to nie kolejka seryjna: brak gwarancji kolejności
To zaskakuje wielu deweloperów przechodzących z GCD. Aktorzy nie gwarantują kolejności wykonania wywołań z zewnątrz.
actor Logger {
private var logs: [String] = []
func log(_ message: String) {
logs.append(message)
}
func getLogs() -> [String] { logs }
}
let logger = Logger()
// Z nonisolated kontekstu
for i in 0..<10 {
Task {
await logger.log("Message \(i)")
}
}
// Logi mogą być: [0, 2, 1, 4, 3, 6, 5, 8, 7, 9]
// lub dowolna inna permutacja!
Dlaczego tak jest?
Trzeba rozróżnić dwie rzeczy:
- Actor mailbox jest FIFO - aktor przetwarza wiadomości w kolejności, w jakiej trafiły do jego mailboxa,
- Task scheduling NIE jest FIFO - ale kolejność, w jakiej taski wysyłają wiadomości do aktora, nie jest deterministyczna.
Innymi słowy: kolejność zakolejkowania ≠ kolejność wykonania. Każdy Task to niezależna jednostka pracy. Scheduler może je uruchomić w dowolnej kolejności, więc wiadomości trafiają do mailboxa aktora w nieprzewidywalnej sekwencji. Aktor gwarantuje tylko, że log() nie będzie wykonywane równolegle - ale nie w jakiej kolejności wiadomości do niego dotrą.
Rozwiązanie: explicit ordering
Jeśli potrzebujesz kolejności, musisz ją wymusić:
actor OrderedLogger {
private var logs: [String] = []
private var pendingTask: Task<Void, Never>?
func log(_ message: String) async {
// Czekaj na poprzedni task
await pendingTask?.value
// Zapisz ten task jako następny w kolejce
pendingTask = Task {
logs.append(message)
}
await pendingTask?.value
}
}
Albo użyj dedykowanej biblioteki jak swift-async-queue:
import AsyncQueue
actor OrderedLogger {
private var logs: [String] = []
private let queue = FIFOQueue()
nonisolated func log(_ message: String) -> Task<Void, Never> {
Task(on: queue) {
await self.appendLog(message)
}
}
private func appendLog(_ message: String) {
logs.append(message)
}
}
Checklist przed użyciem aktora
Zanim zamienisz klasę na aktora, przejdź przez tę listę:
✅ Użyj aktora gdy:
- Masz mutable state współdzielony między taskami,
- Potrzebujesz thread-safety bez ręcznej synchronizacji,
- Operacje na stanie są głównie synchroniczne.
❌ Nie używaj aktora gdy:
- Potrzebujesz gwarantowanej kolejności operacji,
- Wszystkie operacje są asynchroniczne (reentrancy będzie problemem),
- Masz performance-critical code z wieloma małymi operacjami,
- Wymagasz synchronicznego dostępu do stanu.
🔍 Pytania kontrolne:
- Czy mam await wewnątrz metod mutujących stan? → ryzyko reentrancy,
- Czy wywołuję aktora w pętli? → ryzyko actor hopping,
- Czy używam @MainActor z delegatami/callbackami? → ryzyko Thread-safety,
- Czy używam @unchecked Sendable? → Dlaczego dokładnie?
- Czy zależy mi na kolejności operacji? → Aktor tego nie gwarantuje.
Podsumowanie
Aktorzy to potężne narzędzie, ale nie magiczna różdżka. Rozumienie ich ograniczeń jest kluczowe dla pisania poprawnego, wydajnego kodu.
Najważniejsze wnioski:
- Reentrancy - stan może się zmienić między
await, zawsze to zakładaj, - Actor hopping - kosztowny przy MainActor, batchuj operacje,
- @MainActor - to compile-time hint, nie runtime guarantee (szczególnie z legacy APIs),
- Sendable -
@uncheckedto ostateczność, - nonisolated - nie oznacza thread-safe
- Kolejność - aktorzy jej nie gwarantują (kolejność zakolejkowania ≠ kolejność wykonania)
Swift Concurrency to wciąż ewoluujący system. Swift 6.2 wprowadza MainActorIsolationByDefault i @concurrent, które zmieniają domyślne zachowania. Bądź na bieżąco z Swift Evolution proposals - to jedyny sposób, żeby nie dać się zaskoczyć.

Rafał Dubiel
Senior iOS Developer z ponad 8-letnim doświadczeniem w tworzeniu aplikacji mobilnych. Pasjonat czystej architektury, Swift i dzielenia się wiedzą ze społecznością.
Udostępnij artykuł: