Video #361: Isolation: Actors
Episode: Video #361 Date: Apr 6, 2026 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep361-isolation-actors

Description
After fighting with legacy locking and mutexes let’s explore a modern alternative: actors. We will refactor our data race-sensitive class to an actor and see just how simple and flexible its implementation can be, and we will grapple with something it introduces that locking did not: suspension points.
Video
Cloudflare Stream video ID: d33f6869d22532fcaa9508c3fb1ed9a6 Local file: video_361_isolation-actors.mp4 *(download with --video 361)*
References
- Discussions
- Swift evolution proposal SE-0306
- Actor
- 0361-beyond-basics-isolation-pt7
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
And so what we are seeing here is that making everything sendable is not the way. Sendability should be applied rarely and surgically, and there is actually a lot of power in keeping things non-sendable. It allows you to interact with those objects in a completely synchronous manner with no locking whatsoever, and Swift can have your back to make sure you never accidentally leak that object across threads in ways that could cause data races. Stephen
— 0:29
OK, we have now spent a lot of time talking about older-style tools for isolating objects that are not safe to use from multiple threads, primarily locks. They are a very crude tool for synchronizing access to a resource that is not thread safe, and while some modern annotations such as @Sendable and sending can make these locks safer to use, they are still quite problematic.
— 0:50
Perhaps the biggest problem is just that the locking code infects all of your core feature logic. We ended up wrapping every single method in the Bank class in withLock , and not only did that make a mess of our code, but it technically isn’t even 100% correct. Brandon
— 1:06
Further, because locks and mutexes are not re-entrant, and because recursive locks are a whole problem on their own, we are prevented from freely refactoring our code to call little helpers from our methods because we may accidentally introduce a deadlock. Stephen
— 1:21
And finally, because we needed to individually protect each access to the mutable state we were naturally led to a situation that caused us to lock and unlock a ton more than should be necessary. These locks aren’t free, and we over time we are going to pay the cost of locking as our feature gets more and more complex.
— 1:38
And so by the end of these little explorations I just no longer really know how to reliably make changes to this bank class or trust that it even works correctly. Brandon
— 1:47
And this is where actors and isolation domains formally enter the stage. Actors give the Swift compiler a static understanding of isolation domains, and this allows us to weaken the ceremony required to enforce isolation even more. Recall that when exploring locks we saw that by using more and more advanced features of Swift we could loosen the restrictions of locks while not sacrificing safety:
— 2:11
First we saw that OSAllocatedUnfairLock used @Sendable closures for its withLock method, and that meant we couldn’t capture non-sendable state in the closure or even return non-sendable state from the closure. Stephen
— 2:22
Then we saw that Mutex used sending instead of @Sendable , and that allowed us to start capturing and returning non-sendable state, but only if that state was never touched again afterwards.
— 2:33
Actors allow us to go one step further where we just get complete, unfettered access to mutable state with no locks whatsoever, but the catch is that we must first prove to the Swift compiler that we are in the right isolation domain. If you cannot prove that then you must await interacting with the actor to get into its isolation domain. And you may think that will add a proliferation of await s and asynchrony throughout your code, but when used properly that is not necessarily the case. Brandon
— 3:00
And unfortunately there is quite a bit of misinformation in the Swift community that one can simply avoid Swift’s concurrency tools, such as actors, by just using locks, but that is incredibly far from the truth. There really is no comparison because locks do not come anywhere close to achieving what modern Swift concurrency tools achieve. Stephen
— 3:21
Let’s explore this topic deeply by recreating our bank library with actors. We will see that if approached naively we will create all new problems for ourselves, although at least the data races will be fixed. And then we will make use of some of the more advanced tools of Swift to properly use actors for implementing this library.
— 3:38
Let’s take a look. Bank actor
— 3:41
I’ve got our isolation explorations SPM package opened, but one change I have made off camera is that i have added a new library. The previous library is now named LegacyIsolation and the new library is ActorIsolation . It’s in this new library that we will start exploring what actors bring to the table with regards to isolation.
— 3:54
We are going to start by just naively porting the Bank class to an actor to see what happens. I am going to literally copy-and-paste the Bank class from the legacy target over to this new target…
— 4:02
And now let’s start updating this legacy style of isolation to use actors. First things first, let’s delete the imports that have to do with locking: -import os -import Synchronization
— 4:10
…because we don’t want to use locks to enforce isolation. We would prefer to have a stronger static guarantee than what locks can offer.
— 4:16
Next we will upgrade the final class Bank to be an actor Bank : actor Bank { … }
— 4:20
And we don’t need to explicitly conform to Sendable because that is already done implicitly for us. All actors automatically conform to the Actor protocol: public protocol Actor: AnyObject, Sendable { … }
— 4:30
…which forces sendability.
— 4:34
Now that we are in an actor we can finally just hold onto plain, boring, mutable state instead of wrapping the state in locks and mutexes: private var accounts: [Account.ID: Account] = [:]
— 4:47
Already that is nice.
— 4:49
But even nicer, we can also start dropping the withLock pollution from all of our methods. For example, in the transfer method we no longer need to indent the actual implementation in withLock and can just access the accounts state directly: func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) throws { let fromAccount = try accounts.account(for: fromID) let toAccount = try accounts.account(for: toID) try fromAccount.withdraw(amount) toAccount.deposit(amount) }
— 5:06
That is like a breath of fresh air! No more noise and ceremony to do something so simple. We just get to access the state in the actor directly, and synchronously, and serialization will be enforced for us.
— 5:17
The openAccount method also gets quite simple: func openAccount(initialDeposit: Int = 0) -> Account.ID { let id = UUID() accounts[id] = Account(id: id, balance: initialDeposit) return id }
— 5:27
And totalDeposits : var totalDeposits: Int { accounts.values.reduce(into: 0) { $0 += $1.balance } }
— 5:42
And skipping the next method we have the account(for:body:) method at the bottom, which can also be simplified: func account<R: Sendable>( for id: Account.ID, body: @Sendable (Account) -> R ) throws -> R { try body(accounts.account(for: id)) }
— 5:54
And let’s revert the mutexes in Account to get back to the original, non-sendable implementation…
— 6:17
Now let’s take a look at the method we skipped. it’s the account(for:) method that returns an Account : @available(*, deprecated, message: "This is dangerous to use!") func account(for id: Account.ID) throws -> Account { try accounts.withLock { guard let account = $0[id] else { struct AccountNotFound: Error {} throw AccountNotFound() } return account } }
— 6:21
We showed that it is indeed possible to invoke it without any compiler errors: // NB: The fact that this compiles is a bug in Swift. func swiftBug() throws { let account = try account(for: UUID()) _ = account }
— 6:31
This is a completely dangerous method to use and if it were not for a bug in Swift it should not compile. It was allowing us to escape a non-sendable object from a sendable one. That meant we could transport the sendable Bank to a bunch of threads, and then escape the same Account to each thread and start accessing its state. That is not correct at all, and will eventually lead to data corruption and possibly runtime crashes.
— 6:51
So, let’s see what happens when we try to implement this method in the actor by dropping the withLock : func account(for id: Account.ID) throws -> Account { guard let account = accounts[id] else { struct AccountNotFound: Error {} throw AccountNotFound() } return account }
— 7:02
Interesting it compiles. And even this compiles: func isThisOK() throws { let account = try account(for: UUID()) _ = account }
— 7:33
But you may be suspicious that this is not actually correct. After all, we see to be able to escape a non-sendable object from a sendable one, so isn’t this wrong? Is there a chance that there is another Swift bug lurking in the shadows? Well, in a moment we will find this not the case. This is actually 100% safe.
— 7:51
OK, our actor-ification of the Bank is complete, and things are compiling. But before moving onto tests where we will show what it takes to interact with such an object, let’s clean up some unfortunate code that we had to write when using locks.
— 8:02
We previously defined this silly method on dictionaries for retrieving an account: extension [Bank.Account.ID: Bank.Account] { struct AccountNotFound: Error {} func account(for id: Key) throws -> Value { guard let value = self[id] else { throw AccountNotFound() } return value } }
— 8:10
We had to do this because we couldn’t define this method directly in the Bank because it led to deadlocks. If this account is protected by a lock and it’s invoked from another method protected by a lock, then you have a deadlock on your hands.
— 8:21
But actors don’t have this limitation. It is 100% OK to call actor methods from other actor methods, and synchronization is still guaranteed and there is no risk for deadlocking. So let’s delete that helper…
— 8:32
And everywhere we used it we will instead use the account(for:) method defined on the actor that compiles, but we are still suspicious of its validity. For example, in the transfer method we can simply do: let fromAccount = try account(for: fromID) let toAccount = try account(for: toID) And in account(for:) we can do: try body(account(for: id))
— 8:43
Now everything compiles, and we have code that looks almost identical to the version of Bank when we were using Swift 5 mode and had a class with no locking whatsoever: actor Bank { private var accounts: [Account.ID: Account] = [:] 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) } func openAccount(initialDeposit: Int = 0) -> Account.ID { let id = UUID() accounts[id] = Account(id: id, balance: initialDeposit) return id } var totalDeposits: Int { accounts.values.reduce(into: 0) { $0 += $1.balance } } func account(for id: Account.ID) throws -> Account { guard let account = accounts[id] else { struct AccountNotFound: Error {} throw AccountNotFound() } return account } func totallyFine() throws { let account = try account(for: UUID()) _ = account } func account<R: Sendable>( for id: Account.ID, body: @Sendable (Account) -> R ) throws -> R { try body(account(for: id)) } class Account: Identifiable { let id: UUID var balance: Int var balanceHistory: [Int] = [] init(id: UUID, balance: Int = 0) { self.id = id self.balance = balance } func deposit(_ amount: Int) { balanceHistory.append(balance) balance += amount } func withdraw(_ amount: Int) throws { guard balance >= amount else { struct InsufficientFunds: Error {} throw InsufficientFunds() } balance -= amount } } }
— 8:53
This is now very naive code, doing the simplest thing possible in each method. There is no locking, no opportunity for deadlocking, and really looking at this code you wouldn’t even think it is enforcing synchronization at all. The only hint that it is thread safe is that the type is marked as actor . Interacting with actors
— 9:08
OK, we have now upgraded the Bank class to be an actor, and that allowed us to get rid of all locks and mutexes, removed a ton of annoying code from the bank, we can now easily call helpers defined on the actor from other methods on the actor, and finally it seems like a previously unsafe operation is now possibly safe. So this all seems positive so far. Brandon
— 9:27
But we haven’t yet actually written any code that interacts with the actor. That is where the differences between actors and locks really start to show. Locks move the responsibility of synchronization to the library code, such as the Bank , but externally no one needs to know that there is any locking happening.
— 9:47
Actors flip this. There is no sign of synchronization inside our library code, the Bank , but when interacting with the bank from the outside you will come face-to-face with the fact that synchronization is required to get access to anything inside the actor. And the way this happens is by await ing any data access or method invocation. That allows the actor to suspend in a non-blocking fashion until it is free to fulfill your request.
— 10:16
Let’s take a look at this by upgrading the test suite to deal with the Bank actor.
— 10:22
I am going to start by copying-and-pasting the previous test suite into our new test target:
— 10:27
And importing the ActorIsolation target instead of LegacyIsolation: @testable import ActorIsolation
— 10:33
This immediately creates a bunch of compiler errors for us to fix.
— 10:37
First is when trying to open a bank account: let id1 = bank.openAccount(initialDeposit: 100) Call to actor-isolated instance method ‘openAccount(initialDeposit:)’ in a synchronous nonisolated context
— 10:55
This is the first time we are seeing the word “isolated” appear in a Swift error message, and so we are finally seeing how Swift can enforce isolation correctness statically.
— 11:06
And remember, as we saw a few episodes, it was Swift evolution proposal SE-0306 that formally introduced actors, which in turn formally introduced isolation as a mechanism for protecting mutable state.
— 11:27
The Bank actor defines its own isolation domain that is independent of any other isolation domain out there, and Swift knows it. So if you try accessing state in the actor and invoking a method on the actor without proving to Swift you are in its isolation domain, you will get this error.
— 11:49
The easiest way to force yourself into the isolation domain of the actor is to await accessing the state or invoking the method. That allows Swift to suspend until the actor is no longer processing other requests, and then once the actor is free our code will un-suspend and will being running our code.
— 12:22
So, let’s upgrade our test function to be async : @Test func basics() async throws { … } …and await all interactions with the bank: @Test func basics() async throws { let bank = Bank() let id1 = await bank.openAccount(initialDeposit: 100) let id2 = await bank.openAccount(initialDeposit: 100) try await bank.transfer(amount: 50, from: id1, to: id2) #expect(await bank.totalDeposits == 200) #expect( try await bank.account(for: id1) { $0.balance } == 50 ) #expect( try await bank.account(for: id2) { $0.balance } == 150 ) }
— 12:44
And now this test compiles. And just to show it passes let’s comment out all other tests so we can get a fully compiling test suite…
— 12:54
And we can run the test to see it passes. Now this is only exercising the most basic of behavior in the bank. There is no multithreaded behavior being tested. But it is at least interesting to see how the synchronization is pushed outward instead of hidden on the inside. We are confronted with the fact that we have to prove we have isolation to the actor before we are allowed invoking a method or accessing a property on the actor.
— 13:30
And while we are here, let’s poke at that account(for:) method that we were suspicious of. Remember this method: func account(for id: Account.ID) throws -> Account { guard let account = accounts[id] else { struct AccountNotFound: Error {} throw AccountNotFound() } return account }
— 13:42
…was dangerous when using mutexes because it allowed us to escape a non-sendable object from a sendable one. And the fact it compiled with Mutex was only a bug in Swift, and a pretty serious one.
— 13:57
But now this equivalent code is compiling with our actor. Is it correct? Well, let’s try using it in this test to get the balance of an account in a more direct fashion than using the continuation-based API: #expect(try await bank.account(for: id1).balance == 50)
— 14:23
We instantly get a compiler error, and unfortunately the error is hidden away inside the macro, but we can find the error in the issue navigator: Non-Sendable ‘Bank.Account’-typed result can not be returned from actor-isolated instance method ‘account(for:)’ to nonisolated context
— 14:42
This is exactly the kind of error we hoped to see with Mutex . It is not correct for us to escape a non-sendable object from an isolated domain to a non-isolated domain.
— 14:55
But the really remarkable thing is that it is ok to invoke this method when we are inside the actor: func totallyFine() throws { let account = try account(for: UUID()) print(account.balance) }
— 15:27
This is not transferring a non-sendable object from an isolated domain to a non-isolated domain. We are always safely inside the actor’s isolation domain, and so invoking this function is totally fine. It’s incredible to see how we are now free to write helper methods that are safe to use in some contexts, dangerous to use in others, but Swift has our back to make sure we never invoke them in an invalid way.
— 15:56
And this protection goes both ways. Not only does Swift prevent you from escaping non-sendable objects out of an isolation domain, it also prevents you from passing non-sendable objects into an isolation domain and then continuing to access them from the outside.
— 16:12
For example, suppose we added a method to our bank that takes an Account as an argument: extension Bank { func take(account: Bank.Account) { } }
— 16:22
This method is sometimes safe to invoke and sometimes not safe.
— 16:26
When invoked inside the Bank there is no problem because we are not moving between isolation domains: extension Bank { … func operate() { let account = Bank.Account(id: UUID()) take(account: account) print(account.balance) } }
— 16:43
The account is created in the bank’s isolation domain and passed to a method in its isolation domain, so nothing nefarious going on.
— 16:52
On the other hand, if we created an account outside of the bank’s isolation domain, and then tried to pass it in, we get an error: @Test func transferIsolation() async { let bank = Bank() let account = Bank.Account(id: UUID()) await bank.take(account: account) print(account.balance) } Sending ‘account’ risks causing data races
— 17:23
So Swift thinks there is potential for data races in this code, but what exactly is the problem?
— 17:28
Well, what if this take(account:) method decided to store it inside the Bank ? func take(account: Bank.Account) { accounts[account.id] = account }
— 17:44
This non-sendable object now lives in two domains: a non-isolated domain in the test, and an isolated domain in the actor. And it is completely possible for the actor to execute code in parallel to the test, and that means it’s possible for both the test and actor to access and mutate this account simultaneously.
— 18:14
This shows that Swift has our back with yet another subtle concurrency problem. It is not safe to transfer non-sendable objects out of actors or transfer them in.
— 18:24
So this is looking great, but let’s quickly fix the rest of the tests. The newAccountRush requires us to sprinkle in two await s to get things to compile: @Test func newAccountRush() async { let bank = Bank() await withTaskGroup { group in for _ in 1...1000 { group.addTask { await bank.openAccount(initialDeposit: 100) } } } #expect(await bank.totalDeposits == 100 * 1000) } And the busyDepositDay requires 3 awaits to get it compiling: @Test func busyDepositDay() async throws { let bank = Bank() let id = await bank.openAccount(initialDeposit: 0) await withThrowingTaskGroup { group in for _ in 1...1000 { group.addTask { try await bank.account(for: id) { account in account.deposit(100) } } } } #expect(await bank.totalDeposits == 100 * 1000) }
— 18:55
…and everything passes. We have now converted our mutex-based banking class to an actor, and gotten the full test suite to pass. And again the await bank.account serializes all access to the bank so that we do not get any data corruption or crashes. Next time: Squashing suspension points
— 19:21
OK, we are already seeing a number of ways that actors have improved upon locks and mutexes:
— 19:26
No locks or mutexes needed to synchronize state in the actor. We get direct, synchronous access to everything inside the actor without having to think about synchronization. Stephen
— 19:36
No locks means no deadlocks. We are free to refactor our code however we want and call little helpers from any method inside the actor, and it all just works. Brandon
— 19:45
Swift understands that the actor provides a custom isolation domain and forces us to prove that we are in that isolation domain before we can access the data. The easiest way to prove isolation is simply to await . Stephen
— 19:59
The compiler now has enough knowledge about isolation domains that it allows us to write a method that appears to escape non-sendable objects from sendable domains, and vice-versa, but in reality it’s only callable if we prove that we aren’t transferring the object outside of the actor’s isolation. That is just not possible with locks or mutexes.
— 20:16
This is only the beginning of the super powers of actors, and none of this was possible with locks alone. Brandon
— 20:22
However, all of these benefits came at the cost of making a test that was previously synchronous into an asynchronous one. And so this may lead you to believe that actors always force asynchrony, and that can be a real pain when you don’t have an asynchronous context. And that is why many people think that actors are not worth the trouble. Stephen
— 20:41
But, there are various tricks to reduce the number of suspension points you need to use to interact with an actor, and sometimes we can entirely eliminate suspension points and make everything synchronous. We are first going to discuss how to reduce suspension points. Brandon
— 20:55
Right now our basics test has 6 suspension points in order to open two accounts, transfer an amount from one account to another, check total deposits, and then assert on the balance of each account. That is 6 opportunities for multiple threads to interleave between each suspension point and change the state of the bank while we are in the middle of this 6-step process. Stephen
— 21:20
And this is the same problem we had with our lock and mutex-based bank, but the only difference now is that we can actually provide a general purpose tool to reduce these 6 suspension points into just a single one. And this just wasn’t possible with locks.
— 21:34
Let’s take a look…next time! References Actor Common protocol to which all actors conform. https://developer.apple.com/documentation/swift/actor 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 0361-beyond-basics-isolation-pt7 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 .