How do you choose between an actor, a Mutex, an unfair lock, an atomic and a serial queue to protect shared state?

10.10.2 · L5 · Interview

Predict first

A thousand concurrent increments on each of a Mutex, an unfair lock, an actor, a queue and an Atomic. What are the totals?

import Foundation
import Synchronization
import os

final class A: Sendable { let mutex = Mutex(0); func bump() { mutex.withLock { $0 += 1 } } }
final class B: Sendable { let lock = OSAllocatedUnfairLock(initialState: 0); func bump() { lock.withLock { $0 += 1 } } }
actor C { var n = 0; func bump() { n += 1 } }
final class D: @unchecked Sendable { let q = DispatchQueue(label: "d"); var n = 0; func bump() { q.sync { n += 1 } } }

let a = A(), b = B(), c = C(), d = D()
await withTaskGroup(of: Void.self) { g in
    for _ in 0..<1000 { g.addTask { a.bump(); b.bump(); await c.bump(); d.bump() } }
}
print(a.mutex.withLock { $0 }, b.lock.withLock { $0 }, await c.n, d.q.sync { d.n })

let counter = Atomic<Int>(0)
DispatchQueue.concurrentPerform(iterations: 1000) { _ in counter.wrappingAdd(1, ordering: .relaxed) }
print(counter.load(ordering: .relaxed))
  • 1000 1000 1000 1000 / 1000
  • Each is at most 1000, usually fewer
  • 1000 for the actor only

Mental model. A lock is a key to a single-person bathroom: fast, but you must give it back at once. An actor is a receptionist who handles requests in turn: slower per request, but nobody forgets to return the key.

All four counters in the check reach exactly 1000 after a thousand concurrent increments, so correctness is not what separates them; the choice is about what you need around the protected state. An actor gives compiler-checked isolation and fits state that has its own lifecycle and is accessed from async code; the cost is that every access is an await (a suspension and a hop), and reentrancy means a stretch ends at every await (Q295). A Mutex (the Synchronization module, iOS 18 / Swift 6) or OSAllocatedUnfairLock (os, iOS 16+) protects a short, synchronous critical section with very low overhead and lets the owning class be properly Sendable with no @unchecked, because the lock is the checked proof. Use them for caches, counters and small shared tables read from synchronous code (including non-async callbacks).

An atomic (Atomic<Int>) is for a single integer, flag or pointer with no multi-step invariant. A serial dispatch queue is the older tool: easy to reason about and still the right answer where you already have a queue-based API, but it is not checked by the compiler (so the owner needs @unchecked Sendable), and sync from the wrong place deadlocks. Rules of thumb: never hold a lock across an await (the lock would outlive the stretch and can deadlock; the APIs are designed for synchronous bodies), keep critical sections tiny and free of callouts, and pick the actor when the operation is naturally asynchronous. Measure only if the contention is real: for most app code the actor’s clarity wins.

Every mechanism makes the read-modify-write indivisible; the differences are API, checking and cost.

Common traps

  • Holding a lock across an await. Locks and suspension points do not mix; the compiler prevents it for Mutex bodies.
  • Using an atomic for a multi-field invariant. Two atomics are not one consistent state; use a lock or an actor.

They might follow up with

  • Which has the lowest overhead? An atomic, then an unfair lock or Mutex, then a queue, then an actor, but the difference rarely matters until it is a hot path.
  • Why was os_unfair_lock wrapped? A raw one in a Swift struct can move in memory; OSAllocatedUnfairLock and Mutex allocate or pin the storage correctly.

Related questions

  • 10.10.1 When is a type Sendable, and what does the compiler check when you declare it?
  • 10.10.3 What are @unchecked Sendable and nonisolated(unsafe) promising, and what does the compiler still check when…
  • 10.9.3 How do you define your own global actor, what does annotating a type with it do, and when is it better than…
  • 10.4.1 What does an actor guarantee, and what is actor reentrancy?
  • 10.7.1 How do you find and fix deadlocks, data races and slow main-actor hops?
  • 10.1.2 How do barriers, groups, semaphores and dispatch sources coordinate work?

← When is a type Sendable, and what does the compiler check when you…   What are @unchecked Sendable and nonisolated(unsafe) promising, and… →