iosNudge

What are the ways to make code thread-safe in Swift?

MediumAsked oftenConcurrency 3 min read

TL;DR

Thread safety problems come from shared mutable state. You have two choices:

  1. Do not share it: use immutable values, value types, or keep state on one actor like @MainActor.
  2. Protect it: use an actor, a Mutex or lock, a serial queue, or atomics.

For new Swift 6 code, prefer actors and @MainActor. Use Mutex for small, synchronous critical sections.

1. Avoid shared mutable state

The best lock is no lock.

struct Settings: Sendable {       // value type, copied, immutable
    let theme: String
    let fontSize: Int
}

Each task gets its own copy. Nothing to protect.

2. Actor

actor ImageCache {
    private var images: [URL: Data] = [:]

    func image(for url: URL) -> Data? { images[url] }
    func store(_ data: Data, for url: URL) { images[url] = data }
}

let cache = ImageCache()
await cache.store(data, for: url)
  • Checked by the compiler.
  • Callers must await, so it fits async code.
  • Watch out for reentrancy across await inside actor methods.

3. @MainActor isolation

Keep UI-related state on the main actor. Only one thread touches it.

@MainActor
@Observable
final class CartModel {
    var items: [String] = []
}

4. Mutex (Synchronization, iOS 18+)

import Synchronization

final class RequestCounter: Sendable {
    private let count = Mutex(0)

    func increment() {
        count.withLock { $0 += 1 }
    }

    var value: Int { count.withLock { $0 } }
}
  • Synchronous. No await needed.
  • Sendable, so the class is Sendable without @unchecked.
  • Keep the closure short. Never await inside it (the compiler does not allow it).

5. Locks (older OS versions)

import os

final class TokenStore: Sendable {
    private let state = OSAllocatedUnfairLock<String?>(initialState: nil)   // iOS 16+

    func set(_ token: String) { state.withLock { $0 = token } }
    func get() -> String? { state.withLock { $0 } }
}

Or NSLock:

final class Logger: @unchecked Sendable {
    private let lock = NSLock()
    private var lines: [String] = []

    func log(_ line: String) {
        lock.withLock { lines.append(line) }
    }
}

Use withLock so you never forget unlock(), even if the code throws.

6. Serial DispatchQueue

final class Store: @unchecked Sendable {
    private let queue = DispatchQueue(label: "com.app.store")
    private var items: [String] = []

    func add(_ item: String) { queue.async { self.items.append(item) } }
    func all() -> [String] { queue.sync { items } }
}

Classic and still common. Slower than a lock for tiny operations. Risk of deadlock if you call sync from the same queue.

7. Atomics (Synchronization, iOS 18+)

For a single number or flag in very hot code.

import Synchronization

final class Stats: Sendable {
    private let hits = Atomic<Int>(0)

    func hit() { hits.add(1, ordering: .relaxed) }
    var total: Int { hits.load(ordering: .relaxed) }
}

Atomics protect one value. They do not help when two values must change together.

Comparison

TechniqueAsync-friendlyCompiler-checkedSpeedBest for
Immutable / value typesYesYesFastestData models
ActorYes (await)YesGoodShared state in async code
@MainActorYesYesGoodUI state
Mutex / unfair lockSync onlyMutex: yesVery fastSmall critical sections
NSLockSync onlyNo (@unchecked)FastOlder code
Serial queueCallback styleNo (@unchecked)MediumLegacy code
AtomicsSync onlyYesFastestCounters, flags

Common mistakes

  • Making each property safe, but not the operation. if cache[key] == nil { cache[key] = value } needs one lock around both steps.
  • Holding a lock while calling unknown code or doing I/O.
  • Using @unchecked Sendable without real protection, just to silence the compiler.
  • Blocking inside async code with semaphores or queue.sync.

How to choose (quick rule)

  1. Can it be immutable or a value type? Do that.
  2. Is it UI state? @MainActor.
  3. Is it shared state used from async code? Actor.
  4. Is it a tiny, sync, hot critical section? Mutex (or OSAllocatedUnfairLock before iOS 18).

Common follow-up questions

  • Why not os_unfair_lock directly in Swift? It must not move in memory. Swift may move a stored struct, which breaks it. Use OSAllocatedUnfairLock or Mutex instead.
  • Is NSLock reentrant? No. Locking it twice from the same thread deadlocks. NSRecursiveLock is reentrant.
  • Are Swift Array and Dictionary thread-safe? Reading from many threads is safe. Writing while others read or write is not.
  • Is let thread-safe? Yes, if the type is a value type or immutable. A let reference to a mutable class is not.
  • How do you verify thread safety? Thread Sanitizer, stress tests with many concurrent tasks, and Swift 6 compiler checks.