Jacob’s Tech Tavern

Jacob’s Tech Tavern

The Synchronization Framework in Swift 6

Performance testing the new concurrency primitives

Jacob Bartlett's avatar
Jacob Bartlett
Dec 30, 2024

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 sending keyword 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: ~Copyable

This 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.

It’s easy to crash your app when locking your Mutex recursively

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)
    }
}

Subscribe for free to continue reading

Enter your email and get every post from Jacob’s Tech Tavern.
Already have an account? Sign in
© 2026 Jacob Bartlett · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture