iOS Development

Swift Actors: 6 pułapek, na które złapią się nawet doświadczeni deweloperzy

11 min readRafał Dubiel
#iOS#Swift#Concurrency#Actors

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:

  1. Task A: sprawdza balance >= 800 → true
  2. Task A: czeka na authorizeTransaction()
  3. Task B: wchodzi do aktora, sprawdza balance >= 800 → true (wciąż 1000!)
  4. Task B: czeka na authorizeTransaction()
  5. Task A: wraca, odejmuje 800 → balance = 200
  6. 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:

  1. Hop z MainActor do Database actor,
  2. 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:

  1. Izolacja do MainActor - @MainActor gwarantuje, że dostęp do stanu jest izolowany do MainActor (a MainActor jest powiązany z głównym wątkiem),
  2. 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:

  1. Typy immutable z technicznego powodu oznaczone jako mutable (np. lazy initialization),
  2. Typy z wewnętrzną synchronizacją (lock, atomic),
  3. Singleton inicjalizowany raz przy starcie.

Przypadki, gdzie NIE używać @unchecked Sendable:

  1. "Żeby się skompilowało",
  2. Klasy z mutable state bez synchronizacji,
  3. 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:

  1. Actor mailbox jest FIFO - aktor przetwarza wiadomości w kolejności, w jakiej trafiły do jego mailboxa,
  2. 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:

  1. Czy mam await wewnątrz metod mutujących stan? → ryzyko reentrancy,
  2. Czy wywołuję aktora w pętli? → ryzyko actor hopping,
  3. Czy używam @MainActor z delegatami/callbackami? → ryzyko Thread-safety,
  4. Czy używam @unchecked Sendable? → Dlaczego dokładnie?
  5. 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:

  1. Reentrancy - stan może się zmienić między await, zawsze to zakładaj,
  2. Actor hopping - kosztowny przy MainActor, batchuj operacje,
  3. @MainActor - to compile-time hint, nie runtime guarantee (szczególnie z legacy APIs),
  4. Sendable - @unchecked to ostateczność,
  5. nonisolated - nie oznacza thread-safe
  6. 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

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ł:

Swift Actors: 6 pułapek, na które złapią się nawet doświadczeni deweloperzy | FractalDev Blog