Video #358: Isolation: Legacy Locking
Episode: Video #358 Date: Mar 16, 2026 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep358-isolation-legacy-locking

Description
We have a data race on our hands, which is exactly what Swift concurrency is supposed to solve for. Let’s figure out how we managed to get into this mess, and then we will get our feet wet with an isolation tool that predates Swift concurrency: locking.
Video
Cloudflare Stream video ID: a2cf80e8b7e571845fe7ef1e027ad010 Local file: video_358_isolation-legacy-locking.mp4 *(download with --video 358)*
References
- Discussions
- NSLock
- SE-0306: Actors
- 0358-beyond-basics-isolation-pt4
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
So, we just now saw that it’s very easy to write code that is susceptible to data races, which means two threads access a single piece of state at the same time. This often leads to corrupt data, where stale data is written, but can also lead to outright crashes.
— 0:18
The goal of Swift’s concurrency tools is that a program compiled with the strictest concurrency settings should be free from data races as long as you aren’t using any of the escape hatches that are available. Brandon
— 0:28
There is another closely related concept to data races known as “race conditions”. It’s a broader category of concurrency bugs than data races. Every data race is a race condition, but not every race condition is necessarily a data race. A race condition is just a logical error due to executing your code on multiple threads, and it can happen even if there is never an instant where literally 2 threads are accessing a single piece of data at the same time.
— 0:55
So, why is Swift letting us write this kind of code? Isn’t it supposed to prevent us from doing this with its fancy concurrency checking? Well, we have purposely put our SPM package in Swift 5 mode, which turns off all concurrency checking. So it shouldn’t be too surprising we were allowed to write unsafe code without any warnings or errors.
— 1:14
Let’s now turn on Swift 6 mode. Fixing data races with locks
— 1:21
Let’s go to the Package.swift and update it to compile our package in Swift 6 mode: swiftLanguageModes: [.v6]
— 1:31
…and finally we get an error letting us know this is not right: Passing closure as a ‘sending’ parameter risks causing data races between code in the current task and concurrent execution of the closure
— 1:54
This error message is unfortunately not great. It used to be better, prior to what is known as “region based isolation” was introduced. We will discuss that later, but suffice it to say that the addTask method used to take a @Sendable trailing closure, and then the error would have let us know that we are capturing a non-sendable type in a sendable closure, which is easy to understand since the Account class is definitely not sendable.
— 2:26
However, now addTask takes a *sending* trailing closure: public mutating func addTask( priority: TaskPriority? = nil, operation: sending @escaping @isolated(any) () async throws -> ChildTaskResult )
— 2:40
…which allows you to access non-sendable data from the trailing closure, but only under certain conditions. That’s great in one sense because it weakens the notion of sendability, allowing more code that is totally safe to get past the compiler, but it unfortunately does hurt the compiler errors and our ability to diagnose them.
— 3:05
Region-based isolation is very powerful, but it unfortunately is one of the thorniest parts of the Swift concurrency model. It has bad compiler errors, it’s the cause of many compiler crashes, and has many soundness holes, allowing you to write code that compiles but is technically wrong from a concurrency perspective.
— 3:24
We will be getting into sending and region-based isolation later, so for now suffice it to say that this error is indeed because our Bank class is not sendable, and deemed unsafe to access from multiple isolation domains. Sometimes you can transfer it from one isolation domain to another, as long as you can prove that the previous isolation domain no longer interacts with the object. In general, non-sendable objects are quite restricted.
— 4:01
So, how can we make Bank safe to use from multiple isolation domains? In the pre-actor world we would use locks. Locks allow you to create tiny, local isolation domains so that you can read and write state in a way that is guaranteed to never have multiple threads running those lines of code at the same time.
— 4:23
So, let’s add a lock to our Bank class: class Bank { private let lock = NSLock() … }
— 4:33
And anytime we access state we should guard it with a lock. That sounds simple when said so casually out loud, but there is actually a ton of room for interpretation.
— 4:44
Let’s start with some of the more non-controversial uses of the lock. When trying to compute the total deposits in the bank we should surround the whole thing in a lock: var totalDeposits: Int { lock.withLock { … } }
— 4:59
The idea here is that we would like the accounts state to be frozen while we are performing this computation. It would not be good if an account was added or removed while in the middle of summing the balances together.
— 5:36
But locking this one single property does not magically guarantee that. We need to also lock the method that opens accounts: func openAccount(initialDeposit: Int = 0) -> Account.ID { lock.withLock { … } }
— 6:01
This makes it so that if someone tries to open an account while we are computing the total deposits, it will wait until that computation is done, and then the account can be opened.
— 6:18
We should also lock the transfer method so that we don’t update accounts while in the middle of computing the total deposits: func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) throws { try lock.withLock { … } } That makes it so that if we are in the middle of computing the total deposits for our bank we are going to block anyone trying to transfer money.
— 6:41
And at this point it feels like we are just locking everything, so I suppose we should also lock the account method? func account(for id: Account.ID) throws -> Account { try lock.withLock { … } }
— 6:50
Now every method in this class has been wrapped in a lock, and I guess that means that it is magically safe to use from multiple threads? Well, I’m not entirely sure, but in order to put my stamp of approval on the class to tell the compiler I vouch for its thread safety, I need to mark it as @unchecked Sendable : final class Bank: @unchecked Sendable { … }
— 7:40
Now everything compiles, and so I guess that means everything is good? If I run the newAccountRush test again it passes. I can even run it 1,000 times and it succeeds all 1,000 times. Locks bring deadlocks
— 8:11
And so did we do it? Is this class now safe to use from multiple isolation domains? Well, honestly I’m not sure! It seems very strange that we had to surround every single method of our class with a lock. And I guess that means that every single time we add a new method we need to remember to do the same, or else we risk adding all new data races to the class. Stephen
— 8:31
And surrounding each method with a lock would honestly be a pretty small price to pay if this technique did actually provide safety to our class. But sadly it does not. Not only are there still data races, they are just a lot more subtle to reproduce now, but also the mere fact that we have introduced a lock means we must come face-to-face with its biggest danger: deadlocks.
— 8:50
Deadlocks are what happens when one part of your code acquires a lock to get access to the isolated state, and then within the same callstack another part of your code tries to again acquire the lock for whatever reason. It may sound simple to avoid deadlocks: just don’t ever acquire a lock twice! But in complex systems with many objects communicating back-and-forth with each other, it can be nearly impossible to understand all the possible code paths that can lead to a deadlock.
— 9:15
So, let’s take a quick look to see just how pernicious this can be.
— 9:20
Let’s start by doing some very modest refactoring of the LockingBank class to improve its structure. Right now the transfer method is a bit complex, and it seems like we can start leveraging the account(for:) method we previously made to retrieve accounts: func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) throws { try lock.withLock { let fromAccount = try account(for: fromID) let toAccount = try account(for: toID) guard fromAccount.balance >= amount else { struct InsufficientFunds: Error {} throw InsufficientFunds() } fromAccount.balance -= amount toAccount.balance += amount } }
— 9:50
And also this withdraw and deposit logic would probably be best encapsulated in the Account class: func deposit(_ amount: Int) { balance += amount } func withdraw(_ amount: Int) throws { guard balance >= amount else { struct InsufficientFunds: Error {} throw InsufficientFunds() } balance -= amount }
— 10:22
Then things get very simple in transfer : func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) throws { try lock.withLock { let fromAccount = try account(for: fromID) let toAccount = try account(for: toID) try fromAccount.withdraw(amount) toAccount.deposit(amount) } }
— 10:38
That right there is a pretty straightforward and non-controversial refactor. We should always feel empowered to refactor complex methods into simpler units.
— 10:49
Well, unfortunately we have now introduced a bug into our code. We have accidentally broken our basics test. If we run it we will see that it seems to just hang forever: ◇ Test run started. ↳ Testing Library Version: 1501 ↳ Target Platform: arm64e-apple-macos14.0 ◇ Suite BankSuite started. ◇ Test basics() started.
— 11:01
If we pause execution and look at the stack trace, we will see that transfer(amount:from:to:) acquires a lock, and then inside that lock it accesses account(for:) , which then also tries to acquire the lock: #3 0x10236f174 in specialized NSLocking.withLock<A>(_:) [inlined] () #4 0x10236f168 in Bank.account(for:) [inlined] #5 0x10236f158 in closure #1 in Bank.transfer(amount:from:to:) [inlined] #6 0x10236f148 in specialized NSLocking.withLock<A>(_:) [inlined] () #7 0x10236f138 in Bank.transfer(amount:from:to:)
— 11:11
This is a deadlock. The whole point of a lock is to block code that wants to access some state while the lock is acquired, and here we are trying to acquire it twice in the same call stack.
— 11:28
Interestingly, the other test we wrote, newAccountRush , does not have this problem. We can run it 1,000 times and it passes all 1,000 times. This is happening because it just so happens that the way we are interacting with the bank in that test does not lead us into the situation of acquiring the lock twice, even though that test is significantly more complex than the basics test.
— 11:52
And now we come to one of the biggest problems with using locks for isolation. It is all too easy to cause deadlocks. It is nearly impossible to understand all possible code paths that can execute in our class. It’s even surprising that the basics test ran into the deadlock, even though it is a very simple test, and somehow the newAccountRush did not run into the deadlock, even though it appears to actually be quite complex.
— 12:13
This class is even just a couple dozen lines and only a few methods, and already we’ve found ourselves in a deadlock. Can you imagine what would happen with dozens of objects, each with their own locks, and each calling methods on other objects?
— 12:25
I suppose one fix here is just to grab the accounts outside of the lock: 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 lock.withLock { try fromAccount.withdraw(amount) toAccount.deposit(amount) } }
— 12:32
I personally think this is very, very tricky code now. We have to be knowledgable in the implementation of account(for:) to know that it acquires a lock under the hood, and use that knowledge to make sure we invoke it outside of this method’s lock.
— 12:45
Another problem with this code is that we are now acquiring the lock 3 separate times, and that means we have 3 opportunities for code to interleave between each unlock and lock. Is that going to cause problems by allowing subtle race conditions to creep into our code? It’s honestly hard to tell, and in an ideal world this entire method would be a single, atomic transaction. But that’s just not the case right now. Recursive locks
— 13:05
We have now come face-to-face with just how tricky locks can be. By naively wrapping every single method in our class in a lock we have made it impossible to perform very mundane factorizations to our code, such as calling out to a helper method from another method. Doing so introduced a deadlock where within the same callstack we had two frames trying to acquire the lock. Brandon
— 13:24
There is another locking tool out there that aims to help with this situation. It allows a lock to be acquired multiple times from the same thread without deadlocking.
— 13:32
This other locking tool is called a “recursive” lock, because it allows one to re-entrantly acquire the lock. It unfortunately causes all new problems to arise, so let’s take a look.
— 13:45
Let’s bring back that deadlock: func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) throws { try lock.withLock { let fromAccount = try account(for: fromID) let toAccount = try account(for: toID) try fromAccount.withdraw(amount) toAccount.deposit(amount) } }
— 14:03
And swap out our NSLock for a NSRecursiveLock : // let lock = NSLock() let lock = NSRecursiveLock()
— 14:09
With that small change our whole test suite is passing and there is no more deadlock.
— 14:16
And that’s because this type of lock allows for re-entrancy, that is we can acquire the lock twice, but only if each acquisition is from the same thread. There are still other kinds of deadlocks that can happen, but this simple type of deadlock is no longer possible.
— 14:34
But we have only traded one problem for another. We fixed a deadlock at the cost of no longer truly having a locked transaction around the transfer. When we see the withLock invocation we expect the code in the trailing closure to be executed as a single atomic unit, and that all other access to our state will be blocked until this transaction is finished. And we may use that assumption to implement very subtle logic in this method. But this property of locking is no longer true with recursive locks, and allows us to inspect the state of the system while in the middle of a transaction.
— 15:12
To see why this is a problem, suppose that we enhanced our Account with a callback that can notify the outside whenever a withdraw has been performed: class Account: Identifiable { … let didWithdraw: () -> Void … func withdraw(_ amount: Int) throws { … balance -= amount didWithdraw() } }
— 15:44
That seems like a pretty innocent thing to do. And suppose that in the bank, when a new account is opened, we provide a didWithdaw callback closure that simply prints the bank’s total deposits: accounts[id] = Account(id: id, balance: initialDeposit) { print("Current bank deposits =", self.totalDeposits) }
— 16:09
This too also seems like a simple thing.
— 16:11
However, this is very subtle re-entrant locking code happening here. To see the problem, let’s just run the basics() test to see the following printed to the console: Current bank deposits 150
— 16:25
Inside that trailing closure of the Account it sees the bank having only 150 in total deposits. But that’s completely wrong. There is 200 in deposits because there are two accounts with 100 each. The reason we see 150 here is because it is printed while we are in the middle of a transaction that transfers 50 from one account to another.
— 17:04
This shows that we are sadly able to inspect the incorrect state of the bank because we introduced a recursive lock. We only did that because we were trying to fix a deadlock problem, but all it did was introduce a data integrity problem.
— 17:45
And in general, there is rarely a good reason to use recursive locks. One of its only legitimate use cases is when needing to protect mutable state that interacts with a 3rd party library that you do not control, and that library can call into your code in a re-entrant fashion. Sometimes that forces you to use a recursive lock. But that is not the situation we are in now, so for now we are going to go back to a plain NSLock . Next time: A new data race appears Brandon
— 18:21
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
— 18:44
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.
— 19:03
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.
— 19:28
Let’s take a look at why this is a problem…next time! References NSLock An object that coordinates the operation of multiple threads of execution within the same application. https://developer.apple.com/documentation/foundation/nslock 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 0358-beyond-basics-isolation-pt4 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 .