The Synchronization Framework contains two low-level concurrency primitives — Mutex and Atomics. Today, we’ll understand why atomics and mutexes are useful, learn how to use them in your own code, and compare their performance to Actors.
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.
Swift 6 and iOS 18 introduced the Synchronization framework, to surprisingly little fanfare. Seriously, barely even a shout-out at dub-dub. I have my work cut out for me.
Synchronization contains two low-level concurrency primitives — Mutex and Atomics.
These features could only be introduced with Swift 6 because they are implemented using the brand-new generic ownership mechanics.
Today, we’ll understand why atomics and mutexes are useful, and learn how to use them in your own code.
Finally, we’ll profile these primitives to understand their performance compared to the high-level synchronization mechanism in Swift Concurrency: Actors.
Synchronization and Ownership
Let’s check out the API signatures for Mutex and Atomic.
@frozen public struct Mutex<Value> : ~Copyable where Value : ~Copyable {
public init(_ initialValue: consuming sending Value)
}Mutex is ~Copyable, and generic over Value, another ~Copyable type. Mutex may also consume its initial value, if it’s noncopyable.
Swift is really getting away from me, I just had to spend 15 minutes working out what the
sendingkeyword did. Is Apple asking Gen Z to pick out keywords?It marks an argument as safe to pass across isolation domains, e.g. when passed into an actor from outside.
@frozen public struct Atomic<Value> where Value : AtomicRepresentable {
public init(_ initialValue: consuming Value)
}Atomic is generic over an AtomicRepresentable, and consumes any noncopyable Value with which it is initialised.
Noncopyable types and Consuming
Noncopyable types were introduced with Swift 5.9. These add Rust-style ownership semantics to Swift code, which offer performance gains for embedded systems programming by eliminating the runtime overhead associated with copying values.
It’s essentially a form of manual memory management.
All types in Swift are copyable by default. Think of it like an implicit protocol conformance to Copyable. This imaginary protocol defines the ability to copy the type’s a value or reference when passed around.
Value types can be declared noncopyable using the ~Copyable anti-conformance. Uh, apparently this is called a negative constraint.
Noncopyable explicitly marks value types which can’t be copied as part of variable assignment or function-argument-passing.
Function parameters marked consuming take ownership of noncopyable values and invalidate the original instance. Attempting to use the original value will result in a compiler error.
With Swift 6, noncopyable types now work with generics, allowing Mutex to work with any type.
We can see this negative constraint on its wrapped Value:
where Value: ~CopyableThis means that Value is not subject to the Copyable constraint. It actually removes the assumption that the value passed in is copyable, so the type inside the Mutex can be either copyable or noncopyable.
Mutex therefore works just as well when initialised with copyable values. In that case, the consuming function parameter doesn’t do anything; you’re still able to use the value elsewhere.
For more on copyable generics, check out Consume noncopyable types in Swift from WWDC 2024.
Now let’s look at these low-level primitives in detail.
Mutex
Mutex is an abbreviation of “mutual exclusion”.
Mutexes prevent access to shared mutable state by restricting access to one thread at a time. This is implemented by locking — the first thread accessing the mutex causes it to lock. Subsequent concurrent threads are blocked, waiting for the mutex to unlock access once the first thread is finished.
This guarantees that threads enjoy exclusive access to the value inside the mutex; much like you can enjoy exclusive access to Quick Hacks if you’re a paid subscriber to Jacob’s Tech Tavern.
Why are mutexes useful?
Let’s create a simple singleton cache in our code.
class Cache {
static let shared = Cache()
private init() {}
private var cache: [Int: Int] = [:]
func get(_ key: Int) -> Int? {
cache[key]
}
func set(_ key: Int, value: Int) {
cache[key] = value
}
}This code includes a common, dangerous risk: simultaneous access to the cache itself by multiple threads. It’s trivial to break this cache:
let cache = Cache.shared
await withTaskGroup(of: Void.self) { group in
for i in 0..<10_000 {
group.addTask {
cache.set(Int.random(in: 0..<100), value: i)
}
}
}This code immediately crashes out with EXC_BAD_ACCESS, with multiple threads calling set simultaneously to write to the dictionary.
Mutex uses a lock which protects your cache from unsafe simultaneous access. We can convert our cache to use the new Mutex:
import Synchronization
final class MutexCache {
static let shared = MutexCache()
private init() {}
private let cache = Mutex([Int: Int]())
func get(int: Int, key: Int) {
cache.withLock {
$0[key]
}
}
func set(key: Int) -> Int? {
cache.withLock {
$0[key] = int
}
}
}It looks largely identical, except our operations on the Mutex are wrapped in withLock to ensure we acquire a lock before touching the cache. This blocks threads which attempt concurrent access, until the Mutex unlocks.
The code in the closure is the “critical section” where mutual exclusion is enforced. Mutex also includes withLockIfAvailable, which attempts to acquire the lock but returns nil if it’s already locked.
extension Mutex where Value : ~Copyable {
public borrowing func withLock<Result, E>(
_ body: (inout sending Value) throws(E) -> sending Result
) throws(E) -> sending Result where E : Error, Result : ~Copyable
public borrowing func withLockIfAvailable<Result, E>(
_ body: (inout sending Value) throws(E) -> sending Result
) throws(E) -> sending Result? where E : Error, Result : ~Copyable
}These functions are borrowing, which means they gain read-only access to self. They borrow ownership of the Mutex and mutate the inout parameter Value.
I’ll do a proper piece on practical Swift ownership once I get my hands on a 264kB RAM Raspberry Pi Pico.
The Critical Section
If you are using Mutex in your projects, the most important thing is to keep the critical section in the withLock closure small, so competing threads aren’t blocked for too long.
Avoid using complicated O(N²) algorithms in this critical section.
Well; maybe that’s the second most important thing: the most important is probably to avoid recursive calls to withLock. This will crash on Apple platforms, but it’s technically undefined behaviour and platform-dependent. You might just end up deadlocking your process.

Mutex vs Actors vs OSAllocatedUnfairLock
Even today, there’s a lot of controversy in the iOS community as to how to spin up a cache. The modern high-level approach might be an actor:
actor ActorCache {
static let shared = ActorCache()
private init() {}
private var cache: [Int: Int] = [:]
func get(_ key: Int) -> Int? {
cache[key]
}
func set(_ key: Int, value: Int) {
cache[key] = value
}
}If you’re on Apple platforms, you’ll have access to OSAllocatedUnfairLock, a low-level locking mechanism evolved from the classical os_unfair_lock.
import os
final class LockCache {
static let shared = LockCache()
private var cache: [Int: Int] = [:]
private let lock = OSAllocatedUnfairLock()
func get(_ key: Int) -> Int? {
lock.lock()
defer { lock.unlock() }
return cache[key]
}
func set(_ key: Int, value: Int) {
lock.lock()
defer { lock.unlock() }
cache[key] = value
}
}With all these options, what should we be defaulting to? Let’s benchmark the three approaches to help inform our decision.
Performance Testing
Let’s measure performance writing to a cache with lots of contention:
// Random list of 10m cache indexes containing 0-50
let indices = (0..<10_000_000).map { _ in Int.random(in: 0..<50) }We’ll create 10 million concurrent operations with a taskGroup and write to each cache.
To track execution time, I’m using DispatchTime and uptimeNanoseconds. I’m capable of learning my lesson.
func logExecutionTime(_ log: String, block: () async -> Void) async {
let start = DispatchTime.now()
await block()
let end = DispatchTime.now()
let diff = Double(end.uptimeNanoseconds - start.uptimeNanoseconds)/1_000_000_000
print(log, String(format: "%.3f", diff))
}Finally, we need to come up with a way to run our code in parallel to ensure a lot of contention. This runs the 10m tasks in parallel, evenly distributed across all active CPU cores:
func processAcrossCores(
name: String,
total: Int = 10_000_000,
operation: @escaping (Int) async -> Void
) async {
await logExecutionTime(name) {
await withTaskGroup(of: Void.self) {
let cores = ProcessInfo.processInfo.activeProcessorCount
let tasksPerCore = total / cores
for i in 0..<cores {
$0.addTask {
let start = i * tasksPerCore
let end = (i + 1) * tasksPerCore
for j in start..<end {
await operation(j)
}
}
}
}
}
}A special shoutout to Dave Poirier who pointed out the problem with my initial approach—I created 10m tasks in a taskGroup, leading to most of the running time being taken up by concurrency-related overhead rather than the actual locking mechanisms.
Here’s the code I used, you’re welcome to give it a try yourself.
import Synchronization
let mutexCache = MutexCache.shared
let actorCache = ActorCache.shared
let lockCache = LockCache.shared
for total in [
1000,
10_000,
100_000,
1_000_000,
10_000_000
] {
// Control to test the time for 10m 'empty' operations
await processAcrossCores(name: "Control", total: total) { _ in }
await processAcrossCores(name: "Actor", total: total) { index in
await actorCache.set(indices[index], value: index)
}
await processAcrossCores(name: "Lock", total: total) { index in
lockCache.set(indices[index], value: index)
}
await processAcrossCores(name: "Mutex", total: total) { index in
mutexCache.cache(int: indices[index], key: index)
}
}



