Video #359: Isolation: OSAllocatedUnfairLock
Episode: Video #359 Date: Mar 23, 2026 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep359-isolation-osallocatedunfairlock

Description
It turns out that isolating state with an NSLock is not as straightforward as it seems, and we still have a subtle data race. But Apple actually provides a more modern tool that does help prevent this data race at compile time. Let’s take it for a spin and get an understanding of how it works.
Video
Cloudflare Stream video ID: d5c9391193b2d7a24cd05dcd862d8848 Local file: video_359_isolation-osallocatedunfairlock.mp4 *(download with --video 359)*
References
- Discussions
- OSAllocatedUnfairLock
- SE-0306: Actors
- 0359-beyond-basics-isolation-pt5
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
We have now slapped a lock on basically every part of our bank class in the name of trying to protect its data. Along the way we saw that if done naively, it led to deadlocks, and if you then try to fix that with a recursive lock you only introduce other problems. And the code of our bank class has really morphed into quite a grotesque shape that I no longer understand how to safely make changes to it. Stephen
— 0:28
But, aesthetics and code cleanliness aside, does it at least do the job to fully isolate the bank’s data so that we can safely access it from multiple threads at the same time? Well, sadly the answer is still a resounding no . There is a huge problem with this code even though it appears to compile just fine with no warnings or errors.
— 0:48
The problem is with the account(for:) method defined on the Bank class which allows us to fetch an account from the bank. That seems like a completely innocuous operation to have defined on the bank, and Swift didn’t complain about it when we wrote that code even though we have the strictest concurrency settings turned on. Yet the fact that the Account class is non-sendable and is escapable from a sendable object means we can easily trip over data races again.
— 1:12
Let’s take a look at why this is a problem. A new data race appears
— 1:16
The fact that we are able to escape a non-sendable Account from a class that has been marked as safe to use from multiple threads via the account(for:) is actually a big problem. The signature for account(for:) looks innocent enough: func account(for id: Account.ID) throws -> Account { … }
— 1:30
…but is hiding a huge problem from us. The fact that Bank is sendable means that it can be passed around from one isolation domain to another without a worry in the world. But that also means that we can invoke this account(for:) method from any isolation domain, which in turn allows us to move an account from one isolation domain to another, and that should be strictly prohibited by Swift’s concurrency checker since Account is not sendable.
— 1:50
To see this more concretely, let’s write a test to emulate a busy deposit day for a single account: @Test func busyDepositDay() async throws { let bank = Bank() let id = bank.openAccount(initialDeposit: 0) let account = try bank.account(for: id) } We’ve started with a bank and opened a single account for zero balance. We have also used the account(for:) method to actually fetch the account object from the bank.
— 2:15
Next we’d like to emulate many concurrent deposits to this account. We can use a task group to spin up 1,000 concurrent tasks and deposit 100 into the account: await withThrowingTaskGroup { group in for _ in 1...1000 { group.addTask { account.deposit(100) } } } #expect(bank.totalDeposits == 100 * 1000)
— 2:40
This leads to a compiler error because we are trying to send an account to a concurrently executing context: Passing closure as a ‘sending’ parameter risks causing data races between code in the current task and concurrent execution of the closure
— 2:48
…and the account is not currently sendable. The account is a reference type with unprotected mutable state, and so is not safe to pass around like this.
— 2:52
Well, I suppose we could do something a little silly by fetching the account from within the task: await withThrowingTaskGroup { group in for _ in 1...1000 { group.addTask { let account = try bank.account(for: id) account.deposit(100) } } }
— 3:01
This suddenly compiles with no warnings or errors, but running this test will fail, and the amount it fails by will vary depending on how much code interleaving there was: Expectation failed: (bank.totalDeposits → 99100) == (100 * 1000 → 100000)
— 3:13
Somehow we lost some balance after all of the deposits were completed. There is secretly a data race in our code. And this time we’re only seeing it manifest as corrupted data instead of a crash, but it truly is a data race and not just a “race condition”, which would simply be a logical error on our part for not properly accounting for multithreaded code.
— 3:30
To see this, we can just add some more complex state to Account , such as a history of balances: class Account: Identifiable { … var balanceHistory: [Int] = [] … }
— 3:41
And then append to that state when an amount is deposited: func deposit(_ amount: Int) { balanceHistory.append(balance) … }
— 3:49
That is all it takes to get this test to crash: Task 69: EXC_BAD_ACCESS (code=1, address=0xd33a6c9cdde74a35)
— 3:55
So, what gives? This code compiles without warnings or errors in Swift 6 mode, and so shouldn’t we feel reasonably confident that there are no data races?
— 4:03
Well, there are two reasons why this is happening. First of all, in the implementation of account(for:) we immediately reach for the lock.withLock method: func account(for id: Account.ID) throws -> Account { try lock.withLock { … } } But if we look at the definition of withLock we will find the following: public func withLock<R>(_ body: () throws -> R) rethrows -> R
— 4:11
Just a simple method that takes a trailing closure and can magically return any value you want. But remember, the whole point of a lock is to protect some state, and this closure signature gives us carte blanche to escape anything from the lock we want. Typically you would see extra annotations on this closure to help Swift protect fragile state, such as @Sendable or sending , but here there is nothing.
— 4:33
And related to this, we also have an @unchecked Sendable on our Bank class: final class Bank: @unchecked Sendable { … }
— 4:38
Remember we mentioned the caveat that we can only trust the concurrency checker of Swift if we compile our code with maximum checks enabled, and if we don’t use any escape hatches. Well, @unchecked Sendable is an escape hatch.
— 4:50
This is us telling the compiler to trust us. We vouch for the safety of this class. But we are actually wrong. This class is not safe at all to use, specifically because of this account(for:) method. By returning a non-sendable object from this method on a quote-unquote “sendable” object, we are allowing ourselves to escape the object to any thread we want and mutate it.
— 5:10
And that is exactly what is happening here: group.addTask { let account = try bank.account(for: id) account.deposit(100) }
— 5:12
And this is a really good lesson to internalize about @unchecked Sendable . You may believe it’s a localized tool that allows you to vouch for the thread safety of a single class, but in reality you have opened the floodgates for other kinds of problems that are not contained directly in this class. The data race we have here isn’t directly tied to the Bank itself, but rather with the Account , which we haven’t marked as @unchecked Sendable at all. This shows just how tricky and pernicious unchecked sendable conformances can be.
— 5:36
And there just is no way to implement this Bank class such that it uses a lock to protect mutable state without using @unchecked Sendable . We are forced to use the escape hatch, and therefore are forced to face the fact that we can never be 100% sure our class is safe to use from multiple threads. OSAllocatedUnfairLock
— 5:53
We have now seen that marking something as @unchecked Sendable can lead to data races that are not even directly related to that object, but instead can cause data races in other code that is not marked as @unchecked Sendable . It’s a really dangerous tool to use and you must be very, very careful with it.
— 6:08
But, luckily the Apple ecosystem does come with a few tools that allow one to protect state with locks in a way that allows us to avoid @unchecked Sendable . This includes OSAllocatedUnfairLock , which is available only on Apple’s platforms, as well as Mutex , which is part of the open source distribution of Swift, which means it’s available on more platforms, such as Linux, Windows, and more. Brandon
— 6:30
By using these tools we will immediately come face-to-face with compiler errors when we do something wrong because they are stronger tools built in modern times with modern Swift concurrency. They are capable of doing things that NSLock just isn’t capable of.
— 6:46
So, let’s now refactor our banking classes to protect their mutable state with these more modern tools so that we can see what goes wrong, and then what we need to do to play nicely with the tools.
— 6:59
The first one we are going to look at is OSAllocatedUnfairLock , which is a more modern version of NSLock that was released a few years ago and can be used on iOS 16 and later. In its most basic form, we can try literally swapping out our existing NSLock for one of these locks: let lock = OSAllocatedUnfairLock()
— 7:06
But to do that we do need to import the module it lives in: import os
— 7:25
With that, the line that declares the lock is compiling, but other things are no longer compiling: func account(for id: Account.ID) throws -> Account { try lock.withLock { guard let account = accounts[id] else { struct AccountNotFound: Error {} throw AccountNotFound() } return account } } Type ‘Bank.Account’ does not conform to the ‘Sendable’ protocol
— 7:46
These are exactly the kinds of errors we hoped to have with our NSLock because it is notifying us of real problems with our code.
— 7:54
The trailing closure of withLock on OSAllocatedUnfairLock is a lot stricter than that of NSLock . It not only requires that the closure used is @Sendable : public func withLock<R: Sendable>( _ body: @Sendable () throws -> R ) rethrows -> R …which means it can be safely called from multiple threads, but also the value returned from the closure must also be sendable.
— 8:10
Remember that withLock on NSLock had no such restrictions: public func withLock<R>(_ body: () throws -> R) rethrows -> R
— 8:24
…and of course it couldn’t really have any of those features because this was defined long before sendability or Swift concurrency was a thing.
— 8:32
So I guess my first question is why is OSAllocatedUnfairLock more strict than NSLock , and then my second is how do we write code that does what we want while still playing nicely with this newly found strictness.
— 8:47
For the first question, this is due to the fact that OSAllocatedUnfairLock improves upon NSLock in one key aspect: it actually wraps and protects a piece of state instead of just being a raw interface to a locking mechanism. If we look at the type of lock we will see: let lock: OSAllocatedUnfairLock<()>
— 9:08
So the lock is currently just protecting a Void value, which is nothing that needs to be protected. This is not the correct way to use this kind of locking mechanism.
— 9:30
Instead, you put the data you want to protect directly inside OSAllocatedUnfairLock , and then it can do a better job at protecting the state than NSLock could. It’s going to be a huge breaking change, but in a good way because it will force us to come face-to-face with all of the unsafe decisions we made previously: let accounts = OSAllocatedUnfairLock<Account.ID, Account>( initialState: [:] ) Type ‘Account’ does not conform to the ‘Sendable’ protocol
— 10:16
However, OSAllocatedUnfairLock seems to restrict us to only allow putting in sendable data into its protected zone. That seems a little weird because one of the main reasons to use locks is to protect state that is not safe to access from multiple threads.
— 10:25
It turns out that this initializer is being too strict. There is another initializer that doesn’t require sendability: let accounts = OSAllocatedUnfairLock( uncheckedState: Account.ID: Account )
— 10:42
…but as you can see from its name it indicates that we are escaping the guardrails of Swift’s concurrency checks. This initializer is too relaxed to use. It allows us to take a non-sendable object, hand it over to the lock, and then continue using it outside of the lock, which is unsafe.
— 11:22
What we want is something in between these two initializers. We want to be able to hold onto a non-sendable object inside the OSAllocatedUnfairLock , but once you hand over the object you shouldn’t be allowed to interact with it again.
— 11:37
This is something that region-based isolation aims to solve, which I promise we will discuss eventually in this series, but there is even a lower-tech solution to this that could have been used before the days of region-based isolation.
— 11:50
We can define an initializer on OSAllocatedUnfairLock that takes a @Sendable autoclosure that returns potentially non-sendable state: extension OSAllocatedUnfairLock { init(checkedState: @Sendable @autoclosure () -> State) { self.init(uncheckedState: checkedState()) } }
— 12:29
It may seem weird to use a @Sendable closure to produce a non-sendable object, but that is exactly what we want. This allows us to hold onto non-sendable state in the lock, and the sendability of the autoclosure prevents us from escaping the object and referencing it elsewhere.
— 12:48
We can see this in practice. If we create some non-sendable state it compiles just fine: class NS {} _ = OSAllocatedUnfairLock(checkedState: NS())
— 13:09
But if we hold onto a reference outside the initializer and try to pass it along we’re met with an error: let ns = NS() _ = OSAllocatedUnfairLock(checkedState: ns) Implicit capture of ‘ns’ requires that ‘NS’ conforms to ‘Sendable’
— 13:24
That’s because secretly there is a sendable closure that tries to capture the non-sendable value, and that is not allowed. _ = OSAllocatedUnfairLock(checkedState: {ns}())
— 14:06
And so we will use this initializer instead of the ones provided by OSAllocatedUnfairLock : let accounts = OSAllocatedUnfairLock<Account.ID, Account>( checkedState: [:] )
— 14:15
And this really is safe to do even though it’s using uncheckedState under the hood.
— 14:27
And even though we still have a bunch of compilation errors, there is one very important thing we can do. We can now drop the @unchecked Sendable : final class Bank: Sendable { … }
— 14:47
And we can now be a lot more confident in the safety of this class. We have now offloaded the safety concerns with regard to concurrency to the OSAllocatedUnfairLock class, which itself is @unchecked Sendable : @frozen public struct OSAllocatedUnfairLock<State>: @unchecked Sendable { … } …but at least we’ve got some team at Apple vouching for its safety instead of us. Making better use of OSAllocatedUnfairLock
— 15:00
OK, we are now on our way to finally having a thread-safe version of our bank, and it’s thanks to the OSAllocatedUnfairLock . This lock is very different from NSLock in that it actually makes use of some modern concurrency concepts in its definition, such as @Sendable , so that we aren’t allowed to do dangerous things with protected state. Stephen
— 15:21
But this kind of lock is a pretty dramatic departure from legacy locking systems. Older styles of locks are just abstract notions of “lock” and “unlock”, whereas OSAllocatedUnfairLock literally wraps and protects a piece of state. We can no longer access our protected state directly like we could with NSLock . We are forced to go through withLock .
— 15:39
So, let’s see what it takes to update our APIs to play nicely with this kind of lock.
— 15:45
Previously it was on us to remember to use withLock to protect our state, such as in the totalDeposits computed property: var totalDeposits: Int { lock.withLock { accounts.values.reduce(into: 0) { $0 += $1.balance } } }
— 15:56
But technically nothing stopped us from accessing the state directly: var totalDeposits: Int { accounts.values.reduce(into: 0) { $0 += $1.balance } }
— 16:00
Well, that was until now. This isn’t just something that we must remember to do, but rather it is enforced by the API of OSAllocatedUnfairLock . To access the protected state we use another withLock method, but this time the trailing closure is handed the state that is protected: var totalDeposits: Int { accounts.withLock { $0.values.reduce(into: 0) { $0 += $1.balance } } }
— 16:18
The openAccount method can be fixed similarly: @discardableResult func openAccount(initialDeposit: Int = 0) -> Account.ID { accounts.withLock { let id = UUID() $0[id] = Account(id: id, balance: initialDeposit) return id } }
— 16:26
The transfer method is a little stranger. The lock being currently used isn’t actually protecting any state, it’s just wrapping the withdraw and deposit work in a single transaction. So technically I guess we can just remove the lock now? func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) throws { let fromAccount = try account(for: fromID) let toAccount = try account(for: toID) try fromAccount.withdraw(amount) toAccount.deposit(amount) }
— 16:38
Then we have the account method, and this is where things get interesting. If we try fixing this like we did the other methods: func account(for id: Account.ID) throws -> Account { try accounts.withLock { guard let account = $0[id] else { struct AccountNotFound: Error {} throw AccountNotFound() } return account } }
— 16:48
…we are met with a compilation error: Type ‘Account’ does not conform to the ‘Sendable’ protocol
— 16:52
This right here is a good error to have, and it’s what NSLock was incapable of notifying us of. It is not safe at all to escape a non-sendable object from an OSAllocatedUnfairLock . The lock itself is sendable, and so can be transported around to any thread, and if it was possible to leak non-sendable objects from each of those threads we would be allowing ourselves to access non-sendable things from multiple threads.
— 17:16
And there is no fixing this method. It was just doomed from the very beginning and we should have never been allowed to write it. There are a few ways to fix it, but none of them are ideal. For one thing we could abandon trying to share this bit of logic and just inline the guard let and error throwing in every little place that needs an account.
— 17:35
Another approach would be to define these kinds of helpers on the data itself rather than on the class that uses the lock: extension [Account.ID: Account] { struct AccountNotFound: Error {} func account(for id: Key) throws -> Value { guard let value = self[id] else { throw AccountNotFound() } return value } }
— 17:52
Then in transfer we can open up the protected accounts state and perform the full transaction at once: func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) throws { try accounts.withLock { let fromAccount = try $0.account(for: fromID) let toAccount = try $0.account(for: toID) try fromAccount.withdraw(amount) toAccount.deposit(amount) } }
— 18:15
That gets the core library classes of Bank and Account compiling, but some of the tests now have problems because they relied on the account(for:) method. And, well, those tests were just wrong. They should have never been allowed to access the account like that.
— 18:26
There are two specific places we used the account(for:) method. One to check the balance of an account: #expect(try bank.account(for: id1).balance == 50) #expect(try bank.account(for: id2).balance == 150)
— 18:35
And another to get an account and deposit into it: group.addTask { let account = try bank.account(for: id) account.deposit(100) }
— 18:41
One thing we could do is create specific methods on Bank to perform these operations. Something like: bank.balance(for: id1) bank.deposit(100, into: id)
— 19:03
But that’s a real pain. If there are dozens of operations defined on Account , we are going to have to essentially repeat them all in the bank interface just to make them usable from outside the bank.
— 19:12
There’s another approach we can take. What if we provided a way to get access to an account so that we can read its balance or make a deposit, but do so in a way that is safe. In particular, we should not be allowed to escape the non-sendable account from its bank.
— 19:28
Such a method wouldn’t return an account directly, but would instead allow for a closure to be provided that is invoked with the account: func account( for id: Account.ID, body: (Account) -> Void ) throws { }
— 19:53
That gives a small window of time for the account to be made available to the outside.
— 19:59
You might hope that this could be implemented by opening up the protected state, accessing the account, and passing it to the body closure: try accounts.withLock { try body($0.account(for: id)) }
— 20:10
But this is not safe to do because OSAllocatedUnfairLock has extra protections on it that force the closure provided to withLock to be @Sendable : Capture of ‘body’ with non-Sendable type ‘(Account) -> Void’ in a ‘@Sendable’ closure
— 20:20
This forces the closure provided to account(for:) to be @Sendable : func account( for id: Account.ID, body: @Sendable (Account) -> Void ) throws { … }
— 20:26
And technically that compiles, but also this isn’t quite what we want. It does help us with our test that wants to perform a deposit: group.addTask { try bank.account(for: id) { $0.deposit(100) } }
— 20:45
That compiles. But it doesn’t help us with our test where we want to get the balance out of the account. For that we need the operation to be able to return some result: func account<R>( for id: Account.ID, operation: @Sendable (Account) -> R ) throws -> R { … }
— 21:03
But that doesn’t work because OSAllocatedUnfairLock requires the result returned to be sendable: Type ‘R’ does not conform to the ‘Sendable’ protocol
— 21:12
This is a very important error because it prevents us from accidentally leaking sendable objects out of the protected domain of the lock. So we are forced to make R sendable: func account<R: Sendable>( for id: Account.ID, operation: @Sendable (Account) -> R ) throws -> R { … }
— 21:22
And now we can update our last test that has compilation errors: #expect(try bank.account(for: id1) { $0.balance } == 50) #expect(try bank.account(for: id2) { $0.balance } == 150)
— 21:27
So, even though the Account handed to this closure, as the $0 , is not sendable, it is totally safe to interact with the object in this context. And we can even pluck sendable data off of the non-sendable object and escape it from the closure by returning it.
— 21:41
Our library code and tests now compile, and the tests run and pass. This includes even the test that opened a single account, and then fired up 1,000 tasks to concurrently deposit 100 into that account. Previously, when using just NSLock , this test had a race condition causing the total deposits to be less than what it should be. And the reason that was possible is because it is just not possible to write concurrency-safe code with NSLock . It’s too old of a tool.
— 22:11
But OSAllocatedUnfairLock is a newer tool with the proper sendability annotations that make it much safer to use. As long as we aren’t using any of the “unchecked” APIs, we can be pretty confident that we are properly protecting our data, even if it is non-sendable data.
— 22:24
And it’s also worth noting that OSAllocatedUnfairLock took a strong stance with regard to re-entrancy. It specifically chose not to support recursive locks: OSAllocatedUnfairLock isn’t a recursive lock. Attempting to lock an object more than once from the same thread without unlocking in between triggers a runtime exception.
— 22:43
This is very good evidence pointing to the fact that recursive locks are best avoided, and should only be used if absolutely necessary. Next time: Mutex
— 22:51
And finally we have written a thread safe banking class that can manage a collection of accounts. We are allowed to open accounts, deposit into accounts, transfer between accounts, and compute the total deposits across all accounts. And we can do all of this in a thread-safe manner. We were able to fire up a bunch of tasks and hammer the bank with tons of transactions, and at the end of that we had the correct amount left in all accounts. Brandon
— 23:14
And the tool that made this possible was OSAllocatedUnfairLock . It is a modern locking mechanism that takes advantage of modern Swift concurrency tools to prevent us from doing unsafe things with our data. But, this tool only works on Apple platforms, which is fine for apps, but we want to show a cross-platform option too. Swift ships a Mutex type that provides the same “lock-protected state” ergonomics: you store your state inside the mutex and access it only via withLock , which keeps the compiler in the loop about sendability. That gives us a good baseline for isolation on Linux and Windows without falling back to NSLock .
— 23:55
And Mutex employs a few extra tricks to make it even more usable than OSAllocatedUnfairLock , but unfortunately at the same time due to a really bad Swift concurrency bug it is also not as safe as it could be.
— 24:10
Let’s take a look…next time! References OSAllocatedUnfairLock A structure that creates an unfair lock. https://developer.apple.com/documentation/os/osallocatedunfairlock SE-0306: Actors John McCall, Doug Gregor, Konrad Malawski, Chris Lattner • Oct 30, 2020 This proposal introduces actors into Swift. An actor is a reference type that protects access to its mutable state.” https://github.com/swiftlang/swift-evolution/blob/main/proposals/0306-actors.md Downloads Sample code 0359-beyond-basics-isolation-pt5 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .