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:
- Individual Protection: Each bank account actor instance protects its own state
- Independent Operation: Multiple account instances work concurrently without blocking each other
- Thread Safety: No risk of data corruption from concurrent access
- Natural Boundaries: Each actor represents a logical unit (individual bank account)
- 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.