Peculiarities of Mutex
Prior to iOS 16 and macOS 13, there were two primary ways to incorporate locks into Swift apps: using low-level primitives like pthread_mutex_t
or os_unfair_lock
, which come with their own pitfalls, or relying on Foundation’s higher-level NSLocking
types such as NSLock
or NSRecursiveLock
. In this article, we’ll focus on the latter approach and explore why adopting newer APIs like os.OSAllocatedUnfairLock
and Synchronization.Mutex
becomes important when migrating to Swift 6 with strict concurrency checking enabled.
Good, Old Generics
Let’s start with the key difference between Foundation’s older locking APIs and the newer ones: NSLock
isn’t designed to store the protected state. It’s simply a standalone object that you reference separately, alongside the state you want to protect. This has significant consequences - you, the programmer, are responsible for ensuring all interactions with the protected state are correctly wrapped in a withLock
call:
In contrast, both OSAllocatedUnfairLock
and Mutex
are generic, meaning they can hold the protected state internally. As a result, you no longer need to store the state separately or worry about accidental unprotected access - the state is only accessible within the withLock
scope:
So far, so good. Switching from NSLock
to OSAllocatedUnfairLock
or Mutex
brings clear advantages - and it looks pretty straightforward, right? Well, it’s a bit more complicated than that - but for very good reasons.
Compile, Please!
The previous example compiled just fine. Now let’s try switching the implementation to use OSAllocatedUnfairLock
:
The first time I saw this error, I was puzzled - isn’t the whole point of locks to allow safe access to non-sendable state across isolation domains? Why does the value stored by this lock type need to be Sendable
?
Up to this point, we’ve wanted to add thread-safety to our code without worrying much about how it’s achieved. But this error reveals a clear difference in behavior, so let’s dig deeper. We’ll start with a basic breakdown:
Type | Library | Availability | System Primitive |
---|---|---|---|
|
| iOS 16.0+, macOS 13.0+ (Apple platforms only) |
|
|
| iOS 18.0+, macOS 15.0+, Linux, Windows |
|
This alone doesn’t explain much - both types use the same underlying system primitive. However, an important change came along iOS 18 and macOS 15: the introduction of Swift 6 and its region-based isolation. Let’s look at the (distilled) public interfaces of these two lock types to understand the key differences:
Now we’re getting somewhere! Many Swift developers are already familiar with the differences between Sendable
and the newer sending
keyword - especially in the context of closures passed to Task
initializers. Region-based isolation brought major improvements in ergonomics when dealing with non-sendable types in such contexts, and Mutex
inherits similar benefits through the use of inout sending
.
By contrast, OSAllocatedUnfairLock
was introduced before region-based isolation and was therefore designed with the older, stricter Sendable
requirement in mind. As a result, OSAllocatedUnfairLock
remains a valuable tool for developers targeting iOS 16–18 or macOS 13–15, while Mutex
, as part of the Swift Standard Library, is better suited for latest platforms.
But this raises an important question: why do the values we store inside these locks need to be Sendable
or sending
in the first place?
Lockpicking Developer
To understand the reasoning behind requiring Sendable
/sending
values, let’s return to our example using NSLock
and consider some innocent-looking code:
At first glance, this looks safe - access to state
is locked, right? But look again: the NonSendable
instance is being returned from within the locked scope. This means the caller now holds a reference to the protected value outside of the lock, completely bypassing synchronization. That opens the door to race conditions, which may not surface until a crash occurs - in development or production:
A similar problem can arise when passing an externally referenced value into the object responsible for synchronization:
In both examples, the root problem is that the lock only guards access to the state
property of the Store
instance, but it's completely unaware of the usage of underlying NonSendable
instance in other parts of the app.
Trying Out OSAllocatedUnfairLock
So, does OSAllocatedUnfairLock
actually protect us from introducing the race conditions we just explored? Let’s test both scenarios:
These are good - if slightly overzealous - diagnostics. You may have noticed that we had to use init(uncheckedState:)
to initialize the lock with a non-sendable type. This is required for OSAllocatedUnfairLock
, and in this case it’s perfectly safe: the NonSendable
instance is created inline, and there are no external references to it.
But the compiler doesn’t know that. Wouldn’t it be great if we could somehow tell the compiler about this? With region-based isolation and sending
keyword, we can!
Embracing Mutex
This is why our original example using Mutex
compiled successfully - even with a NonSendable
instance passed directly to it. It was safe, and the compiler was able to verify that. Let's test how Mutex
would handle the same problematic patterns we saw earlier:
Again, the compiler catches our mistakes and prevents unsafe behavior. But unlike Sendable
conformance, sending
has more relaxed requirements - so is there a way to make this implementation work while keeping the safety guarantees?
Let’s start with getCurrentState
method. While we don’t need to change its signature, we do need to adjust its behavior:
Here, the solution is to dereference/consume
the protected state and replace it with a new one within the withLock
closure's scope. The new method name, takeCurrentState
, reflects this change. Now, the returned instance can safely be transferred across isolation domains and mutated by the caller.
But what about setState
method? If you’re thinking that marking the parameter as sending
might help - you’re on the right track, but it’s not enough:
This took me a while to understand. A Swift Forums discussion was crucial in helping me see what’s really happening here - and the answer is surprisingly deep: we’ve wandered into an edge case that’s currently inexpressible in Swift language!
The issue isn’t with the function parameter - it’s with the withLock
method itself, and more generally with any function that accepts a closure parameter that is guaranteed to be called no more than once. I don’t recall this ever coming up as a problem before, but with region-based isolation and sending
values, it becomes significant. Here’s a minimal example that has nothing to do with Mutex
, but illustrates the same underlying issue:
Even though oneTask
function only calls the closure once, just like task
, the compiler must assume the closure could be called multiple times - as in twoTasks
. Since a NonSendable
instance cannot be safely referenced from multiple isolation domains, this creates a potential race condition, which the compiler prevents on the caller’s side.
This same reasoning applies to the inout sending
parameter in the withLock
method - except instead of returning a sending
value, we’re assigning one to the inout
variable.
SingleUseTransfer To The Rescue
Is it even possible to safely transfer an existing non-sendable value into the state protected by Mutex
? It is - and I’ve included a solution in my Principle
package, as part of the PrincipleConcurrency
module. It’s called SingleUseTransfer
.
This utility doesn’t bypass any concurrency checks. However, since we’re working near the edge of what the Swift language can currently express, it comes with a tradeoff that some may find uncomfortable: implicit force unwrapping. SingleUseTransfer
, as the name implies, is designed to be used at most once - and it’s the developer’s job to ensure that. That said, when used responsibly, it allows code like the following to compile cleanly and safely:
If you’ve run into issues using sending
values with closures, I encourage you to give this approach a look - it enables powerful patterns that the compiler can still verify as concurrency-safe. Personally, I find it much easier to reason about force unwrapping than about concurrency issues masked by unsafe annotations like @unchecked Sendable
or nonisolated(unsafe)
.
Don't Cheat
If you’re migrating to Swift 6 and come across usage of NSLock
, pthread_mutex_t
, or os_unfair_lock
in your code, I understand why it might be tempting to simply mark the enclosing type as @unchecked Sendable
and move on without adopting any new APIs. After all, you (or your users) may have verified that the code is safe.
Personally, I believe that the current inconveniences of using Mutex
are far outweighed by the long-term benefits - both for the safety of existing code and for the flexibility of future modifications to these critical parts of your app. And even if you can’t adopt Mutex
just yet due to platform version constraints, it’s still worth experimenting with. Doing so deepened my understanding of Swift Concurrency and gave me a much greater appreciation for the incredible work that makes such complex analysis possible at compile time.
Apple, the Apple logo, Swift, the Swift logo, Xcode, and related marks are trademarks of Apple Inc., registered in the U.S. and other countries. Any references to these trademarks are for informational and illustrative purposes only, to showcase projects built using Apple development tools and platforms.
Copyright © 2025 Kamil Strzelecki. All Rights Reserved.