Zum Inhalt springen

Instance Actors in Swift: Part 3 of Actor Series

In our previous articles, we explored @MainActor for main thread operations and Global Actors for app-wide synchronization domains. Now, let’s complete our actor series by diving deep into Instance Actors – the most fundamental and flexible type of actor in Swift’s concurrency model.

What are Instance Actors?

Instance actors, also known as regular actors, are individual actor instances that each maintain their own isolated execution context. Unlike global actors that provide shared synchronization domains, each instance actor creates its own protective boundary around its mutable state.

Think of an instance actor as a personal bodyguard for your data. Each actor instance has its own dedicated security guard that ensures only one operation can access the actor’s internal state at a time, preventing data races and ensuring thread safety.

actor Counter {
    private var value = 0

    func increment() -> Int {
        value += 1
        return value
    }

    func decrement() -> Int {
        value -= 1
        return value
    }

    func getValue() -> Int {
        return value
    }
}

In this example, each Counter instance has its own isolated execution context. If you create multiple Counter instances, they operate independently without interfering with each other.

Why Use Instance Actors?

Instance actors solve specific concurrency challenges:

  • Data Isolation: Each instance protects its own mutable state independently
  • Granular Control: Fine-grained synchronization for specific objects or resources
  • Scalable Concurrency: Multiple instances can run concurrently without blocking each other
  • Resource Management: Perfect for managing individual resources like file handlers, network connections, or cache entries
  • State Machines: Ideal for implementing thread-safe state machines and object lifecycle management

How Instance Actors Work

Instance actors process requests one at a time in a serialized manner for each specific instance. When you call an actor’s method from outside its isolation context, you must use await, which creates a suspension point where your code waits for its turn to execute on that particular actor.

Example Bank Account Manager

This example shows how instance actors protect individual account data from race conditions:

actor BankAccount {
    private let accountNumber: String
    private var balance: Double
    private var transactionHistory: [Transaction] = []

    init(accountNumber: String, initialBalance: Double = 0.0) {
        self.accountNumber = accountNumber
        self.balance = initialBalance
    }

    func deposit(_ amount: Double) async -> Bool {
        guard amount > 0 else { return false }

        // Simulate processing delay
        try? await Task.sleep(nanoseconds: 100_000_000)

        balance += amount
        transactionHistory.append(Transaction(type: .deposit, amount: amount))

        print(" Deposited $(amount). New balance: $(balance)")
        return true
    }

    func withdraw(_ amount: Double) async -> Bool {
        guard amount > 0 && balance >= amount else {
            print("Insufficient funds for withdrawal of $(amount)")
            return false
        }

        // Simulate processing delay
        try? await Task.sleep(nanoseconds: 100_000_000)

        balance -= amount
        transactionHistory.append(Transaction(type: .withdrawal, amount: amount))

        print("Withdrew $(amount). New balance: $(balance)")
        return true
    }

    func getBalance() async -> Double {
        return balance
    }

    func getTransactionHistory() async -> [Transaction] {
        return transactionHistory
    }
}

struct Transaction {
    enum TransactionType {
        case deposit
        case withdrawal
    }

    let type: TransactionType
    let amount: Double
    let timestamp = Date()
}

// Real-world usage demonstrating thread safety
func bankAccountExample() async {
    let account = BankAccount(accountNumber: "12345", initialBalance: 1000.0)

    // Multiple concurrent operations - all safely serialized by the actor
    async let deposit1: Bool = account.deposit(200.0)
    async let withdrawal1: Bool = account.withdraw(150.0)
    async let deposit2: Bool = account.deposit(300.0)
    async let withdrawal2: Bool = account.withdraw(50.0)

    let results = await [deposit1, withdrawal1, deposit2, withdrawal2]
    let finalBalance = await account.getBalance()

    print("Final balance: $(finalBalance)")
    print("All operations completed: (results)")
}

This example demonstrates the core strength of instance actors:

  1. Individual Protection: Each bank account actor instance protects its own state
  2. Independent Operation: Multiple account instances work concurrently without blocking each other
  3. Thread Safety: No risk of data corruption from concurrent access
  4. Natural Boundaries: Each actor represents a logical unit (individual bank account)
  5. Scalability: You can create as many instances as needed without performance degradation

When to Use Instance Actors

Instance actors are perfect when you need:

  • Individual Resource Management: Each instance manages its own dedicated resource (file, connection, cache)
  • State Isolation: Different instances need to maintain separate state without interference
  • Concurrent Processing: Multiple similar operations need to run simultaneously without blocking
  • Task-Based Operations: Each actor represents an individual task or workflow
  • Scalable Architecture: You need to create many independent units that work concurrently

Best Practices

Keep Actor Operations Focused

Design actors around single responsibilities and keep their interfaces clean:

// Good: Focused responsibility
actor TokenManager {
    private var accessToken: String?
    private var refreshToken: String?
    private var expiryDate: Date?

    func setTokens(access: String, refresh: String, expiresIn: TimeInterval) {
        accessToken = access
        refreshToken = refresh
        expiryDate = Date().addingTimeInterval(expiresIn)
    }

    func getValidAccessToken() async -> String? {
        guard let token = accessToken,
              let expiry = expiryDate,
              expiry > Date() else {
            return nil
        }
        return token
    }

    func clearTokens() {
        accessToken = nil
        refreshToken = nil
        expiryDate = nil
    }
}

Minimize Cross-Actor Communication

Avoid frequent communication between different actors as it can impact performance:

// Better: Batch operations to minimize actor switching
actor DataProcessor {
    private var processedItems: [String] = []

    func processItems(_ items: [String]) async -> [String] {
        let processed = items.map { item in
            return "processed-(item)"
        }
        processedItems.append(contentsOf: processed)
        return processed
    }

    func getProcessedCount() -> Int {
        return processedItems.count
    }
}

Don’t Block Actor Threads

Avoid long-running synchronous operations that block the actor.

Summary

Instance actors are the foundation of Swift’s actor concurrency model. They provide:

  • Individual Isolation: Each instance protects its own state independently
  • Scalable Concurrency: Multiple instances can work simultaneously without interference
  • Fine-Grained Control: Perfect for managing specific resources or implementing state machines
  • Thread Safety: Automatic protection against data races and concurrent access issues

Together with @MainActor and Global Actors, instance actors complete Swift’s comprehensive actor concurrency system. Each type serves its purpose:

  • @MainActor: For UI updates and main thread operations
  • Global Actors: For app-wide shared synchronization domains
  • Instance Actors: For individual, independent state protection

This completes our 3-part actor series! You now have the knowledge to choose the right actor type for any concurrency challenge in your Swift applications.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert