BS
BleepingSwift
Published on

> Choosing the Right Resource Isolation Strategy in Swift

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @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

AspectActorsGCDLocks
SafetyCompile-timeRuntimeRuntime
AccessAsync onlySync or asyncSync only
OverheadHighestMediumLowest
ComplexityLowMediumHigh
Deadlock riskLowMediumHigh

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.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.