EP 363 · Isolation · Apr 20, 2026 ·Members

Video #363: Isolation: Actor Reentrancy

Local

Video #363: Isolation: Actor Reentrancy

Episode: Video #363 Date: Apr 20, 2026 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep363-isolation-actor-reentrancy

Episode thumbnail

Description

We explore the concept of “reentrancy” in actors, and how innocently adding async-await to an actor method can open you up to a world of race conditions. This problem also shows up when we naively communicate between actors, but we can solve things in a non-naive way and make actor communication completely synchronous.

Video

Cloudflare Stream video ID: 2d7445d6d0b037558ad7375993110f06 Local file: video_363_isolation-actor-reentrancy.mp4 *(download with --video 363)*

References

Transcript

0:05

So this is yet another thing we get to do with actors that cannot be done with legacy locking tools. We get insight into every job that is enqueued into an actor, and we can start to understand how the await s in our code correspond to jobs are enqueued. It seems that sometimes Swift is capable of eliminating unnecessary job enqueuing when executing one actor method right after another. But if any work needs to be done between those methods, then Swift has no choice but to enqueue another job.

0:34

However, our run method is a surefire way to squash enqueued jobs. Only a single job is enqueued into the actor, and after that you are free to interact with the actor as much as you want without paying any additional costs. We are even going to soon see that getting direct access to an actor’s serial executor allows us to do some powerful things, such as make two separate actors share the same executor so that they speak to each other synchronously. Stephen

1:02

But before getting to that we must first understand actor reentrancy. So far we have been very lucky that all work performed in the actor has been synchronous, but it is common that one needs to perform async work in the actor, such as network requests, database queries, and more.

1:16

This is possible to do, but opens you up to something called “reentrancy”, which is similar to the reentrancy we saw in recursive locks, and this is where Swift deviates from actors in other languages. Historically actors have not been reentrant, which is some ways is a simpler model, but does make it possible to deadlock.

1:34

Let’s take a look at an example of actor reentrancy, show why it can be be problematic in practice. Actor reentrancy

1:42

Imagine that our transfer method needed to call out to some external service in order to facilitate the transfer. Perhaps it needs to call some kind of fraud detection service. I’m not going to make this change directly to transfer , since that will require updating a bunch of tests, so let’s add a new method that emulates this: public func checkedTransfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) async throws { let fromAccount = try account(for: fromID) let toAccount = try account(for: toID) try fromAccount.withdraw(amount) try await Task.sleep(for: .seconds(1)) // Fraud check toAccount.deposit(amount) }

2:14

It seems innocent enough, but this is actually a fundamental shift in how this actor behaves. When the method suspends for the async work, the actor is freed up to do other work at the same time. That means some other part of our code can ask the bank for its total deposits, open a new account, and really do anything, all while this checkedTransfer is still in the middle of executing.

2:32

We can even use our logging executor to see exactly what is happening under the hood. Let’s write a test for this checkedTransfer method: @Test func checkedTransfer() async throws { }

2:49

We will start by creating a bank and opening two accounts and asserting on the total deposits of the bank: let bank = Bank() let id1 = await bank.openAccount(initialDeposit: 100) let id2 = await bank.openAccount(initialDeposit: 100) try await bank.checkedTransfer(amount: 50, from: id1, to: id2) #expect(await bank.totalDeposits == 200)

3:21

This test passes, but it takes a second to run because of the async work we are performing inside checkedTransfer.

3:32

Now in between opening the second account and performing the transfer we will fire up a concurrent task, wait just half a second, and the check the deposits of the bank: Task { try await Task.sleep(for: .seconds(0.5)) #expect(await bank.totalDeposits == 200) }

3:49

By waiting half a second we should be able to wiggle ourselves into the exact moment the checkedTransfer method is suspending to do its fraud check.

3:56

If we run this test we get a failure: Expectation failed: await bank.totalDeposits == 200

4:00

This completely breaks my mental model for how this bank actor should work. In my mind I know the checkedTransfer is still processing the transfer, and so I would expect the total deposits of the bank to remain 200 until that transfer is done.

4:12

But, due to actor reentrancy that is not the case. The 50 has been transferred out of the first account already and is stuck in purgatory until the fraud check is done. So, I guess we have no choice but to assert that the total bank deposits is 150, even though that doesn’t seem quite right: #expect(await bank.totalDeposits == 150)

4:27

Now the test passes. To get insight into what jobs were enqueued, let’s bring back the print(#function) … public func checkedTransfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) async throws { printFunction() … }

4:29

Now when we run the test we get the following printed to the console: -> Job enqueued: ExecutorJob(id: 29) @ priority 25 openAccount(initialDeposit:) openAccount(initialDeposit:) -> Job enqueued: ExecutorJob(id: 29) @ priority 25 checkedTransfer(amount:from:to:) account(for:) account(for:) -> Job enqueued: ExecutorJob(id: 30) @ priority 25 totalDeposits -> Job enqueued: ExecutorJob(id: 29) @ priority 25 totalDeposits

4:32

And this shows actor reentrancy clear as day. We enqueue a job to handle opening the two accounts and then enqueue a job to start the checked transfer. Already this is a bit different from what we previously noticed with the non-async transfer method. When inspecting the logs for a plain transfer we saw that a single enqueued job could service opening the accounts, performing the transfer, and inspecting the total deposits: -> Job enqueued: ExecutorJob(id: 37) @ priority 25 openAccount(initialDeposit:) openAccount(initialDeposit:) transfer(amount:from:to:) totalDeposits

4:50

But because checkedTransfer is async , Swift has no choice but to enqueue another job because it thinks there is going to be async work to perform.

4:57

OK, so that all makes sense, but then something interesting happens. A new job is enqueued with a new ID that checks the total deposits. That is due to the Task we spun up. And then a moment later the previous job with ID 37 resumes in order to check the total deposits at the end of the test. It’s worth noting that after the fraud check in checkedTransfer there were two jobs enqueued. One is to complete the rest of the checkedTransfer method, and then another to perform the totalDeposits assertion.

5:13

This means we can no longer think of this method as an atomic unit that is executed at once while all other work on the actor is suspending. We can’t even use the run method due to the asynchrony of checkedTransfer : let bank = Bank() try await bank.run { bank in let id1 = bank.openAccount(initialDeposit: 100) let id2 = bank.openAccount(initialDeposit: 100) try bank.checkedTransfer(amount: 50, from: id1, to: id2) #expect(bank.totalDeposits == 200) } ‘async’ call in a function that does not support concurrency

5:47

So we have opened ourselves up to the state of the actor being able to change due to outside forces while in the middle of executing the method.

5:54

And this subtle behavior is exactly what plagued recursive locks when we looked at them a few episodes ago. In that episode we saw that recursive locks fixed a deadlocking problem, but only introduced a new problem in which we were allowed to inspect intermediate states of the bank that were not meant to be seen. That is exactly what we are seeing here with the actor, in which we were allowed to see the total deposits was temporarily 150, even though the bank did have a full 200 in its control.

6:19

And just as recursive locks solved a deadlocking problem, reentrancy in actors was also allowed largely to solve a deadlocking problem. This is documented quite a bit in the actors proposal, SE-0306 .

6:31

If we navigate to the “Actor reentrancy” section we will find a very detailed explanation of what reentrancy is and why it can be problematic. But it also details what happens when one disallows reentrancy. The example given, “ Deadlocks with non-reentrant actors ,” is a little convoluted, so here is a more distilled version: actor Foo { func operate(_ bar: Bar) async { await bar.operate(foo) } func finish() {} } actor Bar { func operate(_ foo: Foo) async { await foo.finish() } }

6:55

We have two actors: Foo and Bar . When one calls the operate method on Foo , it immediately calls the operate method on Bar , which in turns calls the finish method back on Foo . If actors were non-reentrant in Swift, this would be a deadlock. The Foo actor cannot process the finish message until it is done processing the operate message, but it cannot complete until finish does. This is a cycle that will never be broken, and so will suspend forever.

7:20

The proposal illuminates another downside to non-reentrant actors with this code sample: // assume non-reentrant actor ImageDownloader { var cache: [URL: Image] = [:] func getImage(_ url: URL) async -> Image { if let cachedImage = cache[url] { return cachedImage } let data = await download(url) let image = await Image(decoding: data) return cache[url, default: image] } }

7:34

This kind of code in a non-reentrant actor would serialize the downloading of images. If we spun up a task group with 100 tasks to download 100 images with this actor, it would naively download each image one after another. There would be no possibility for parallelization without reentrancy.

7:49

After explaining the tradeoffs of reentrant and non-reentrant actors, the proposal discusses 3 examples of prior art from Erlang, Scala and C#. And in stark contrast to Swift, all 3 of these have chosen to implement non-reentrant actors, which means they trade the potential problem of race conditions with the potential problem of deadlocks. And you’d be surprised what the recommended pattern for avoid deadlocks is.

8:12

In Erlang, gen_server is a common library to help make use of actors. The article linked from the proposal shows just how easy it is to cause a deadlock with gen_server : do_withdraw(_UserID, _Amount) -> ok. do_maybe_withdraw() -> %% You did some checking %% and decided to withdraw from account %% %% Imaging any call here, irrelevant to the problem %% you just need to get some UserID to make a withdraw UserID = get_user_id_to_withdraw(), AmountToWithdraw = 1000, %% BUG! %% Can you guess why ? %% What call do you need to do instead ? withdraw(UserID, AmountToWithdraw)

8:26

The act of calling one function, withdraw , from another function, maybe_withdraw , is a deadlock.

8:30

That seems pretty bad, but the fix is this: Note The fix is pretty easy. All you need is to call an internal function that responsible for handling your public one.

8:42

That is, instead of calling the public withdraw function defined in this Erlang module you call the “internal” version that is prefixed by do_ : -withdraw(UserID, AmountToWithdraw) +do_withdraw(UserID, AmountToWithdraw)

8:50

That is incredibly subtle.

8:51

Can you imagine if the fix to the hypothetical deadlock we showed a moment ago was to make finish private: actor Foo { func operate(_ bar: Bar) async { await bar.operate(foo) } private // NB: Very important to avoid deadlock!!! func do_finish() {} public func finish() { do_finish() } } actor Bar { func operate(_ foo: Foo) async { await foo.finish() } }

9:01

That change would be enough to tell Swift to allow reentrancy back into Foo because it’s private. And if we ever need to make finish public we may unknowingly introduce a deadlock in our code. And the fix would be to do a weird dance like this: private // NB: Very important to avoid deadlock!!! func do_finish() {} public func finish() { do_finish() }

9:29

That would be really annoying. The wrong way to use actors

9:32

So when it comes to reentrancy in actors, I think it is fair to say: you’re damned if you do and your damned if you don’t.

9:37

The reality today is that actors in Swift are reentrant, and that has made quite a few people in the Swift community upset, and they have shunned actors as a result. But had Swift chosen non-reentrancy for actors, there would only be another set of people out there angry that it is so easy to create deadlocks and that you have to contort your code in strange ways to avoid the deadlocks.

9:56

At the end of the reentrancy section of the proposal, there is a final summary for why reentrancy was chosen, and it boils down to the fact that deadlocks and the serialization of work was more of a downside than reentrancy. And it’s always possible to design one’s actors in a way that protects invariants of state even when reentrancy is present, so they felt it was the best tradeoff. And they further leave open the possibility of introducing new tools to enforce reentrancy on actors in the future. Brandon

10:22

OK, now it’s time to take what we have just learned about actor reentrancy, and what we previously learned about squashing multiple suspension points into a single one, and explore a brand new to way to completely eliminate suspension points. That’s right, it is sometimes possible to write code that interacts with an actor in a completely synchronous manner so that you don’t even have to await at all. That can greatly simplify your code and make it possible to avoid actor reentrancy where not needed.

10:49

To explore this we are going re-examine a lesson we previously learned when we tried to make the Account class Sendable .

10:58

Let’s take a look.

11:00

Suppose that we wanted to make the Bank.Account type sendable. Currently it’s a class with mutable state, and so it isn’t sendable: final class Account: Identifiable { … }

11:07

Previously we saw that we could introduce a mutex to protect the state in this account to make it safe to use from multiple threads, but it was annoying to do because we had to move all of the state into a dedicated struct and lock it: final class Account: Identifiable, Sendable { let id: UUID let state: Mutex<State> struct State { var balance: Int var balanceHistory: [Int] = [] } … }

11:26

And we of course had to further wrap all methods in withLock , while accepting that we may someday introduce deadlocks on accident.

11:36

And this further led to the unfortunate side effect that computing the total deposits in the bank leads to incurring a lock/unlock for each account: var totalDeposits: Int { accounts.withLock { $0.values.reduce(into: 0) { $0 += $1.state.withLock(\.balance) } } }

11:47

And so that means if the bank has 10,000 accounts, then we are locking and unlocking 10,001 times in this simple computed property. And mutexes are fast, but they are not free.

11:57

Well, let’s see what happens if we use actors to isolate the data in an account. All we have to do is just swap out the class for actor : actor Account: Identifiable { … }

12:06

…and now the data in Account is isolated and the type is sendable.

12:10

However, this of course causes some problems. We have a bunch of compiler errors throughout the bank where we are accessing data and methods inside the account in a non-isolated way. We are now forced to await these accesses.

12:24

For the checkedTransfer method this is no big deal because it’s already async: func checkedTransfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) async throws { let fromAccount = try account(for: fromID) let toAccount = try account(for: toID) try await fromAccount.withdraw(amount) try await Task.sleep(for: .seconds(1)) // Fraud check await toAccount.deposit(amount) }

12:36

The transfer method is not so lucky. It is not async, nor should it be, because we need to perform the withdraw and deposit all at once without any other actor jobs running in between. We need that so that a race condition does not creep into this transaction.

12:58

But, let’s just push forward a bit more to see how things evolve. We’ll make transfer async so that we can accommodate the account being isolated: func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) async throws { printFunction() let fromAccount = try account(for: fromID) let toAccount = try account(for: toID) try await fromAccount.withdraw(amount) await toAccount.deposit(amount) } And thanks to our discussion on reentrancy in actors, we know that this is most likely not the right thing to do. We have now opened ourselves up to other actor jobs interleaving between the two account(for:) methods. We have lost the guarantee that the withdraw and deposit will be done in a single atomic unit, and that all other actor interactions will be suspended until this work is done.

13:18

But it gets worse. Even the totalDeposits computed property needs to become async : public var totalDeposits: Int { get async { var sum = 0 for account in accounts.values { sum += await account.balance } return sum } }

13:53

Again this leaves us open to reentrancy. While in the middle of compute the total deposits we could withdraw and deposit from various accounts, causing the deposits to be an ambiguous mixture of new and old data in the bank.

14:19

So, this has turned into a real mess, but at least the bank and account code is now compiling. Our tests, however, are another story. A bunch of tests are no longer compiling because we need to add await s to them. And we can do that for many, but some do not have such an easy fix.

14:49

For example, the transaction test wanted to perform a transfer and assert on account balances all at once in a single atomic unit: @Test func transaction() async throws { let bank = Bank() let id1 = await bank.openAccount(initialDeposit: 100) let id2 = await bank.openAccount(initialDeposit: 100) try await bank.run { bank in let startingDeposits = bank.totalDeposits try bank.transfer(amount: 50, from: id1, to: id2) #expect(bank.totalDeposits == startingDeposits) #expect(try bank.account(for: id1).balance == 50) #expect(try bank.account(for: id2).balance == 150) } }

15:08

This was facilitated by the run method, which allowed us to pay the cost of actor isolation a single time, and then once in the trailing closure we could perform as much synchronous work as we wanted.

15:23

But now we cannot compute the bank.totalDeposits , or make a transfer, or access an account’s balance. All of those operations are now asynchronous, and so we have no choice but to drop the bank.run , which just opens us up to race conditions since other threads can wiggle themselves in between each of our awaits. Eliminating actor suspension points

15:52

Everything we are seeing here is leading me to believe we should not pursue this way of making Account sendable. Making the Bank class an actor seemed to work great, but making Account into an actor has leaked suspension points throughout our code, add reentrancy to a few core operations on the bank, and made it impossible to execute atomic units of work in the bank.

16:12

And this right here is another reason why so many people in our community think actors are a bad tool. When approached naively they do indeed force you to sprinkle in a bunch of awaits throughout your code, make things that used to be synchronous asynchronous, and introduce a lot of potential race conditions in your code due to the proliferation of awaits. Stephen

16:31

But what if we told you it does not have to be this way. What if we could make it so that the Account is an actor in its own right, but that it shares its isolation with the Bank . This would that the bank gets to interact with accounts in a synchronous fashion, and in general, if we prove a single time that we have isolation with the bank, then we get unfettered synchronous access to all of its accounts.

16:54

Sounds too good to be true, but it is! Let’s check it out.

17:00

Right now the Account actor has its own isolation that is not only different from the bank, but even every Account instance has different isolation from every other Account instance.

17:08

What if we allowed an isolation to be passed into Account so that its isolation could be controlled from the outside. We can even pin it directly to a Bank actor: actor Account: Identifiable { … private let isolation: any Actor init( id: UUID, balance: Int = 0, isolation: Bank ) { … self.isolation = isolation } … }

17:28

And we can override the Account ’s executor to be that of the isolation handed to us: public nonisolated var unownedExecutor: UnownedSerialExecutor { isolation.unownedExecutor }

17:36

Then when opening an account we will connect the new Account ’s isolation to that of the bank: accounts[id] = Account(id: id, balance: initialDeposit, isolation: self)

17:43

Everything now compiles, and we now have the tools to start synchronously interacting with accounts from the bank’s isolation domain.

17:50

Take for example the transfer method, which previously had to be made async so that we could interact with isolated accounts to withdraw and deposit: func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) async throws { printFunction() let fromAccount = try account(for: fromID) let toAccount = try account(for: toID) try await fromAccount.withdraw(amount) await toAccount.deposit(amount) }

17:58

Well, now we know that the isolation of the account matches that of the bank, and so we should be able to access it synchronously. To do this we can tell the compiler to trust us, that we know the current isolation matches that of the account, and so we should be allowed to access account’s properties and methods synchronously: func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) throws { printFunction() let fromAccount = try account(for: fromID) let toAccount = try account(for: toID) try fromAccount.assumeIsolated { try $0.withdraw(amount) } toAccount.assumeIsolated { $0.deposit(amount) } }

18:47

In fact, we can even make the account(for:body:) method give us an isolated account so that we can hide away this assumeIsolated : func account<R: Sendable>( for id: Account.ID, body: @Sendable (isolated Account) throws -> R ) throws -> R{ printFunction() return try account(for: id).assumeIsolated { try body($0) } }

19:11

And now we can make transfer short and sweet: func transfer( amount: Int, from fromID: Account.ID, to toID: Account.ID ) throws { printFunction() try account(for: fromID) { try $0.withdraw(amount) } try account(for: toID) { $0.deposit(amount) } }

19:35

Now everything compiles, and we have just gotten our first glimpse into completely eliminating a suspension point. Previously when we introduced the Actor.run method we saw that we could squash multiple suspension points into a single one. But there still needed to be at least one suspension point to get into the actor’s isolation domain. This time we are seeing the complete elimination of a suspension point because we know that since we are in the bank’s isolation we must also be in the account’s isolation.

20:02

This has also completely fixed the potential for reentrancy problems in this method. We are back to this method being a single atomic unit that is guaranteed to run while all other access to the bank actor are suspended. There is no possibility for interleaving in this code now.

20:16

We can also make the totalDeposits synchronous now: var totalDeposits: Int { var sum = 0 for account in accounts.values { sum += account.assumeIsolated { $0.balance } } return sum }

20:34

And now we are back to the state of our bank prior to make Account an actor. All methods are synchronous except for the checkedTransfer method, which is OK because that method did actually want to perform some async work. It performs an async fraud check.

20:46

The tests aren’t yet compiling, but there are only two errors, on these lines: #expect(try bank.account(for: id1).balance == 50) #expect(try bank.account(for: id2).balance == 150)

21:22

This isn’t compiling because we are synchronously trying to retrieve an account and then access the balance in the account. In these lines of code we haven’t proven to the compiler that we are in the account’s isolation. To do so we should use the account(for:) method that takes a trailing closure: try bank.account(for: id1) { #expect($0.balance == 50) } try bank.account(for: id2) { #expect($0.balance == 150) } Now everything compiles, and tests are passing.

21:32

It is worth noting that we are making a big decision by using assumeIsolated : try account(for: id).assumeIsolated { try body($0) }

21:37

This is us telling the compiler to trust us. We know that the current isolation matches the account’s isolation, and so it’s ok to dive right into the account synchronously. If we ever get this wrong this will crash at runtime.

21:51

However, since we control the Account actor, and we even forced its initialization to be handed a bank: init( id: UUID, balance: Int = 0, isolation: Bank ) { … }

21:58

…we can feel a bit safer that these isolations will indeed match up. We could even make the initializer private so that we have further control over the situation: fileprivate init( id: UUID, balance: Int = 0, isolation: Bank ) { … }

22:08

But still, at the end of the day it will be our responsibility that we never interact with this account in an invalid manner. It’s best to keep this detail hidden deep down in private library code so that we can be sure we have complete control over the situation. Next time: Actor performance

22:20

OK, I hope everyone agrees that we are now cooking.

22:23

We have shown that if one naively uses actors to provide data isolation, then you do indeed need to sprinkle in awaits in places you don’t expect, and that forces things to be async that should not be async. So on that point, the detractors of Swift actors are correct.

22:37

But, there is a non-naive way to approach actors that completely fixes this problem. It is possible to create a system of actors that share isolation and communicate with each other in a synchronous fashion. This allows us to design the Bank and Account type as actors that can talk to each other without making everything async, and most importantly avoiding reentrancy, while on the outside any access to the data or methods in the bank and account are protected by actor isolation. The outside must suspend in order to get exclusive access to the actor. Brandon

23:06

We have now covered nearly every trick that we are employing in the next generation of the Composable Architecture. By taking strict control over isolation in the Store we are able to guarantee the same isolation through the full lifecycle of a feature: from the moment an action is sent, through to when the feature updates its state, and all the way to where the effect executes. And that controlled isolation allows us to do things that we previously thought was impossible. Stephen

23:37

Before moving on from actors and on to the last topic in isolation, which is region-based isolation, we want to take a moment to discuss performance. There is unfortunately quite a bit of misinformation in the community that leads many to believe that actors are not performant, and that legacy locks are more performant. Those comparisons are almost never apples-to-apples, and instead are apples-to-some strange fruit no one has heard of. Brandon

24:00

Now, it’s certainly true that the cost of acquiring exclusive access to an actor is slower and more heavyweight than a lock. It requires suspending, which necessitates involving Swift’s complex task scheduler and depending on the surrounding priority your suspension may be deprioritized to let other work execute first. Stephen

24:19

However, as we’ve seen in the past few episodes, it often possible to squash multiple suspension points into a single one, and sometimes even possible to completely eliminate suspension points. That means we are able to do more work in the actor without paying the cost of the task scheduler, and when done correctly that gives massive performance improvements. And there just is no equivalent to this story in locks. Locks are not very malleable, and you will just always pay their cost, or you will have to contort your code in some really strange ways to make it possible to elide locking. Brandon

24:53

So, let’s explore the performance of actors and locks by examining a benchmark that has been used in our community to claim that actors are slower than locks…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 0363-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 .