- Published on
> Choosing the Right Resource Isolation Strategy in Swift
- Authors

- Name
- Mick MacCallum
- @0x7fs
Thread safety in Swift comes down to a simple goal: prevent multiple threads from accessing the same mutable state simultaneously. You have three main tools to achieve this—actors, GCD, and locks. Each makes different tradeoffs between safety, performance, and ease of use.
The Core Tradeoff
At the highest level, the choice breaks down like this:
Actors give you compile-time safety. The compiler enforces isolation boundaries, so race conditions become build errors rather than runtime bugs. The cost is that all access is async, which changes how you structure code.
GCD gives you synchronous access with runtime coordination. You can write thread-safe code that looks sequential, but you're responsible for using queues correctly. Mistakes compile fine and crash at runtime.
Locks give you maximum performance with maximum responsibility. They're an order of magnitude faster than the alternatives for short critical sections, but they offer zero compiler assistance.
Decision Framework
Start with these questions:
Can your code be async? If you're in a context where await is available and acceptable, actors are usually the best choice. You get safety guarantees that the other options can't match.
Do you need synchronous access? Property getters, Codable implementations, and callbacks from non-async code often need to return values immediately. Actors can't help here. Use GCD or locks.
Is this a hot path? If you're synchronizing millions of operations per second, lock overhead matters. A cache accessed once per user tap doesn't need os_unfair_lock. A counter incremented for every pixel in an image processing pipeline might.
How complex is the state? For a single counter or flag, a lock wrapper is fine. For coordinating multiple collections that need to stay consistent, an actor's structured isolation helps you reason about correctness.
Actors in Practice
Actors excel at coordinating shared state across your app. A cache, a connection pool, a feature flag manager—these are natural fits.
actor FeatureFlags {
private var flags: [String: Bool] = [:]
private var overrides: [String: Bool] = [:]
func isEnabled(_ flag: String) -> Bool {
overrides[flag] ?? flags[flag] ?? false
}
func setOverride(_ flag: String, enabled: Bool) {
overrides[flag] = enabled
}
func loadFromServer() async throws {
let newFlags = try await api.fetchFlags()
flags = newFlags
}
}
Multiple parts of your app can check flags and set overrides. The actor serializes access automatically. You can't forget synchronization because the compiler requires await.
The async requirement does ripple through your code. If a SwiftUI view needs a flag value, you can't just read it in the body. You need to load it into @State during task {} or use a view model that caches the value.
For a deeper dive, see Resource Isolation in Swift Using Actors.
GCD in Practice
GCD shines when you need synchronous thread-safe access. The classic pattern wraps a serial queue around shared state:
class ThreadSafeStore {
private let queue = DispatchQueue(label: "com.app.store")
private var items: [String: Item] = [:]
subscript(key: String) -> Item? {
get { queue.sync { items[key] } }
set { queue.sync { items[key] = newValue } }
}
}
Callers use this exactly like a dictionary. They don't need to think about concurrency at all. That's both the strength and the danger—the implementation could have a bug, and callers would never know until it crashes.
GCD also handles coordination patterns that actors don't express easily. Barrier writes on concurrent queues, dispatch groups for waiting on multiple operations, target queues for hierarchy—these have no direct actor equivalent.
For a deeper dive, see Resource Isolation in Swift Using GCD.
Locks in Practice
Locks matter when you need raw speed. For a counter in a tight loop:
final class AtomicCounter {
private var lock = os_unfair_lock()
private var _value = 0
var value: Int {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
return _value
}
func increment() {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
_value += 1
}
}
This is roughly 10x faster than GCD for the same operation. Whether that matters depends entirely on how often you're calling it.
Locks also work where nothing else can—in C callbacks, during static initialization, in deinit. Any context where you can't be async and can't structure code around a queue.
For a deeper dive, see Resource Isolation in Swift Using Locks.
Hybrid Approaches
Real apps often mix approaches. You might use actors for high-level coordination and locks for performance-critical internals:
actor ImageProcessor {
private let cache = LockedCache<URL, ProcessedImage>()
func process(_ url: URL) async -> ProcessedImage {
// Check cache (synchronous, fast)
if let cached = cache[url] {
return cached
}
// Process (async, potentially slow)
let raw = try? await downloader.fetch(url)
let processed = await processImage(raw)
// Store in cache
cache[url] = processed
return processed
}
}
The actor provides the async coordination boundary. The locked cache provides fast synchronous access internally. You get safety from the actor and performance from the lock.
Migration Guidance
If you're maintaining a codebase with GCD or locks, there's no urgent need to migrate. Working, tested synchronization code has value.
Consider migrating when:
- You're adding significant new functionality that would benefit from compile-time safety
- You're seeing race condition bugs that proper actor isolation would have prevented
- You're already adopting async/await throughout your codebase
- New team members are struggling with the synchronization patterns
Don't migrate just because actors are newer. Migration has costs, and "works correctly" is a high bar to clear again.
Common Mistakes
Mixing isolation incorrectly: Don't access actor state from a detached task expecting the actor to still be protected. The isolation is per-actor, not global.
Forgetting GCD sync/async semantics: async returns immediately. If you need the write to complete before continuing, use sync. But don't sync on a queue you might already be on.
Holding locks too long: Every nanosecond you hold a lock is a nanosecond another thread might be blocked. Do I/O outside the lock.
Ignoring reentrancy: Actor methods can interleave at suspension points. State you read before an await might change after it.
Summary
| Aspect | Actors | GCD | Locks |
|---|---|---|---|
| Safety | Compile-time | Runtime | Runtime |
| Access | Async only | Sync or async | Sync only |
| Overhead | Highest | Medium | Lowest |
| Complexity | Low | Medium | High |
| Deadlock risk | Low | Medium | High |
For new async code, start with actors. For synchronous thread-safe APIs, use GCD. For performance-critical hot paths, consider locks. And remember that mixing approaches is not only acceptable but often optimal.
// Continue_Learning
Resource Isolation in Swift Using Actors
Actors provide compile-time safety for shared mutable state in Swift. Here's when to use them and how they compare to older approaches.
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.