- Published on
> Resource Isolation in Swift Using Actors
- Authors

- Name
- Mick MacCallum
- @0x7fs
When multiple tasks access the same mutable state, things break. You get corrupted data, impossible states, and crashes that only happen in production. Swift's actors solve this by making unsafe concurrent access a compile-time error rather than a runtime mystery.
Actors are reference types like classes, but with a crucial difference: the compiler enforces that only one task can access an actor's mutable state at a time. You don't have to remember to acquire a lock or dispatch to a queue. The isolation is built into the type system.
Basic Actor Syntax
An actor looks almost identical to a class:
actor BankAccount {
private var balance: Double = 0
func deposit(_ amount: Double) {
balance += amount
}
func withdraw(_ amount: Double) -> Bool {
guard balance >= amount else { return false }
balance -= amount
return true
}
func getBalance() -> Double {
return balance
}
}
The difference shows up when you try to use it. Accessing an actor's properties or methods from outside requires await:
let account = BankAccount()
// This requires await because we're crossing an isolation boundary
await account.deposit(100)
let success = await account.withdraw(50)
let balance = await account.getBalance()
If you forget await, the compiler stops you. This is the key safety guarantee: you can't accidentally access actor state from multiple threads simultaneously.
When Actors Shine
Actors work best when you have a resource that multiple parts of your app need to access, and the access patterns involve both reads and writes.
A cache is a classic example:
actor ImageCache {
private var cache: [URL: Data] = [:]
func image(for url: URL) -> Data? {
return cache[url]
}
func store(_ data: Data, for url: URL) {
cache[url] = data
}
func clear() {
cache.removeAll()
}
}
Multiple network requests can store images, multiple views can read them, and you don't need to think about synchronization. The actor handles it.
Analytics managers, feature flag caches, connection pools—anything that coordinates shared mutable state across your app is a good candidate.
The nonisolated Keyword
Sometimes you have actor properties that don't need isolation because they never change. Mark these as nonisolated to allow synchronous access:
actor UserSession {
nonisolated let userId: String
private var preferences: [String: Any] = [:]
init(userId: String) {
self.userId = userId
}
func setPreference(_ value: Any, forKey key: String) {
preferences[key] = value
}
}
let session = UserSession(userId: "abc123")
print(session.userId) // No await needed—userId is immutable
This is useful for identifiers, configuration that's set once at initialization, or computed properties that derive from immutable state.
Actor Reentrancy
Here's where actors get tricky. When an actor method suspends (hits an await), other calls can interleave. The actor's state might change while you're waiting:
actor Counter {
var count = 0
func incrementAfterDelay() async {
let current = count
await Task.sleep(nanoseconds: 1_000_000_000) // Actor unlocks here
count = current + 1 // count might have changed!
}
}
If two tasks call incrementAfterDelay() simultaneously, both might read count as 0, then both set it to 1. You expected 2 but got 1.
The fix is to avoid capturing state before suspension points, or recapture it after:
actor Counter {
var count = 0
func incrementAfterDelay() async {
await Task.sleep(nanoseconds: 1_000_000_000)
count += 1 // Read and write atomically after the suspension
}
}
MainActor for UI Work
@MainActor is a special global actor that runs on the main thread. Use it for any code that touches UIKit or SwiftUI state:
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var isLoading = false
func loadItems() async {
isLoading = true
let fetched = await api.fetchItems()
items = fetched
isLoading = false
}
}
Because the entire class is marked @MainActor, all property access and method calls happen on the main thread. You don't need to manually dispatch UI updates.
You can also mark individual methods:
class DataService {
func fetchData() async -> [Item] {
// Runs on whatever thread
return await api.fetch()
}
@MainActor
func updateUI(with items: [Item]) {
// Guaranteed main thread
}
}
When Not to Use Actors
Actors add overhead. Each call crosses an isolation boundary, which means potential suspension points and context switches. For performance-critical code that's already running on a single thread, actors are unnecessary.
If your synchronization needs are simple—protecting a single property update—a lock might be more appropriate. Actors shine for coordinating complex state, not guarding individual operations.
Actors also don't help with synchronous code. If you need synchronous thread-safe access (like during app launch before any async context exists), you'll need locks or GCD.
Finally, actors can't protect state they don't own. If you're wrapping a thread-unsafe C library or working with UIKit classes that must be accessed from the main thread, an actor won't magically make that safe. You still need to understand the underlying constraints.
Combining Actors with Structured Concurrency
Actors work naturally with async let and task groups:
actor DataAggregator {
private var results: [String: Data] = [:]
func aggregate(urls: [URL]) async {
await withTaskGroup(of: (URL, Data?).self) { group in
for url in urls {
group.addTask {
let data = try? await URLSession.shared.data(from: url).0
return (url, data)
}
}
for await (url, data) in group {
if let data = data {
results[url.absoluteString] = data
}
}
}
}
}
Multiple tasks fetch data concurrently, but storing results in the actor's dictionary is serialized automatically.
Migration Path
If you're coming from GCD or locks, actors often let you delete synchronization code entirely. A class with a serial queue becomes an actor without the queue:
// Before: GCD
class Cache {
private let queue = DispatchQueue(label: "cache")
private var storage: [String: Any] = [:]
func get(_ key: String) -> Any? {
queue.sync { storage[key] }
}
func set(_ key: String, value: Any) {
queue.async { self.storage[key] = value }
}
}
// After: Actor
actor Cache {
private var storage: [String: Any] = [:]
func get(_ key: String) -> Any? {
storage[key]
}
func set(_ key: String, value: Any) {
storage[key] = value
}
}
The actor version is cleaner, and the compiler catches any thread-safety violations. The tradeoff is that callers must now use await, which can ripple through your codebase.
For a detailed comparison of actors versus GCD and locks, see Choosing the Right Resource Isolation Strategy in Swift.
// Continue_Learning
Choosing the Right Resource Isolation Strategy in Swift
Swift offers actors, GCD, and locks for thread safety. Each solves the same problem differently. Here's how to choose.
Resource Isolation in Swift Using GCD
Grand Central Dispatch remains a practical choice for thread safety in Swift, especially when you need synchronous access or are working with legacy code.
Resource Isolation in Swift Using Locks
Locks offer the lowest overhead for thread synchronization in Swift. Here's when that matters and how to use them safely.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.