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:
- Do not share it: use immutable values, value types, or keep state on one actor like
@MainActor. - Protect it: use an actor, a
Mutexor 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
awaitinside 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
awaitneeded. Sendable, so the class isSendablewithout@unchecked.- Keep the closure short. Never
awaitinside 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
| Technique | Async-friendly | Compiler-checked | Speed | Best for |
|---|---|---|---|---|
| Immutable / value types | Yes | Yes | Fastest | Data models |
| Actor | Yes (await) | Yes | Good | Shared state in async code |
@MainActor | Yes | Yes | Good | UI state |
Mutex / unfair lock | Sync only | Mutex: yes | Very fast | Small critical sections |
NSLock | Sync only | No (@unchecked) | Fast | Older code |
| Serial queue | Callback style | No (@unchecked) | Medium | Legacy code |
| Atomics | Sync only | Yes | Fastest | Counters, 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 Sendablewithout real protection, just to silence the compiler. - Blocking inside async code with semaphores or
queue.sync.
How to choose (quick rule)
- Can it be immutable or a value type? Do that.
- Is it UI state?
@MainActor. - Is it shared state used from async code? Actor.
- Is it a tiny, sync, hot critical section?
Mutex(orOSAllocatedUnfairLockbefore iOS 18).
Common follow-up questions
- Why not
os_unfair_lockdirectly in Swift? It must not move in memory. Swift may move a stored struct, which breaks it. UseOSAllocatedUnfairLockorMutexinstead. - Is
NSLockreentrant? No. Locking it twice from the same thread deadlocks.NSRecursiveLockis reentrant. - Are Swift
ArrayandDictionarythread-safe? Reading from many threads is safe. Writing while others read or write is not. - Is
letthread-safe? Yes, if the type is a value type or immutable. Aletreference 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.