Skip to content
ivanmagda.dev
Go back

Swift synchronization benchmarks: Mutex, locks, queues, atomics, and actors on real iPhones

Suggest Changes

Over my years as an iOS developer I’ve kept running into advice about which synchronization mechanism is the fast one. If you need performance, use os_unfair_lock. If reads far outnumber writes, look at a read-write lock or a concurrent queue with barrier writes. And whenever Swift Concurrency comes up, someone says that actors are slow and lose to plain locks.

I wanted to see how well that advice holds up in practice, so I compared synchronization mechanisms on tasks that resemble what real apps do. I also wanted to find out whether one mechanism can be the default choice for most of them.

I started from two articles, by Vadim Bulavin and Marin Todorov, that compare synchronization mechanisms on simple operations. New APIs have appeared since then, including Synchronization.Mutex and Atomic. I wanted to repeat the baseline comparisons with today’s tools and then check how much the choice of mechanism affects the speed of everyday app tasks.

To choose the tasks, I looked at how popular open source libraries and apps synchronize access to state: SDWebImage, Alamofire, Apollo iOS, Nuke, Firebase, and Pocket Casts. One example is how image-loading libraries protect their cache and keep track of active requests. From those examples I built a benchmark that runs the same work with different synchronization mechanisms and compares the results.

I ran every measurement in Release builds on two physical devices: an iPhone 11 Pro Max and an iPhone 15 Pro Max.

For each experiment I’ll describe the task, what exactly I measured, and what the results show. At the end I’ll pull the conclusions together: when Mutex is enough, where Atomic makes sense, and what to keep in mind when working with actors.

Fair warning: this article is long. It’s a full study, and I couldn’t shorten it without losing something. If you’re short on time, you don’t have to read it from start to finish: pick the experiment you care about, or jump straight to the conclusion. Each section stands on its own.

In this article

How I measured

iOS versions on the devices:

DeviceiOS version
iPhone 15 Pro Max26.6.2
iPhone 11 Pro Max26.6

I built the benchmark with Xcode 27.1 and Swift 6.4.0 in the Release configuration. I’ll show the main results for the iPhone 15 Pro Max and call out where the iPhone 11 Pro Max differs. The charts are interactive: each one switches between the two phones, and pointing at a row shows its ratio to Mutex and the spread across launches.

In each experiment I compared implementations of one task that differ only in the synchronization mechanism. In the counter test, for example, one operation increments a value by one. The measured time includes both the access to the protected state and the work done on it.

I launched each test several times. Within one launch the app repeated the measured operation many times, in some tests millions of times. That gave me the average time of short operations, and the repeated launches showed how stable the result was. Across the two phones, that added up to 1,366 launches.

Time per operation

For short operations I timed a whole batch and divided by the number of operations. Here is a simplified example with a counter:

import Synchronization

let counter = Mutex<Int>(0)
let iterations = 1_000_000
let clock = ContinuousClock()

let elapsed = clock.measure {
    for _ in 0..<iterations {
        counter.withLock { value in
            value += 1
        }
    }
}

let timePerOperation = elapsed / iterations

The loop increments the counter a million times. Each increment acquires the lock, changes the value, and releases the lock. Dividing the loop time by a million gives the average time of one operation: if the loop took 2 ms, that’s 2 ns per operation.

Each app launch measured several such batches. I took the median across batches, then the median across launches. That value is what the tables and charts show.

Say five batches gave 1.9, 2.0, 2.1, 2.2, and 5.0 ns per operation. The values are already sorted, so the median is the middle one, 2.1 ns. That becomes the result of the launch.

Operations per second and the latency of individual calls

With several threads, I also measured how many operations they completed per second in total. I added up the operations of all threads and divided by the time from the first thread’s start to the last thread’s finish. Higher is better. Time per operation shows how long one operation takes on one thread; operations per second shows how much work all the threads get done together.

Suppose four threads each complete a million operations, and half a second passes between the first start and the last finish. That’s 4 million operations in 0.5 seconds, or 8 million operations per second.

To assess latency, I timed individual calls and summarized them with two numbers:

I computed both for each launch separately and took the median across launches for the final comparison.

The source code and the measurement method are in the repository. The raw results are published separately.

Baseline: read, write, counter

I’ll start with the question Vadim Bulavin and Marin Todorov asked: how long does the simplest operation on shared state take under each synchronization mechanism? The shared state is deliberately trivial: a single integer. There are three operations, each measured separately:

A single thread performs all the operations (a single Task in the actor case). I’ll call this mode “uncontended” from here on. The synchronization is still there: every operation acquires and releases the lock. No other thread holds it at that moment, so there’s nothing to wait for.

Apps do this all the time: a background thread occasionally updates a download state, and the main thread occasionally reads it.

What I measured

The Mutex increment appeared in the example above. For the other mechanisms, only the protection changes. Here is the same operation, simplified, for three of them. With Atomic, the increment is a single atomic operation:

let counter = Atomic<Int>(0)

counter.wrappingAdd(1, ordering: .acquiringAndReleasing)

With a serial queue, every access goes through sync:

let queue = DispatchQueue(label: "counter")
var value = 0

queue.sync {
    value += 1
}

With an actor, every access is a separate await call from a background task:

actor Counter {
    private var value = 0

    func increment() -> Int {
        value += 1
        return value
    }
}

let counter = Counter()

let newValue = await counter.increment()

The actor method returns the new value, and the loop uses it. Otherwise the compiler optimizes the loop, and the result no longer reflects an ordinary await call from another task.

The comparison covers 20 variants. The full list and the code for each are in the repository.

Uncontended results

Locks and Atomic come first. The chart shows the time of one counter increment; shorter bars are faster.

The actor and the queues don’t fit on this scale: they take tens of times longer. The switch above the chart adds them to the same axis.

Time of one counter increment for the locks and Atomic. Mutex, OSAllocatedUnfairLock, os_unfair_lock, and Atomic take 1.9 ns each, DispatchSemaphore 3.7 ns, pthread_mutex 4.6 ns, pthread_rwlock 6.0 ns, NSLock 6.4 ns, OSAllocatedUnfairLock with adaptiveSpin 8.5 ns, objc_sync 10.9 ns, NSRecursiveLock 12.7 ns. With the actor and the queues added: actor 62.8 ns, serial DispatchQueue 88.2 ns, concurrent queue with an async barrier 110.4 ns, concurrent queue with a sync barrier 209.6 ns.

View data: uncontended read, write, and counter
MechanismRead, nsWrite, nsCounter, nsCounter relative to Mutex
Mutex1.851.851.861.0×
OSAllocatedUnfairLock1.861.861.861.0×
os_unfair_lock1.861.861.851.0×
pthread_mutex4.594.604.622.5×
NSLock6.366.366.363.4×
OSAllocatedUnfairLock with .adaptiveSpin8.488.228.494.6×
Atomic0.270.271.881.0×
actor62.6462.0362.7734×
Serial DispatchQueue199.42107.4788.2348×

All 20 variants:

MechanismRead, nsWrite, nsCounter, ns
Mutex1.851.851.86
OSAllocatedUnfairLock1.861.861.86
os_unfair_lock1.861.861.85
DispatchSemaphore3.723.723.74
pthread_mutex4.594.604.62
pthread_rwlock6.146.076.00
NSLock6.366.366.36
OSAllocatedUnfairLock with .adaptiveSpin8.488.228.49
objc_sync_enter / objc_sync_exit10.8810.8710.87
NSRecursiveLock12.7212.7212.72
Atomic, acquiring/releasing0.2650.2651.88
Atomic, relaxed0.2650.2651.88
ManagedAtomic, acquiring/releasing0.7950.7951.86
ManagedAtomic, relaxed0.7950.7951.88
actor62.6462.0362.77
@MainActor from a background task5,7705,7695,731
Serial DispatchQueue199.42107.4788.23
Concurrent queue, writes via sync(flags: .barrier)204.76210.56209.61
Concurrent queue, writes via async(flags: .barrier)204.94115.66110.42
No synchronization0.2650.2650.83

The “No synchronization” row shows how long the loop and the memory access take on their own. I measured it on one thread only; it doesn’t protect the state. For writes via async(flags: .barrier) the measured time includes waiting for the queue to run every submitted operation. I’ll come back to @MainActor in the section on actors.

Mutex, OSAllocatedUnfairLock, and os_unfair_lock gave the same result. All three take about 1.86 ns per operation; the differences show up only in the third decimal place. That’s expected: on Apple platforms, Mutex and OSAllocatedUnfairLock are built on the same os_unfair_lock. So there’s no speed reason to use os_unfair_lock with manual lock() and unlock() calls. Mutex is just as fast.

pthread_mutex and NSLock are slower, but only by nanoseconds. pthread_mutex takes 4.6 ns per operation and NSLock 6.4 ns, which is 2.5 and 3.4 times as long as Mutex. In absolute terms, though, NSLock adds about 4.5 ns per operation. If an app takes the lock 10,000 times a second, the whole difference adds up to 45 µs in that second. So the 3.4× ratio doesn’t mean the app will get faster once you replace NSLock with Mutex.

Atomic is the fastest at reads and writes and ties with Mutex on the counter. A read or a write takes 0.27 ns, seven times faster than Mutex. An increment takes 1.88 ns, the same as under a lock: it’s a single atomic operation, but a costlier one than a plain read or write.

The actor and the queues are tens of times more expensive. A call to the actor takes about 63 ns for any of the three operations, 34 times as long as a counter increment under Mutex. The serial queue takes 88 to 199 ns, depending on the operation. The operation itself is tiny, so these numbers mostly show the cost of calling into the actor or the queue.

In Marin Todorov’s benchmark the actor came out slower than the serial queue; in mine it’s the other way around. The main reason, I think, is where the actor is called from. In his test, the Task is created in a SwiftUI button handler and inherits MainActor isolation, so every call moves execution from the main thread to the actor and back. In my test, a background task calls the actor, and the main thread isn’t involved. The section on actors shows what a call from MainActor costs.

There’s also one result I can’t explain: a read through the serial queue took 199 ns and a counter increment 88 ns, although both go through queue.sync.

Two threads on one counter

Now two threads increment the same counter at the same time. Here I compare operations per second across both threads.

Counter increments per second with one and two threads. Mutex: 539 and 102 million, OSAllocatedUnfairLock: 539 and 99 million, pthread_mutex: 216 and 22 million, NSLock: 157 and 15 million, Atomic: 532 and 310 million.

View data: one thread against two threads
MechanismOne thread, M ops/sTwo threads, M ops/s
Mutex539102
OSAllocatedUnfairLock53999
pthread_mutex21622
NSLock15715
Atomic532310

Throughput drops for every mechanism: about fivefold for Mutex and by a factor of 1.7 for Atomic. With two threads, Atomic completes three times as many operations per second as Mutex, even though they were equal on one thread.

The benchmark also has six- and twelve-thread variants, described in the repository.

iPhone 11 Pro Max

The overall picture is the same on the older phone. Mutex, OSAllocatedUnfairLock, and os_unfair_lock are again indistinguishable at about 9.5 ns. The actor takes about 65 ns, and the serial queue 199 ns on the counter.

There are three differences:

Takeaway

For a short synchronous operation on shared state, start with Mutex. It’s as fast as os_unfair_lock and needs no manual lock() and unlock() calls.

Atomic is worth considering when one atomic operation is enough: a read, a replacement of the value, or a counter increment. There it’s no slower than Mutex when uncontended and clearly faster when two threads compete for the counter.

Calling an actor or a queue for every tiny operation is expensive: the call takes tens of times longer than acquiring a lock.

In real apps, though, a lock usually protects more than a single number. In the next section, several threads work with a shared dictionary.

Synchronizing access to a dictionary

Now the lock protects a dictionary that maps identifiers to request objects. Each new request is added to the dictionary and removed when it finishes. Sometimes all the remaining requests have to be canceled at once.

Networking and image-loading libraries deal with this task. I modeled the experiment on runningOperations in SDWebImage and RequestTaskMap in Alamofire. What I test here is my own implementation of a similar task; the benchmark makes no real network requests.

In the test, several threads use the dictionary at the same time. Each thread alternately removes an old request and adds a new one, doing a little work outside the lock between accesses. One of the threads also cancels every request and clears the dictionary after every 1,024 of its own accesses.

I tested two modes:

Each mode runs with four and with eight threads. Eight is more than the phone’s six cores can run at once.

There are two metrics. Call time is how long one add or remove takes, including the wait for the lock; I report its p50 and p99. Operations per second counts all threads together and includes the work outside the lock and the pauses.

What I measured

Here is the dictionary protected with Mutex:

final class MutexRegistry {
    private let downloads = Mutex<[Int: Download]>([:])

    func add(_ download: Download, id: Int) {
        downloads.withLock { downloads in
            downloads[id] = download
        }
    }

    func remove(id: Int) -> Download? {
        downloads.withLock { downloads in
            downloads.removeValue(forKey: id)
        }
    }

    func cancelAll() -> Int {
        downloads.withLock { downloads in
            let count = downloads.count
            for download in downloads.values {
                download.cancel()
            }
            downloads.removeAll(keepingCapacity: true)
            return count
        }
    }
}

The comparison covers ten mechanisms: the locks from the previous section and a serial queue. One variant needs a word of explanation: OSAllocatedUnfairLock can be acquired with the .adaptiveSpin flag, available since iOS 18:

downloads.withLock(flags: .adaptiveSpin) { downloads in
    downloads[id] = download
}

With this flag, a thread that fails to get the lock doesn’t go to sleep right away. It first spins briefly while the lock’s owner is running on another core. In the previous section, this variant was 4.6 times slower than the plain lock. Here the result is different.

The code for all variants is in the repository.

With pauses between accesses

When the threads pause, the choice of mechanism barely affects overall throughput. With four threads, all ten variants complete between 57.3 and 57.8 thousand operations per second, a spread of less than 1%. With eight threads the picture is the same: 110.1 to 110.8 thousand.

Individual call times do differ. With four threads, Mutex has a p99 of 0.125 µs and the serial queue 3.75 µs, 30 times as much. But the threads spend nearly all their time pausing and working outside the lock, so the difference doesn’t affect the overall result.

Without pauses

Without pauses, the threads compete for the lock constantly, and the differences between mechanisms become visible. The results below are for eight threads. Operations per second come first; longer bars are faster.

Dictionary operations per second with eight threads and no pauses. OSAllocatedUnfairLock with adaptiveSpin 1.12 million, OSAllocatedUnfairLock 1.08 million, Mutex 1.06 million, os_unfair_lock 1.04 million, objc_sync 0.99 million, NSRecursiveLock 0.91 million, NSLock 0.89 million, pthread_mutex 0.89 million, DispatchSemaphore 0.20 million, serial DispatchQueue 0.18 million.

Operations per second doesn’t show how often a single call gets stuck waiting. The p99 of call time does, and here shorter bars are better.

p99 of call time with eight threads and no pauses. OSAllocatedUnfairLock with adaptiveSpin 2.1 µs, Mutex 19.1 µs, OSAllocatedUnfairLock 22.5 µs, DispatchSemaphore 47.3 µs, NSRecursiveLock 50.4 µs, pthread_mutex 51.7 µs, NSLock 51.8 µs, serial DispatchQueue 64.1 µs, os_unfair_lock 67.0 µs, objc_sync 68.6 µs.

View data: dictionary with eight threads and no pauses
MechanismCall p50, µsCall p99, µsM ops/s
OSAllocatedUnfairLock with .adaptiveSpin0.422.081.12
OSAllocatedUnfairLock0.2922.541.08
Mutex0.2919.061.06
os_unfair_lock0.3867.021.04
objc_sync_enter / objc_sync_exit0.5068.610.99
NSRecursiveLock0.3350.430.91
NSLock0.3351.790.89
pthread_mutex0.3851.690.89
DispatchSemaphore38.4647.270.20
Serial DispatchQueue40.0664.100.18

The serial queue and DispatchSemaphore fall behind by a factor of five to six. They complete 0.18 and 0.20 million operations per second versus 1.06 million for Mutex. A typical call through the queue takes 40 µs; under Mutex it takes 0.29 µs.

NSLock and pthread_mutex trail Mutex by about 15%. Both complete 0.89 million operations per second, with a p99 of 52 µs versus 19 µs for Mutex.

Once again, os_unfair_lock offered no advantage. Its throughput is almost the same as that of Mutex, and its p99 is three and a half times higher: 67 versus 19 µs.

The .adaptiveSpin flag shortens the rare long calls. Compared with Mutex, p99 fell from 19 to 2 µs, and operations per second rose by about 6%. A typical call got slower, though: p50 went from 0.29 to 0.42 µs. The result with four threads is similar: p99 dropped from 6 to 1.6 µs.

With eight threads, canceling all requests typically takes 25 µs with Mutex, 14 µs with .adaptiveSpin, and 42 µs with the serial queue.

iPhone 11 Pro Max

The older phone shows the same picture. With pauses and four threads, every mechanism completes between 55.5 and 56.5 thousand operations per second. Without pauses and with eight threads, the serial queue is five times slower than Mutex: 0.14 versus 0.74 million operations per second. The .adaptiveSpin flag lowers p99 from 48 to 2.9 µs and adds about 5% to throughput.

Takeaway

Mutex is a good choice for a dictionary too. Among the ordinary locks, it was near the top in both operations per second and p99.

If the threads are busy with other work between dictionary accesses, the synchronization mechanism barely affects the overall result.

When accesses are continuous, the serial queue and DispatchSemaphore lose to the locks by a wide margin. And the .adaptiveSpin flag, which lost on the simple counter, cut the rare long calls ninefold here. It’s worth testing when the slowest calls matter.

In the next section, the state is simple again: a single flag that is read often and changed rarely.

What it costs to read a feature flag

Picture a feature flag that decides which branch of code runs. The app checks its value often and updates it far less often. If the reads and the writes happen on different threads, access to the flag has to be synchronized.

Almost every app has flags like this. An interesting case: in Pocket Casts, every such flag used to create its own serial queue and call sync on each read, until the queue was replaced with a lock. In SDWebImage, the isCancelled flag is protected with @synchronized.

In the test, a background thread reads the flag continuously while the main thread toggles it about every 10 ms. I compare the time of a single read.

What I measured

Reading the flag under Mutex:

let isFeatureEnabled = Mutex<Bool>(false)

let enabled = isFeatureEnabled.withLock { value in
    value
}

The same read with Atomic:

let isFeatureEnabled = Atomic<Bool>(false)

let enabled = isFeatureEnabled.load(ordering: .acquiring)

The comparison covers 17 variants; the code is in the repository.

Results

Atomic and the locks come first. The chart shows the time of one read; shorter bars are faster.

The queues don’t fit on this scale either. The switch above the chart adds them.

Flag read time for Atomic and the locks. Atomic 0.3 ns, ManagedAtomic 0.8 ns, Mutex, OSAllocatedUnfairLock, and os_unfair_lock 1.9 ns each, DispatchSemaphore 3.7 ns, pthread_mutex 4.6 ns, pthread_rwlock 6.1 ns, NSLock 6.5 ns, OSAllocatedUnfairLock with adaptiveSpin 8.9 ns, objc_sync 11.2 ns, NSRecursiveLock 13.2 ns. With the queues added: serial DispatchQueue 202.8 ns, concurrent queue with an async barrier 207.3 ns, concurrent queue with a sync barrier 207.9 ns.

View data: flag read time
MechanismRead, nsRelative to Mutex
Atomic0.320.2×
ManagedAtomic0.800.4×
Mutex1.861.0×
OSAllocatedUnfairLock1.861.0×
os_unfair_lock1.861.0×
DispatchSemaphore3.742.0×
pthread_mutex4.562.5×
pthread_rwlock6.103.3×
NSLock6.473.5×
OSAllocatedUnfairLock with .adaptiveSpin8.894.8×
objc_sync_enter / objc_sync_exit11.246.0×
NSRecursiveLock13.207.1×
Serial DispatchQueue202.78109×
Concurrent queue, writes via async(flags: .barrier)207.31111×
Concurrent queue, writes via sync(flags: .barrier)207.85112×

Atomic reads the flag fastest. One read takes 0.32 ns, almost six times faster than Mutex. In the first experiment, nothing changed the value during the reads. Here another thread does, and Atomic keeps its advantage. ManagedAtomic from the swift-atomics package, which also works before iOS 18, beats the locks as well: 0.80 ns.

Mutex, OSAllocatedUnfairLock, and os_unfair_lock are tied. All three take 1.86 ns per read; NSLock takes 6.5 ns.

pthread_rwlock didn’t help. A read under it takes 6.1 ns, three times as long as under Mutex. There is only one reading thread here, so no reads can run in parallel.

A read through a queue takes a hundred times as long as under Mutex. With the serial queue it takes 203 ns, with a concurrent queue about 207 ns.

To get a sense of scale, multiply these numbers by the read rate. If the flag is read 10,000 times a second, all the reads in that second take 3 µs with Atomic, 19 µs with Mutex, and 2 ms with the serial queue.

iPhone 11 Pro Max

The picture is the same on the older phone: Atomic 0.61 ns, Mutex 9.1 ns, serial queue 407 ns. Memory ordering matters for Atomic on this phone: a relaxed read takes 0.46 ns, a quarter less than an acquiring one. On the iPhone 15 Pro Max, the two gave the same result.

Takeaway

For a flag that is read often, Atomic is the fastest option.

Mutex is still a good choice: the difference from Atomic is 1.5 ns per read.

Creating a queue to read a flag is expensive. A read through it takes a hundred times as long as under Mutex. This is the design Pocket Casts moved away from.

Next, several threads read at once.

Many reads, few writes: do you need a read-write lock?

When data is mostly read, an ordinary lock looks like a needless restriction: while one thread reads, the others wait, even though nothing is changing. That leads to the familiar answer: a read-write lock or a concurrent queue with a barrier. With those, several threads can read at once, while writes run one at a time.

Well-known libraries do this too. In Apollo iOS 1.x, the cache is read through a concurrent queue and written with async(flags: .barrier). In swift-log, the LoggingSystem settings are read under a pthread_rwlock.

The feature flag section had a single reading thread, so nothing could read in parallel. Now there are six threads working with an array of 10,000 records: 98% of the operations read data and 2% change it. Let’s see whether that ratio is enough for a read-write lock to beat Mutex.

What I measured

I tested two variants. In the first, each operation reads or changes one record. In the second, it works with a group of twenty records at once. That makes each read longer, so the threads’ reads overlap more often. If a read-write lock can beat Mutex anywhere, it’s here.

There are two metrics: operations per second across all threads, and the p99 of write time.

Reading a group under Mutex:

let records = Mutex<[Record]>(initialRecords)

let result = records.withLock { records in
    records.readGroup(group, size: groupSize)
}

A write looks the same, except that it calls writeGroup instead of readGroup.

With pthread_rwlock, reads and writes acquire the lock differently:

// Read: several threads can read at once
pthread_rwlock_rdlock(lock)
let result = records.readGroup(group, size: groupSize)
pthread_rwlock_unlock(lock)

// Write: the thread has the data to itself
pthread_rwlock_wrlock(lock)
records.writeGroup(group, size: groupSize)
pthread_rwlock_unlock(lock)

With a concurrent queue, reads go through sync and writes through sync with the .barrier flag:

let queue = DispatchQueue(label: "store", attributes: .concurrent)

let result = queue.sync {
    records.readGroup(group, size: groupSize)
}

queue.sync(flags: .barrier) {
    records.writeGroup(group, size: groupSize)
}

A second queue variant submits the write with async(flags: .barrier). In that variant, a write counts as done when its block has run, not when the call returns.

The comparison covers 13 variants; the code is in the repository.

Results

First, operations per second for groups of twenty records; longer bars are faster.

Operations per second with six threads and groups of twenty records. OSAllocatedUnfairLock 9.50 million, Mutex 9.37 million, pthread_mutex 9.29 million, os_unfair_lock 8.60 million, NSLock 8.05 million, NSRecursiveLock 7.14 million, OSAllocatedUnfairLock with adaptiveSpin 5.80 million, pthread_rwlock 3.07 million, objc_sync 2.89 million, concurrent queue with a sync barrier 1.60 million, concurrent queue with an async barrier 1.47 million, DispatchSemaphore 1.05 million, serial DispatchQueue 0.45 million.

Writes make up only 2% of the operations, but they need access to the data too. The p99 of write time shows how long they wait, measured from the call to the moment the data has changed. Here shorter bars are better.

p99 of write time with six threads and groups of twenty records. OSAllocatedUnfairLock with adaptiveSpin 6.2 µs, os_unfair_lock 6.4 µs, Mutex 8.0 µs, DispatchSemaphore 8.0 µs, OSAllocatedUnfairLock 8.3 µs, objc_sync 10.7 µs, pthread_mutex 14.9 µs, NSLock 16.5 µs, NSRecursiveLock 16.9 µs, pthread_rwlock 21.8 µs, serial DispatchQueue 27.3 µs, concurrent queue with a sync barrier 29.6 µs, concurrent queue with an async barrier 33.7 µs.

View data: store with 98% reads
MechanismOne record, M ops/sTwenty records, M ops/sTwenty records, write p99, µs
OSAllocatedUnfairLock34.679.508.29
Mutex34.819.377.96
pthread_mutex35.109.2914.94
os_unfair_lock33.358.606.44
NSLock22.488.0516.52
NSRecursiveLock14.627.1416.85
OSAllocatedUnfairLock with .adaptiveSpin10.405.806.21
pthread_rwlock3.883.0721.77
objc_sync_enter / objc_sync_exit3.122.8910.67
Concurrent queue, writes via sync(flags: .barrier)1.691.6029.56
Concurrent queue, writes via async(flags: .barrier)2.091.4733.69
DispatchSemaphore1.101.058.02
Serial DispatchQueue0.460.4527.31

pthread_rwlock is slower than the ordinary locks. With groups of twenty records it completes 3.07 million operations per second, a third of the 9.37 million that Mutex manages. With one record the gap is ninefold: 3.88 versus 34.81 million. Reads make up 98% of the operations, yet reading in parallel didn’t pay off.

The most likely reason is how short the operations are. pthread_rwlock is more expensive in itself: in the flag section, a read under it took 6.1 ns versus 1.86 ns under Mutex. Reading twenty records from memory is too short for parallel reads to make up that difference.

A concurrent queue with a barrier is slower still. With groups of twenty records it completes 1.60 million operations per second with sync(flags: .barrier) and 1.47 million with async(flags: .barrier), roughly a sixth of what Mutex does. Replacing sync with async gives no consistent gain: async is faster with one record (2.09 versus 1.69 million) and slower with twenty.

Mutex, OSAllocatedUnfairLock, and pthread_mutex are neck and neck. With twenty records they complete 9.29 to 9.50 million operations per second, and with one record 34.67 to 35.10 million. NSLock falls behind: 8.05 and 22.48 million.

Writes wait longest in the queues and under pthread_rwlock. The write p99 is 8.0 µs for Mutex, 21.8 µs for pthread_rwlock, and for the concurrent queue 29.6 µs with sync and 33.7 µs with async. pthread_mutex matches Mutex in throughput, but its write p99 is almost twice as high: 14.9 µs.

The .adaptiveSpin flag didn’t pay off here. In the dictionary section it cut p99 ninefold. Here the write p99 fell from 8.3 to 6.2 µs, but operations per second dropped from 9.50 to 5.80 million.

iPhone 11 Pro Max

On the older phone, Mutex is still faster than pthread_rwlock and the queues, but the gap is smaller. With twenty records, pthread_rwlock completes 3.45 million operations per second versus 4.26 million for Mutex: 19% behind instead of a factor of three. The concurrent queue with a barrier is more than three times slower than Mutex here. With one record, pthread_rwlock is almost four times slower: 3.99 versus 15.59 million.

Takeaway

A high share of reads isn’t reason enough to reach for a read-write lock or a concurrent queue with a barrier. On short in-memory operations, Mutex was faster than both.

The operations in this test are short. If a read under the lock takes a long time, parallel reads may pay off, and that’s worth measuring in your own code.

Next, we turn to asynchronous code and compare actors with locks.

Are actors really slow?

“Actors are slow, use a lock.” The first experiment seems to support that advice: there, a call to an actor costs 34 times as much as acquiring a Mutex. But I tested only one way of calling the actor: a single background task calling it in a loop.

In an app, actors get called in different ways: from the main thread, from dozens of tasks at once, with one operation per await or a hundred.

And more and more code is built on actors, with entire libraries moving to them. Nuke 13 replaced its internal serial queue with a global actor, ImagePipelineActor. In Apollo iOS 2.0, the cache is protected by an asynchronous read-write lock that is itself written as an actor.

I’ll start with the question left over from the first experiment. In Marin Todorov’s benchmark the actor was slower than the serial queue; in mine it was the other way around. My guess was MainActor: in his test, the actor was called from the main thread. Now I can test that guess.

What I measured

The counter is the same as in the first experiment. Only the way it’s called changes.

actor CounterActor {
    private var value = 0

    func increment() {
        value &+= 1
    }
}

It can be called from a background task:

Task.detached {
    for _ in 0..<count {
        await counter.increment()
    }
}

Or from MainActor. The loop is the same, but after every await, execution returns to the main thread:

@MainActor
func incrementFromMainActor() async {
    for _ in 0..<count {
        await counter.increment()
    }
}

For comparison, here is the same counter under Mutex. Even in asynchronous code, a lock is acquired synchronously, with no await:

let value = Mutex(0)

value.withLock { $0 &+= 1 }

The code for all variants is in the repository.

Where the actor is called from

The chart shows the time of one counter increment for each way of calling; shorter bars are faster.

Time of one counter increment. Mutex 1.9 ns, actor called from inside the actor 8.8 ns, actor called from a background task 52.6 ns, actor called from MainActor 4,891 ns, MainActor called from a background task 5,723 ns.

View data: ways of calling the actor
How the counter is incrementedTime per operation
Mutex, from any thread1.86 ns
Actor, code already running on it8.77 ns
Actor, called from a background task52.61 ns
Actor, called from MainActor4.89 µs
MainActor, called from a background task5.72 µs

The guess held up: a call from the main thread costs 93 times as much. A background task spends 52.6 ns on one call to the actor. Code on MainActor spends 4.89 µs, or 4,891 ns. The counter and the actor are the same; only the call site changed. The opposite direction looks similar: when a background task updates state on MainActor, one call takes 5.72 µs.

That’s enough to explain Todorov’s result. In the first experiment, an operation on the serial queue took 88 to 199 ns. A call to the actor from a background task is faster than that; a call from MainActor is tens of times slower.

For a sense of scale: a hundred calls in a row from MainActor to an actor take about 0.5 ms. A frame at 120 Hz lasts 8.3 ms.

The animation below traces the calls. Switch between them to see where the time goes.

Three ways to update a counter in an actor. Called from a background task, each await suspends the task and runs on the actor: 52.6 ns per call. Called from MainActor, each await leaves the main thread and comes back: 4.89 µs per call. With a hundred updates in one await the call is paid once: 1.23 ns per update.

Is it the actor or the main thread?

A test with no actor at all answers that. MainActor code calls an ordinary short async function, and I check which thread its body runs on and how long the call takes:

nonisolated func work() async { … }

@concurrent
func concurrentWork() async { … }

I built the first function with two sets of project settings, so there are three variants:

VariantWhere the function ranTime per call
nonisolated async, new-project settings in Xcode 27: Approachable Concurrency and default MainActor isolationOn the main thread8.82 ns
nonisolated async, Swift 6 without Approachable ConcurrencyOff the main thread5.58 µs
@concurrent, new-project settings in Xcode 27Off the main thread5.60 µs

iPhone 15 Pro Max, iOS 26.6.2, Release build. Median of 12 launches.

The cost is in the thread switch, with or without an actor. The @concurrent function ran off the main thread, and each call took 5.60 µs. That’s even slightly more than a call to an actor from MainActor (4.89 µs), although no actor is involved. So about 5 µs goes to switching from the main thread to a background thread and back.

With new-project settings, a nonisolated async function stays on the main thread. Called from MainActor, the nonisolated async function stayed on the main thread in every call, and each call took 8.8 ns. In Swift 6 mode without Approachable Concurrency, the same function ran off the main thread at 5.58 µs per call. SE-0461 describes the rules.

The fast call in the first row doesn’t make that variant better: any heavy work in it stays on the main thread too.

That settles the main thread. But even without it, a call to an actor costs 52.6 ns, 28 times as much as acquiring a Mutex. And that’s a single task with nothing in its way. What happens with many tasks?

50 tasks on one counter

Fifty tasks call one counter at the same time, 1,000 calls each. Here I time every call individually and look at p99.

p99 of call time with 50 tasks. OSAllocatedUnfairLock 6.0 µs, Mutex 6.3 µs, NSLock 11.4 µs, actor 24.9 µs.

The gap remains under load. Mutex and OSAllocatedUnfairLock have a p99 of about 6 µs, NSLock 11.4 µs, and the actor 24.9 µs. The typical call differs too: the actor’s median is 19 µs, while the locks’ is under 0.1 µs. At every await, a task yields to the others and waits its turn. With a lock, it usually reaches the counter right away.

In all of these tests, a single call to an actor costs more than acquiring a lock. But we pay per call, not for the work inside it. That leads to the last check: what if we call less often and do more per call?

A hundred updates in one await

The state here is an array of 128 counters. In the first variant, every update gets its own await; in the second, one call performs a hundred updates:

actor CounterGroupActor {
    private var counters = [Int](repeating: 0, count: 128)

    func incrementOne(at index: Int) {
        counters[index % 128] &+= 1
    }

    func incrementGroup(startingAt start: Int) {
        for index in start..<(start + 100) {
            counters[index % 128] &+= 1
        }
    }
}

Here is how it looks at the call site. The first variant makes a hundred calls:

for index in 0..<100 {
    await counters.incrementOne(at: index)
}

The second makes one:

await counters.incrementGroup(startingAt: 0)

The work is the same in both: a hundred separate increments in the array, not one addition of 100.

Time per update. Actor with an await per update 58.08 ns, actor with a hundred updates in one await 1.23 ns, Mutex with a hundred updates under one lock 0.69 ns.

Batching made the actor 47 times faster. With an await per update, one update takes 58.1 ns; in a batch of a hundred, it takes 1.23 ns. Mutex with the same batching is still faster at 0.69 ns. But the gap between them is now half a nanosecond per update, down from about 50 ns without batching.

Batching isn’t always an option: while the actor runs a hundred updates, every other call to it waits.

iPhone 11 Pro Max

The older phone shows the same picture. A call to the actor takes 64.6 ns from a background task and 6.0 µs from MainActor. Batching makes the actor 49 times faster: from 80.2 to 1.63 ns per update. With 50 tasks, the actor’s p99 is 35.4 µs versus 9.4 µs for Mutex. One thing differs: NSLock has a p99 of 33.5 µs here, almost as high as the actor’s.

Takeaway

Actors aren’t slow in themselves. The cost of a call depends on where it comes from and how much work it does.

The most expensive part is switching between the main thread and a background thread: about 5 µs for the round trip, with or without an actor. If that happens in a loop, look there first.

If state already lives in an actor, hand it related operations in a single call. In the test, that made the actor 47 times faster.

For a short synchronous operation, Mutex remains the cheapest option, even in asynchronous code. The two work well together: in Nuke 13, an actor manages the tasks, while small state that has to be read synchronously is still protected by a lock.

In the next section, the main thread reads an image cache during scrolling.

An image cache during scrolling

“Don’t block the main thread.” Every iOS developer knows the rule. And yet image caches are read on the main thread: cellForItemAt pulls an image out of memory while background threads put new ones in. So during scrolling, the main thread takes a lock itself and occasionally waits for a background thread to release it.

Nuke, for example, works this way: LazyImageView checks the cache on the main thread before starting a load, and the cache itself is protected by an os_unfair_lock. In SDWebImage, part of the memory cache also sits behind an os_unfair_lock.

I wanted to know two things. How long does such a read take with different synchronization mechanisms? And is it noticeable during scrolling?

What I measured

The test uses a real UICollectionView: a four-column grid scrolled at a constant speed by a CADisplayLink. When UIKit asks for a cell, its image comes from the cache:

func collectionView(_ collectionView: UICollectionView,
                    cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(
        withReuseIdentifier: "image", for: indexPath) as! ImageCell
    cell.imageView.image = cache.image(for: key(at: indexPath))?.image
    return cell
}

The cache is a dictionary that maps a URL string to a ready-to-use image. Here is the Mutex variant:

final class MutexImageCache {
    private let images = Mutex<[String: CachedImage]>([:])

    func image(for key: String) -> CachedImage? {
        images.withLock { $0[key] }
    }

    func store(_ entry: CachedImage, for key: String) {
        images.withLock { $0[key] = entry }
    }
}

During the scroll, four background threads replace images in the cache. Before each write, a thread does some computation outside the lock, as if decoding an image; after the write, it sleeps for 700 ms. The cache is filled in advance, so every read finds an image.

There are two metrics. Read time is the duration of the cache.image(for:) call; the rest of the cell’s work isn’t included. To catch hitches, the moments when the main thread misses the next frame, I measured the intervals between CADisplayLink callbacks. On a 120 Hz display, a callback arrives every 8.3 ms. If the main thread stays busy longer than that, the next callback arrives late and the interval grows.

The comparison covers 14 variants; the code is in the repository.

How long a read takes

The chart shows the median read time; shorter bars are faster.

Median cache read time during scrolling. Mutex 2.02 µs, OSAllocatedUnfairLock 2.06 µs, MainActor with a direct read 2.10 µs, os_unfair_lock 2.15 µs, pthread_rwlock 2.21 µs, pthread_mutex 2.22 µs, DispatchSemaphore 2.29 µs, NSLock 2.42 µs, objc_sync 2.50 µs, OSAllocatedUnfairLock with adaptiveSpin 2.65 µs, NSRecursiveLock 2.69 µs, serial DispatchQueue 7.94 µs, concurrent queue with a sync barrier 8.21 µs, concurrent queue with an async barrier 8.27 µs.

View data: cache read time
MechanismRead p50, µsRead p99, µs
Mutex2.028.16
OSAllocatedUnfairLock2.068.56
MainActor, direct read2.108.29
os_unfair_lock2.158.62
pthread_rwlock2.218.48
pthread_mutex2.228.41
DispatchSemaphore2.299.21
NSLock2.4210.02
objc_sync_enter / objc_sync_exit2.509.00
OSAllocatedUnfairLock with .adaptiveSpin2.659.96
NSRecursiveLock2.699.93
Serial DispatchQueue7.9419.25
Concurrent queue, writes via sync(flags: .barrier)8.2120.21
Concurrent queue, writes via async(flags: .barrier)8.2719.89

A read through a queue takes four times as long. It takes 2.0 µs under Mutex, 7.9 µs through the serial queue, and about 8.2 µs through a concurrent queue. The other locks take 2.1 to 2.7 µs.

pthread_rwlock and .adaptiveSpin didn’t help here. A read with them takes 2.21 and 2.65 µs versus 2.02 µs for Mutex. The background threads write rarely, and the main thread almost never waits for the lock.

So a queue adds about 6 µs to every read. Is that noticeable during scrolling?

Is it noticeable during scrolling?

No, it isn’t. All 14 variants show the same intervals between CADisplayLink callbacks, and not a single interval stretched to even a frame and a half.

That’s expected. Hitches usually appear when the main thread is busy with heavy work: complex UI, layout, image decoding. A cache read isn’t that kind of work. It takes microseconds, while a frame at 120 Hz lasts 8.3 ms. So the choice of lock doesn’t affect how smooth the scrolling is here.

What if the cache is isolated to MainActor?

Only the main thread reads the cache, so reads don’t need a lock: the cache can be isolated to MainActor.

@MainActor
final class MainActorImageCache {
    private var images: [String: CachedImage] = [:]

    func image(for key: String) -> CachedImage? {
        images[key]
    }

    func store(_ entry: CachedImage, for key: String) {
        images[key] = entry
    }
}

In the previous section, a call to MainActor from a background task took 5.7 µs. But here the main thread does the reading itself, so no thread switch is needed. A read takes 2.10 µs, almost the same as under Mutex. The background tasks pay for the thread switch when they write through await cache.store(…), and there are only about six writes per second.

The comparison shows one more thing: a read with no lock at all takes the same 2 µs as a read under Mutex. So the lock accounts for almost none of those 2 µs.

iPhone 11 Pro Max

The older phone’s display refreshes at 60 Hz. A read takes 3.1 µs under Mutex and 13.7 µs through the serial queue, more than four times as long. Here too, no interval exceeded a frame and a half.

Takeaway

A short read under a lock doesn’t break the “don’t block the main thread” rule. Under Mutex it takes about 2 µs, while a frame lasts 8.3 ms.

A queue is four times slower, but even that difference isn’t noticeable during scrolling. There’s no point in replacing a queue with Mutex for smoothness here: hitches come from heavy work on the main thread, not from the choice of lock.

If the cache is already isolated to MainActor, there’s no point in moving it under a lock for read speed either.

All of this holds only while the lock is held briefly. In the next section, a low-priority background thread holds it while the main thread waits.

Priority inversion: the main thread waits for a background thread

Heavy background work usually runs at low priority so that it doesn’t get in the way of the UI. But suppose such a thread takes a lock that the main thread needs. Now the main thread has to wait for the background thread to finish, and the background thread gets no CPU time because the app has higher-priority work to do. The situation is upside down: the most important thread is waiting for the least important one. This is called priority inversion.

Some synchronization mechanisms are designed to solve this problem: while the important thread waits, the system temporarily lends its priority to the thread holding the lock. In this section I check how long the main thread waits in this situation and which mechanisms actually shorten the wait.

What I measured

One trial goes like this:

  1. A thread at background priority takes the lock and starts working under it. With no load on the CPU, that work takes about 5 ms.
  2. Six threads at userInitiated priority occupy all the cores for 300 ms.
  3. After 1 ms, the main thread tries to read the state under the same lock.

I measure how long the main thread waits. With Mutex it looks like this:

let state = Mutex(0)

// Background thread, background priority
state.withLock { value in
    doWork()    // about 5 ms with no load
    value = trial
}

// Main thread, 1 ms later
let value = state.withLock { $0 }

Each launch runs 100 such trials per mechanism. The comparison covers 14 variants; the code is in the repository.

How long the main thread waits

The chart shows the median wait; shorter bars are better.

Median main-thread wait. objc_sync 12.95 ms, Mutex 12.95 ms, NSLock 12.96 ms, pthread_mutex 12.96 ms, OSAllocatedUnfairLock with adaptiveSpin 12.97 ms, OSAllocatedUnfairLock 12.97 ms, concurrent queue with an async barrier 13.00 ms, NSRecursiveLock 13.09 ms, os_unfair_lock 13.65 ms, serial DispatchQueue 13.86 ms, concurrent queue with a sync barrier 61.94 ms, DispatchSemaphore 65.63 ms, pthread_rwlock 68.55 ms.

View data: main-thread wait
MechanismMedian wait, ms
objc_sync_enter / objc_sync_exit12.95
Mutex12.95
NSLock12.96
pthread_mutex12.96
OSAllocatedUnfairLock with .adaptiveSpin12.97
OSAllocatedUnfairLock12.97
Concurrent queue, writes via async(flags: .barrier)13.00
NSRecursiveLock13.09
os_unfair_lock13.65
Serial DispatchQueue13.86
Concurrent queue, writes via sync(flags: .barrier)61.94
DispatchSemaphore65.63
pthread_rwlock68.55

With ten of the thirteen mechanisms, the wait is the same: about 13 ms. That’s 12.95 ms for Mutex, 12.96 ms for NSLock, and 13.86 ms for the serial queue.

Three mechanisms make the main thread wait about five times as long. The main thread waits 61.9 ms with a concurrent queue and writes via sync(flags: .barrier), 65.6 ms with DispatchSemaphore, and 68.6 ms with pthread_rwlock.

With the background thread at utility priority, the result is the same: about 13 ms for most mechanisms and 55 to 60 ms for the same three.

Why these three

Apple has the answer. The WWDC 2017 session Modernizing Grand Central Dispatch Usage divides synchronization primitives into three groups:

The three slow mechanisms match the second and third groups exactly. The results fit this explanation: in the fast group, the background thread temporarily gets the main thread’s priority and finishes its work; in the slow group, it keeps its own priority and waits for a free core.

Here is one trial as a timeline. Switch the lock to compare the groups.

A timeline of one trial. A background thread takes the lock, six load threads occupy all the cores, and after 1 ms the main thread asks for the lock. With Mutex the main thread waits 12.95 ms, about a frame and a half at 120 Hz. With DispatchSemaphore it waits 65.63 ms, about eight frames.

There’s one exception I can’t explain: the async(flags: .barrier) variant landed in the fast group, even though it uses the same concurrent queue.

13 ms is still a lot

Even in the fast group, the main thread waited 13 ms. The work under the lock takes only 5 ms, but under load it runs longer. At 120 Hz, 13 ms is a frame and a half: a hitch. And no choice of lock will remove it.

iPhone 11 Pro Max

On the older phone, the same three mechanisms are slower than the rest, but by less: about 21.5 ms, versus 12 to 14.5 ms for the others.

Takeaway

If the main thread might wait for a lock, choose a mechanism that handles priority inversion: Mutex, OSAllocatedUnfairLock, NSLock, or a serial queue. With a semaphore, pthread_rwlock, or a concurrent queue with sync(flags: .barrier), the main thread waited five times as long.

But even with Mutex, the main thread waited 13 ms, which is already a hitch. So what needs to change is the work done under the lock, not the lock itself. Do the computation before acquiring the lock, and keep only a short state update under it.

One question remains: what the lock object itself costs in creation time and memory.

Creation cost and memory footprint

A lock usually lives inside the object, as a field next to the state it protects. With only a few such objects, nobody gives that field a thought. But sometimes there are thousands of them: cache entries, requests, list items. Then each one has its own lock, queue, or actor, and each of those has to be created, kept in memory, and eventually destroyed.

In this section I check how long it takes to create and destroy such an object and how much memory the synchronization mechanism adds to it.

What I measured

The object in the test is simple: one integer and the mechanism that protects it. Here are the Mutex variant and the variant with its own queue:

final class MutexObject {
    private let value = Mutex<Int>(0)
}

final class SerialQueueObject {
    private var value = 0
    private let queue = DispatchQueue(label: "creation.serial")
}

I create 100,000 such objects in a row, then destroy them, and divide the time by the number of objects. For memory, I read the allocator’s counters: how much the number of bytes in use grew after creation, per object. Then I subtract the result for an object with no synchronization. Nothing accesses the objects in this test; they are only created and destroyed.

The comparison covers 15 variants; the code is in the repository.

How long creation takes

The chart shows the time to create one object; shorter bars are faster.

Time to create one object. Mutex 15.3 ns, no synchronization 15.7 ns, Atomic 15.7 ns, MainActor class 16.2 ns, actor 20.7 ns, os_unfair_lock 22.4 ns, ManagedAtomic 23.3 ns, DispatchSemaphore 23.8 ns, OSAllocatedUnfairLock 24.3 ns, pthread_mutex 24.7 ns, pthread_rwlock 25.3 ns, NSLock 26.8 ns, NSRecursiveLock 31.4 ns, concurrent DispatchQueue 114.1 ns, serial DispatchQueue 152.2 ns.

An object with a Mutex is created as fast as an object with no synchronization. They take 15.3 and 15.7 ns. The same goes for Atomic and for a @MainActor class.

The other locks add 7 to 16 ns. OSAllocatedUnfairLock takes 24.3 ns, NSLock 26.8 ns, and NSRecursiveLock 31.4 ns.

An object with its own queue takes seven to ten times as long to create. That’s 152 ns with a serial queue and 114 ns with a concurrent one. For 100,000 objects, that adds up to 15 ms versus 1.5 ms with Mutex.

The memory numbers show where the difference comes from.

How much memory a mechanism adds

Here each bar shows how many more bytes an object with a synchronization mechanism takes than the same object without one.

Extra memory per object. Atomic 0 bytes, MainActor class 0, Mutex 16, ManagedAtomic 32, OSAllocatedUnfairLock 32, os_unfair_lock 32, pthread_mutex 80, actor 96, DispatchSemaphore 96, NSLock 112, serial DispatchQueue 160, concurrent DispatchQueue 176, NSRecursiveLock 240, pthread_rwlock 240.

View data: creation time, destruction time, and extra memory
Object containsCreation, nsDestruction, nsExtra memory, bytes
No synchronization15.717.50
Atomic15.717.00
@MainActor class16.217.40
Mutex15.316.616
os_unfair_lock22.424.632
ManagedAtomic23.339.832
OSAllocatedUnfairLock24.335.632
pthread_mutex24.728.080
Actor20.722.396
DispatchSemaphore23.865.996
NSLock26.836.2112
Serial DispatchQueue152.281.0160
Concurrent DispatchQueue114.183.5176
pthread_rwlock25.328.7240
NSRecursiveLock31.434.9240

The same numbers as blocks. Drag the scene to turn it, and pick a mechanism to see where its bytes live.

Extra memory per object drawn as blocks. Atomic and a MainActor class add nothing. Mutex adds 16 bytes inside the object. OSAllocatedUnfairLock, os_unfair_lock, and ManagedAtomic add 32 bytes in a separate allocation, pthread_mutex 80, DispatchSemaphore 96, NSLock 112, NSRecursiveLock and pthread_rwlock 240. An actor adds 96 bytes inside the object. A serial queue adds 160 bytes and a concurrent queue 176, each in two separate allocations.

Mutex adds 16 bytes to the object. It’s stored inside the object and needs no separate allocation. That’s also why such an object is created as fast as one with no lock.

The other locks need a separate allocation. That makes two allocations per object instead of one. OSAllocatedUnfairLock adds 32 bytes, NSLock 112, and NSRecursiveLock and pthread_rwlock 240 each. Mutex and OSAllocatedUnfairLock were equal in access speed in the first experiment, but they aren’t equal in creation cost.

A queue takes three allocations per object and adds 160 bytes. This is exactly the Pocket Casts case from the feature flag section, where every flag created its own queue.

For 100,000 objects, that adds up to 1.6 MB with Mutex, 11 MB with NSLock, 16 MB with a serial queue, and 24 MB with NSRecursiveLock.

iPhone 11 Pro Max

The memory numbers are the same on the older phone. The times are roughly double, but the ratios hold: creating an object takes 31.1 ns with a Mutex, 30.5 ns with no synchronization, and 318 ns with a serial queue.

Takeaway

If every object needs a lock and there are many objects, use Mutex. Such an object is created as fast as one with no synchronization and takes only 16 more bytes.

Don’t create a queue per object: the object takes seven to ten times as long to create and at least 160 more bytes.

For a single value, Atomic is enough, and it adds nothing to the object.

That was the last experiment. Now let’s pull the conclusions together.

Conclusion

At the start I asked whether there’s a synchronization mechanism you can pick by default. There is, and it’s Mutex. It was among the best in all eight experiments on the iPhone 15 Pro Max.

Other mechanisms beat it only under specific conditions: Atomic for a single value, the .adaptiveSpin flag for rare long calls. Neither a read-write lock nor a queue was faster than Mutex in any experiment.

At the same time, the choice of mechanism changes little. The cost of acquiring a free lock differs between mechanisms by nanoseconds, or by hundreds of nanoseconds for queues. Noticeable delays, in the microseconds and milliseconds, appeared in three cases: when threads competed for a lock continuously, when execution switched between the main thread and a background thread in a loop, and when a background thread held a lock for a long time.

The ruler below puts the article’s numbers on one axis.

The article's durations on one axis: an Atomic read 0.27 ns, a Mutex lock 1.86 ns, NSLock 6.36 ns, an actor call from a background task 52.6 ns, a flag read through queue.sync 203 ns, an image cache read 2.02 µs, a call from MainActor to an actor and back 4.89 µs, the p99 of a dictionary call under Mutex 19.1 µs, a frame at 120 Hz 8.33 ms, the main thread waiting for a Mutex held by a background thread 12.95 ms, and the same wait with DispatchSemaphore 65.63 ms.

The lock is often only a small part of an optimization. Every app and every task is different, and the biggest speedup may be somewhere else entirely. My article Why my Swift debouncer got 50× faster and what the Swift Runtime has to do with it is one example.

Mutex by default

The table collects the results of five experiments for the mechanisms that took part in almost all of them.

MechanismCounter, nsDictionary, M ops/sDictionary, call p99, µs98% reads, M ops/sMain-thread wait, msExtra memory, bytes
Mutex1.861.0619.19.3712.9516
OSAllocatedUnfairLock1.861.0822.59.5012.9732
os_unfair_lock1.851.0467.08.6013.6532
pthread_mutex4.620.8951.79.2912.9680
NSLock6.360.8951.88.0512.96112
pthread_rwlock6.00not testednot tested3.0768.55240
DispatchSemaphore3.740.2047.31.0565.6396
Serial DispatchQueue88.230.1864.10.4513.86160

iPhone 15 Pro Max. Counter: one thread. Dictionary: eight threads without pauses. 98% reads: six threads, groups of twenty records. Main-thread wait: a thread at background priority holds the lock.

Mutex, OSAllocatedUnfairLock, and os_unfair_lock are the same lock. On Apple platforms, Mutex stores an os_unfair_lock inside itself and calls its functions directly. OSAllocatedUnfairLock is the same os_unfair_lock in separately allocated memory. So all three take the same time to acquire; they differ in where the lock is stored and how you acquire it.

Mutex requires iOS 18. If your app supports earlier versions, use OSAllocatedUnfairLock, which is available from iOS 16.

Replacing a working NSLock with Mutex for speed is rarely worth it. Uncontended, NSLock adds about 4.5 ns per acquisition, and under priority inversion the main thread waited as long with it as with Mutex. The difference shows up in two cases. The first is when threads hit the lock continuously: in the dictionary test, NSLock completed 15% fewer operations, and the call p99 rose from 19 to 52 µs. The second is when there are many locks: NSLock adds 112 bytes to an object, Mutex 16.

A queue that protects state through queue.sync was among the slowest mechanisms in almost every experiment. A counter increment through it takes 48 times as long as under Mutex, and a flag read a hundred times as long. In the dictionary test with eight threads, the serial queue completed almost six times fewer operations; at 98% reads, twenty times fewer. An object with its own queue takes ten times as long to create and 160 more bytes.

Replacing such a queue with a lock pays off mainly where accesses are continuous or where every object creates its own queue. If accesses are rare, the replacement won’t make a noticeable difference.

Atomic for a single value

If one atomic operation is enough, Atomic is faster than a lock. That covers reading a flag, replacing a value, or incrementing a counter. A flag read takes 0.32 ns versus 1.86 ns under Mutex. On the counter, Atomic matches Mutex with one thread and completes three times as many operations per second with two: 310 versus 102 million. And it doesn’t add a single byte to the object.

If a flag is already protected by a Mutex, replacing it with Atomic for speed is rarely worth it. The difference is 1.5 ns per read: at 10,000 reads a second, that’s 3 µs with Atomic and 19 µs with Mutex. The switch is justified when the flag is read far more often or when several threads constantly compete for the value.

Before iOS 18, you can use ManagedAtomic from the swift-atomics package instead of Atomic. A read with it takes 0.80 ns, and it adds 32 bytes to the object.

Did the familiar advice hold up?

“If you need performance, use os_unfair_lock.” The advice is essentially right, but Mutex is now the easier way to follow it. Mutex contains the same os_unfair_lock: acquiring it takes the same 1.86 ns, with no manual lock() and unlock() calls. os_unfair_lock didn’t complete more operations per second than Mutex in either the dictionary test or the 98%-reads test. I found no reason to use it directly in new code.

“If reads far outnumber writes, look at a read-write lock or a concurrent queue with a barrier.” For short in-memory operations, this advice didn’t hold up. With 98% reads and six threads, pthread_rwlock completed a third as many operations per second as Mutex, and the concurrent queue about a sixth. Writes waited longer with them too: a write p99 of 21.8 and 29.6 µs versus 8.0 µs.

The most likely reason is the length of the operation. Acquiring a pthread_rwlock is more expensive in itself: a flag read under it takes 6.1 ns versus 1.86 ns under Mutex. For a short in-memory read, the gain from reading in parallel is smaller than that difference.

These mechanisms have two more drawbacks that have nothing to do with the share of reads. Under priority inversion, the main thread waited 68.6 ms for pthread_rwlock and 61.9 ms for a concurrent queue with writes via sync(flags: .barrier), compared with 13 ms for Mutex. And pthread_rwlock adds 240 bytes to an object, fifteen times as much as Mutex.

So even with a high share of reads, start with Mutex. A read-write lock is worth testing if a read under the lock takes a long time. My tests had no such operations, so you’d need to measure the gain in your own code.

“Actors are slow, use a lock.” This advice is half right. A call to an actor from a background task takes 52.6 ns, 28 times as long as acquiring a Mutex.

But we pay per call, not for the work done inside the actor. A hundred updates in one await cost 1.23 ns each instead of 58.1 ns. That’s 47 times less, and close to the 0.69 ns of Mutex.

The most expensive thing in these tests wasn’t the actor but the switch between the main thread and a background thread. The round trip takes about 5 µs, with or without an actor: 93 times as long as a call to an actor from a background task. And when the code is already running on the right actor, no switch is needed at all.

So here are my recommendations:

“Don’t block the main thread.” The rule is right if you read it as a limit on how long a lock is held. When the main thread read the cache under Mutex, a read took about 2 µs. When a background thread ran a computation under a Mutex, the main thread waited 13 ms, which is already a hitch.

So do the computation before acquiring the lock, and keep only a short state update under it. And wherever the main thread might wait for a lock, avoid DispatchSemaphore, pthread_rwlock, and a concurrent queue with sync(flags: .barrier): with those, it waited five times as long.

Where to start

In most cases you can pick Mutex and stop thinking about which lock to use. Your time is better spent on what runs under the lock and how often the lock is acquired.

TaskWhere to start
A short synchronous operation on shared stateMutex; before iOS 18, OSAllocatedUnfairLock
A single value: a flag or a counterAtomic; before iOS 18, ManagedAtomic
Reads far outnumber writesMutex. A read-write lock only if reads under the lock are long and measurement shows a gain
The state is already in an actor or on MainActorLeave it as is. Batch calls and don’t switch between the main thread and a background thread in a loop
The main thread might wait for the lockMutex, OSAllocatedUnfairLock, or NSLock, and only short work under the lock
A lock in each of thousands of objectsMutex or Atomic. Not a queue per object
Threads compete for the lock continuously and p99 mattersTest OSAllocatedUnfairLock with .adaptiveSpin on your own workload
The code already has an NSLock or a serial queue, and accesses are rareDon’t replace it for speed

The benchmark and the raw results of every launch are public. You can run the experiments on your own device and compare your numbers with mine.

Thanks for reading! If your numbers come out different or you have questions, open an issue in the benchmark repository.


Suggest Changes
Share this post on:

Previous Post
Why my Swift debouncer got 50× faster and what the Swift Runtime has to do with it