← All topics/Concurrency

Question 1

Race conditions in structured concurrency

You have multiple async tasks mutating shared state. How would you design the system to prevent race conditions?

Answer outline

Race conditions come from shared mutable state, so the design goal is to remove it or put it behind one controlled access point. Prefer immutable data where you can. Where mutation is unavoidable, confine it to one serial executor so only one task writes at a time.

Model the shared resource as an actor, or mark UI-facing types @MainActor, so the compiler enforces mutual exclusion rather than relying on discipline.

Only one task runs actor-isolated code at a time, so reads and writes cannot race. The cost is that every cross-actor call needs await, and after any await inside the actor another task may have changed the state. That's reentrancy, so re-check the state before you write.

Start with actors for safety. If one actor becomes a bottleneck because many tasks queue on it, split the state into smaller actors with narrower responsibilities.

Actor serialization model: one task inside at a time, with reentrancy after each await.

Principles

  • A data race is unsynchronized concurrent access where at least one side writes, and Swift 6 catches many at compile time.
  • An actor serializes access to its own state, and @MainActor is the actor for UI and view models.
  • Structured concurrency with async let and task groups bounds child lifetimes and propagates cancellation.
  • Locks such as NSLock can win micro-benchmarks but are easy to misuse, so prefer actors unless you have measured contention.
  • Reentrancy means another task can change actor state while you're suspended, so re-check invariants after every await.
Actor-backed cache with compiler-enforced serialization
actor ImageCache {
    private var store: [URL: Data] = [:]

    func image(for url: URL) async throws -> Data {
        if let hit = store[url] { return hit }
        // Suspension point: another task may enter the actor before we resume
        let data = try await download(url)

        // Re-check after the suspension point: another task may have filled the entry
        if let existing = store[url] { return existing }
        store[url] = data
        return data
    }

    private func download(_ url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }
}
Structured fan-out at the call site
func loadThumbnails(urls: [URL]) async throws -> [URL: Data] {
    let cache = ImageCache()
    return try await withThrowingTaskGroup(of: (URL, Data).self) { group in
        for url in urls {
            group.addTask {
                let data = try await cache.image(for: url)
                return (url, data)
            }
        }
        var result: [URL: Data] = [:]
        for try await (url, data) in group {
            result[url] = data
        }
        return result
    }
}