Video #362: Isolation: Actor Enqueuing
Episode: Video #362 Date: Apr 13, 2026 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep362-isolation-actor-enqueuing

Description
Using an actor seems to have forced us from a synchronous context to an asynchronous one, but it doesn’t have to be this way. We will show how with the proper tools we can squash many awaits down to a single one, and we will use “serial executors” to better understand how an actor enqueues work behind the scenes.
Video
Cloudflare Stream video ID: 55d6f60382fbf35978a3f8073c993d5a Local file: video_362_isolation-actor-enqueuing.mp4 *(download with --video 362)*
References
- Discussions
- Actor
- SE-0306: Actors
- 0362-beyond-basics-isolation-pt8
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
OK, we are already seeing a number of ways that actors have improved upon locks and mutexes:
— 0:10
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
— 0:21
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
— 0:29
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
— 0:43
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.
— 1:00
This is only the beginning of the super powers of actors, and none of this was possible with locks alone. Brandon
— 1:06
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
— 1:26
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
— 1:39
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
— 2:04
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.
— 2:18
Let’s take a look. Squashing suspension points
— 2:21
Let’s take a look at the basics test again: @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) }
— 2:23
This test used to be synchronous, but it’s async and has 6 await s. That means while suspending to open the second bank account some part of the code running in another thread could open up a bunch of accounts, make deposits, make transfers, and do whatever they want. And that can happen in between each set of awaits.
— 2:43
That makes it quite difficult to consider this sequence of operations as an atomic unit, and this can manifest itself as real world race conditions that introduce logical errors into your code. We can even concretely see this in a test.
— 2:55
Let’s start a new test that creates a bank and opens two accounts: @Test func nonAtomicProblem() async throws { let bank = Bank() let id1 = await bank.openAccount(initialDeposit: 100) let id2 = await bank.openAccount(initialDeposit: 100) }
— 3:08
Then we are going to do perform two tasks in parallel. The first task will do the transfer between accounts, but we are going to insert a Thread.sleep to emulate some actual blocking work happening between the suspensions: let transfer = Task { let startingDeposits = await bank.totalDeposits try await bank.transfer(amount: 50, from: id1, to: id2) _ = {Thread.sleep(forTimeInterval: 1)}() #expect(await bank.totalDeposits == startingDeposits) #expect(try await bank.account(for: id1) { $0.balance } == 50) #expect(try await bank.account(for: id2) { $0.balance } == 150) }
— 3:59
And in parallel to that task we will perform a withdraw on one of the accounts after a short delay: let withdraw = Task { try await Task.sleep(for: .seconds(0.5)) try await bank.account(for: id1) { try $0.withdraw(50) } }
— 4:31
And then we will wait for the tasks to finish: _ = try await (withdraw.value, transfer.value)
— 4:52
I think we would expect that if we capture the starting total deposits, then perform a transfer, that the total deposits should not change. However, that is not the case: Expectation failed: await bank.totalDeposits == startingDeposits
— 5:07
The withdraw task was able to wiggle its way in between the multiple suspension points, and break a very simple bit of logic that seemed reasonable. So, this isn’t right, and let’s mark it as such using withKnownIssue : await withKnownIssue { #expect(await bank.totalDeposits == startingDeposits) try #expect(await bank.account(for: id1) { $0.balance } == 50) }
— 5:33
What if we wanted to get exclusive access to the actor the duration of these operations so that all other access to the actor will suspend until we are done. That would cause the bank.withdraw operation to suspend until the atomic unit was done and would fix this problem.
— 5:50
This is something that is just not possible with locks, but is quite easy to do with actors. Let’s start by theorizing a syntax. What if on the bank actor we could invoke a method with a trailing closure: try await bank.run { bank in … } And further what if the trailing closure was proven to run with the same isolation as the bank instance so that we could interact with the bank directly without suspending: @Test func transaction() async throws { let bank = Bank() try await bank.run { bank in let id1 = bank.openAccount(initialDeposit: 100) let id2 = bank.openAccount(initialDeposit: 100) try bank.transfer(amount: 50, from: id1, to: id2) #expect(bank.totalDeposits == 200) #expect(try bank.account(for: id1) { $0.balance } == 50) #expect(try bank.account(for: id2) { $0.balance } == 150) } }
— 6:31
And not only can we interact with the bank in a synchronous fashion in this context, but we can even invoke methods that are unsafe to invoke outside of the bank’s isolation domain. For example, we should be able to get an account for an ID directly instead of using that trailing closure API: #expect(try bank.account(for: id1).balance == 50) #expect(try bank.account(for: id2).balance == 150)
— 6:47
This would allow us to pay the cost of entering the isolation domain of the actor a single time, and then we are free to interact with the actor in a synchronous fashion as much as we want, without paying any additional cost. Any other outside access to the actor will be forced to suspend until we run block is finished.
— 7:04
Incidentally, this is also very similar to the run static method defined on MainActor : await MainActor.run { … }
— 7:11
This performs a single hop to the main actor, and then you are free to perform as much synchronous work with the main actor as you want. The biggest difference with our run is that MainActor.run does not need to be passed an instance of the main actor since there is only one main actor. But our bank.run needs to be handed the Bank because each Bank instance is its own isolation domain and Swift needs to know which actor we are isolating to.
— 7:36
We can try naively implementing this method as one that takes a trailing closure that is handed an instance of the Bank , and then just invoke that closure with self : extension Bank { func run(_ body: (Bank) -> Void) { body(self) } }
— 7:55
Well, that’s not quite enough because we do need to allow the trailing closure to throw errors: Invalid conversion from throwing function of type ‘(Bank) throws -> Void’ to non-throwing function type ‘(Bank) -> Void’
— 8:01
So let’s upgrade body to be throwing, and run to throw the same error: extension Bank { func run<Failure: Error>( _ body: (Bank) throws(Failure) -> Void ) throws(Failure) { try body(self) } }
— 8:20
That fixes that problem, but now on this line: let id1 = bank.openAccount(initialDeposit: 100)
— 8:26
…we are getting the following error: Call to actor-isolated instance method ‘openAccount(initialDeposit:)’ in a synchronous nonisolated context
— 8:33
This is happening because Swift does not know that the trailing closure is executed in the exact same isolation domain as is determined by the bank argument handed to the trailing closure.
— 8:43
We can see from the implementation that is the case: extension Bank { func run<Failer: Error>( _ body: (Bank) throws(Failure) -> Void ) throws(Failure) -> Void { try body(self) } }
— 8:47
When Swift starts executing the lines of code in run it does so with isolation as determined by the actor, and so that means when we execute body it is also done in the isolation of the actor.
— 8:53
So it seems like this error is wrong, but Swift is actually smarter than us. It knows that nothing is stopping us from doing something nefarious, such as constructing a brand new Bank and handing that to body : extension Bank { func run<Failure: Error>( _ body: (Bank) throws(Failure) -> Void ) throws(Failure) -> Void { try body(Bank()) } }
— 9:04
And so if Swift allowed us to have synchronous access to the bank in the trailing closure we would run the risk of introducing data races since a completely different bank, with different isolation, could be handed to the closure.
— 9:16
Luckily Swift provides a tool for us to be able to describe the isolation that a closure runs in, and we can use it to prove to Swift that the Bank we hand to the closure has the same isolation as the run method being executed.
— 9:25
The tool is called isolated , and we apply it to the Bank argument in the trailing closure: extension Bank { func run<Failure: Error>( _ body: (isolated Bank) throws(Failure) -> Void ) throws(Failure) -> Void { try body(self) } }
— 9:30
That right there tells Swift that we are executing body in the same isolation domain as the Bank argument, and so it’s OK for users to synchronously accessing anything on the bank.
— 9:40
And to prove this, let’s try doing that nefarious thing again where we pass some completely unrelated bank to the body closure: extension Bank { func run(_ body: (isolated Bank) throws -> Void) rethrows -> Void { try body(Bank()) } }
— 9:45
Swift now catches us: Call to actor-isolated parameter ‘body’ in a synchronous actor-isolated context
— 9:47
It knows that we are executing body in one isolation domain, and passing a completely different isolation domain to the closure.
— 9:56
We are getting really close to a proper implementation of run , but we now have this error: Sending value of non-Sendable type ‘(isolated Bank) throws -> Void’ risks causing data races Sending ‘bank’-isolated value of non-Sendable type ‘(isolated Bank) throws -> Void’ to actor-isolated instance method ‘run’ risks causing races in between ‘bank’-isolated and actor-isolated uses
— 10:08
This error is happening because we are passing a non-sendable object to the actor. We previously saw that you are not allowed to transfer non-sendable objects from the outside into or out of actors, and this is an example of that. The trailing closure is not @Sendable , and hence we cannot provide a closure from the outside.
— 10:25
So, we can fix this by making the trailing closure @Sendable : extension Bank { func run<Failure: Error>( _ body: @Sendable (isolated Bank) throws(Failure) -> Void ) throws(Failure) -> Void { try body(self) } }
— 10:30
And finally our transaction test now compiles and the test passes. And this gives us a very simple way to start an atomic transaction in the bank actor, and perform as much synchronous work as we want, all the while we are guaranteed that the actor will not process any one else’s work while we are working.
— 10:51
And so while actors help prevent data races from occurring in your code, which can lead to data corruption and crashes, this run method helps with race conditions . That is when multiple threads interleave in ways you don’t expect leading to logical errors in your code.
— 11:04
And in fact, this new run operation fixes the test failure we had a moment ago. Let’s copy-and-paste that test, and run the transfer task in bank.run : @Test func atomic() async throws { let bank = Bank() let id1 = await bank.openAccount(initialDeposit: 100) let id2 = await bank.openAccount(initialDeposit: 100) let transfer = Task { try await bank.run { bank in let startingDeposits = bank.totalDeposits try bank.transfer(amount: 50, from: id1, to: id2) Thread.sleep(forTimeInterval: 1) #expect(bank.totalDeposits == startingDeposits) #expect(try bank.account(for: id1).balance == 50) #expect(try bank.account(for: id2).balance == 150) } } let withdraw = Task { try await Task.sleep(for: .seconds(0.5)) try await bank.account(for: id1) { try $0.withdraw(50) } } _ = try await (transfer.value, withdraw.value) }
— 11:34
This test now passes, and will pass 100% of the time. It is no longer possible for another thread to wiggle into the run operation and perform work between each operation on the bank.
— 11:44
There are even performance benefits to using run to group multiple operations together instead of sprinkling multiple await s throughout your code since you only have to pay the cost of actor hopping a single time. But we will show that off later.
— 11:55
We can even make a quick improvement to run by making it much more generic. Right now it’s needlessly specific to the Bank actor, but it can be defined on any actor. And we can do that by extending the Actor protocol: extension Actor { func run<Failure: Error>( _ body: @Sendable (isolated Self) throws(Failure) -> Void ) throws(Failure) { try body(self) } }
— 12:09
…which every actor automatically conforms to.
— 12:16
And we can even make it possible to return values from the trailing closure: extension Actor { func run<R, Failure: Error>( _ body: @Sendable (isolated Self) throws(Failure) -> R ) throws(Failure) -> R { try body(self) } }
— 12:29
And let’s go ahead and move this code to the library target and make it public…
— 12:50
This is now a very general purpose tool that works with any actor, and allows one to suspend a single time to get synchronous access to everything in the actor.
— 12:58
And it’s worth mentioning again that it’s just not possible to write this kind of transactional helper with locks. There is no way to use withLock to protect each data access in a class and simultaneously provide a single, transaction run method that groups multiple withLock s into a single one. But it is a very easy and natural thing to do with actors.
— 13:15
Before moving on, this run method gives us an opportunity to call out an important fact about actors. Each instance of an actor defines its own personal isolation domain that is incompatible with every other instance of the actor out there. So if we created another Bank and tried interacting with it from the first bank’s run : @Test func transaction() async throws { let bank = Bank() let otherBank = Bank() try await bank.run { bank in let id1 = bank.openAccount(initialDeposit: 100) let id2 = bank.openAccount(initialDeposit: 100) let id3 = otherBank.openAccount(initialDeposit: 100) … } }
— 13:46
…we get a compiler error: Call to actor-isolated instance method ‘openAccount(initialDeposit:)’ in a synchronous actor-isolated context
— 13:47
This is happening because the trailing closure is isolated to the bank actor, not to the otherBank actor, and so we are not allowed to interact with that bank in a synchronous fashion. We are forced to await. How actors enqueue jobs
— 13:58
We have now seen a major way actors are an improvement over legacy locking. We are able to define a fully generic run method on any actor that allows one to demarcate a scope that can run as much synchronous code on the actor as you want. You pay the cost to enter the actor’s isolation domain just a single time, and then can run wild. Brandon
— 14:15
In contrast to locks, this is just not feasible. There is no easy way to protect mutable state in a class by locking all of its methods, while then also making it possible to lock a single time and gain unlocked access to everything in the class. You would have to do quite a bit of hand wiring to make such a thing possible, and we already saw how gnarly sendable references types can get with locking. But with actors this is incredibly simple. Stephen
— 14:44
So, I hope you don’t need any more convincing that actors are indeed solving a problem that is just not solvable with legacy locking tools. There is quite a bit of misinformation in our community that locks replace the need for actors and that actors aren’t worth the hassle, but more often than not there is just a serious misunderstanding of how these tools work. Brandon
— 15:01
Speaking of which… how exactly do actors work under the hood? What is the mechanism that allows us access state in the actor or invoke methods and have it magically suspend until the actor is ready to process our request?
— 15:15
There is something known as a “serial executor” at the heart of every actor that is responsible for enqueuing jobs and determining how to execute them. We can even provide a custom executor for our Bank actor and print when a job is enqueued so that we can see exactly how this works.
— 15:33
Let’s take a look.
— 15:36
When using the actor keyword like we are in Bank : public actor Bank { … }
— 15:38
…Swift is also conforming this type to the Actor protocol, which has only one requirement: public protocol Actor : AnyObject, Sendable { nonisolated var unownedExecutor: UnownedSerialExecutor { get } }
— 15:47
It requires there to an “serial executor” in the actor, and Swift generates this for you automatically when defining a new actor. And it’s weirdly named “unowned” because these tools pre-dates Swift’s ownership tools, and so someday in the future these APIs could be updated to be more modern. The only thing you should remember is that it is not legitimate to ever hold onto one of these “unowned” things for an extended period of time. You should construct them and discard them by the end of the lexical scope, and if handed an “unowned” object you should not store it.
— 16:22
With that said, there’s nothing stopping us from implementing our own executor and plugging it into our bank so that we can see how the sausage is made. And that’s exactly what we are going to do.
— 16:34
We are going to build a whole new SerialExecutor from scratch that delegates actor work to an underlying dispatch queue. We will define a new class that conforms to the SerialExecutor protocol: final class LoggingExecutor: SerialExecutor { }
— 16:52
We have made this class final because the SerialExecutor protocol requires the type to be Sendable .
— 16:58
This protocol has only one requirement, which is an enqueue method: func enqueue(_ job: consuming ExecutorJob) { }
— 17:04
This method is called every time some code outside of the actor wants to access the state inside the actor or invoke one of its methods. This job represents that access, and it gets enqueued inside the executor so that it can be executed once the actor is ready.
— 17:23
Let’s print some details of the job: func enqueue(_ job: consuming ExecutorJob) { print("-> Job enqueued: \(job.description) @ priority \(job.priority.rawValue)") }
— 17:45
Remember way back at the beginning of this series we looked at the Swift evolution proposal for actors, and it specifically called out that actors execute the jobs enqueued into them serially, but they do not have to execute them in order. Each job has a priority, as we can see above, and the executor may choose to execute a high priority job first, even if it was enqueued after a low priority job.
— 18:10
To implement this method we will hold onto a dispatch queue that will be the thing that is actually responsible for executing jobs: final class LoggingExecutor: SerialExecutor { private let queue: DispatchSerialQueue init(label: String) { queue = DispatchSerialQueue(label: "co.pointfree.logging-executor") } … }
— 18:45
And then when a job is enqueued we will enqueue to the dispatch queue in an async fashion: queue.async { }
— 18:51
To run the job in this closure we can execute the runSynchronously method on the job : job.runSynchronously(on: <#UnownedSerialExecutor#>)
— 19:01
To do this we need to provide an unowned serial executor. The serial executor in question is this class we are currently implementing, and we can get an unowned reference to it via this method: asUnownedSerialExecutor() And we will use that when running synchronously: queue.async { job.runSynchronously(on: self.asUnownedSerialExecutor()) }
— 19:14
This does not compile because jobs are non-copyable and so cannot be passed into escaping closures: Noncopyable ‘job’ cannot be consumed when captured by an escaping closure or borrowed by a non-Escapable type
— 19:27
We are going to get into the nitty gritty of non-copyable types in future episodes of Point-Free, but for now know that we can turn this into an “unowned” job: let unownedJob = UnownedJob(job) …and now we are allowed to pass it along: queue.async { unownedJob.runSynchronously(on: self.asUnownedSerialExecutor()) }
— 19:39
That is basically it.
— 19:41
Now it’s worth noting that this implementation of a serial executor is a gross simplification of how things work in regular actors in Swift. Dispatch queues execute all work in a strict first-in-first-out order, whereas actors can take task priority into account to execute high priority work before low priority work that was enqueued earlier. That’s a very powerful feature of actors that dispatch queues are not capable of, but it’s OK we are not capturing that behavior right now because we are just using this for debugging and it is not meant to be used in production.
— 20:20
With this done we can now get some wonderful insight into how job enqueueing works. To see this we need to install this executor into the Bank actor. We will hold onto the executor as a property in the bank: public actor Bank { let executor = LoggingExecutor() … }
— 20:37
And then we will override the actor’s unownedExecutor by just returning our executor: public nonisolated var unownedExecutor: UnownedSerialExecutor { executor.asUnownedSerialExecutor() }
— 21:09
Every access to the actor will be forwarded through our executor.
— 21:17
And now we can have some fun. Take for example the basics test we previously wrote: @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) }
— 21:26
There are 6 suspension points in this test and so does that mean there are 6 jobs enqueued into our executor? Well, running the test and looking at the logs shows: -> Job enqueued: ExecutorJob(id: 25) @ priority 25 -> Job enqueued: ExecutorJob(id: 25) @ priority 25 -> Job enqueued: ExecutorJob(id: 25) @ priority 25
— 21:38
Interestingly only 3 jobs were enqueued. I’m not sure what “ id: 25 ” means, and that ID isn’t exposed as part of any public API for ExecutorJob s, but the description exposes it for whatever reason. And I don’t know why all of them have the same ID.
— 21:57
To see which suspensions points are actually enqueuing jobs, let’s add a private helper that can print the function that calls the helper: private func printFunction(_ f: StaticString = #function) { print(f) }
— 22:22
And let’s invoke this in each method and property in the Bank …
— 22:58
Now when we run the test we see: -> Job enqueued: ExecutorJob(id: 25) @ priority 25 openAccount(initialDeposit:) openAccount(initialDeposit:) transfer(amount:from:to:) account(for:) account(for:) totalDeposits -> Job enqueued: ExecutorJob(id: 25) @ priority 25 account(for:body:) account(for:) -> Job enqueued: ExecutorJob(id: 25) @ priority 25 account(for:body:) account(for:)
— 23:04
This shows that a single job was somehow able to service opening two accounts, transferring an amount from one account to the other, and checking the total deposits, even though each of those statements required an await . It seems that Swift’s scheduling system has some extra smarts in it to see that no work was done between successive access to the actor, and so it only needs to enqueue a single job to service that.
— 23:43
However, if we execute anything between two suspension points, then the actor has no choice but to enqueue a job for each await . It something as simple as incrementing an integer: var x = 0; let id1 = await bank.openAccount(initialDeposit: 100) x += 1; let id2 = await bank.openAccount(initialDeposit: 100) x += 1; try await bank.transfer(amount: 50, from: id1, to: id2) x += 1; #expect(await bank.totalDeposits == 200) x += 1; #expect(try await bank.account(for: id1) { $0.balance } == 50) x += 1; #expect(try await bank.account(for: id2) { $0.balance } == 150)
— 24:06
Now we can see that 6 jobs are enqueued, corresponding to the 6 awaits to get access to the actor: -> Job enqueued: ExecutorJob(id: 25) @ priority 25 openAccount(initialDeposit:) -> Job enqueued: ExecutorJob(id: 25) @ priority 25 openAccount(initialDeposit:) -> Job enqueued: ExecutorJob(id: 25) @ priority 25 transfer(amount:from:to:) account(for:) account(for:) -> Job enqueued: ExecutorJob(id: 25) @ priority 25 totalDeposits -> Job enqueued: ExecutorJob(id: 25) @ priority 25 account(for:body:) account(for:) -> Job enqueued: ExecutorJob(id: 25) @ priority 25 account(for:body:) account(for:)
— 24:41
OK, we are starting to get a little bit of insight into how actor job enqueuing works. Let’s kick things up a notch.
— 24:47
Let’s for a moment comment out all the print(#function) …
— 24:53
And let’s remove all of the x += 1 s…
— 24:59
At the end of the test let’s spin two tasks and interact with the actor: @Test func basics() async throws { … let task1 = await Task(priority: .userInitiated) { #expect(await bank.totalDeposits == 200) } let task2 = await Task(priority: .utility) { #expect(await bank.totalDeposits == 200) } _ = await (task1.value, task2.value) }
— 25:26
Now when we run the test we see the following logs: -> Job enqueued: ExecutorJob(id: 25) @ priority 25 -> Job enqueued: ExecutorJob(id: 25) @ priority 25 -> Job enqueued: ExecutorJob(id: 25) @ priority 25 -> Job enqueued: ExecutorJob(id: 26) @ priority 25 -> Job enqueued: ExecutorJob(id: 27) @ priority 25
— 25:32
It seems that by interacting with the actor from multiple threads at once we are starting to see that jobs with different IDs can indeed be enqueued. This must correspond to there being concurrent access to the actor from different tasks.
— 26:04
Now let’s see what happens with our transaction test: @Test func transaction() async throws { let bank = Bank() try await bank.run { bank in let id1 = bank.openAccount(initialDeposit: 100) let id2 = bank.openAccount(initialDeposit: 100) 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) } }
— 26:08
This test is the same as the basics , except it performs all of the banking work in a single run transaction. This allows us to execute as many synchronous statements as we want in the actor’s isolation.
— 26:26
If we run the tests we see that just one single job was enqueued: -> Job enqueued: ExecutorJob(id: 25) @ priority 25
— 26:33
This means we were able to squash the number of jobs enqueued to get this work done. And it gets better. Let’s add a bunch of statements between each line of this test: @Test func transaction() async throws { let bank = Bank() try await bank.run { bank in var x = 0; let id1 = bank.openAccount(initialDeposit: 100) x += 1; let id2 = bank.openAccount(initialDeposit: 100) x += 1; let startingDeposits = bank.totalDeposits x += 1; try bank.transfer(amount: 50, from: id1, to: id2) x += 1; #expect(bank.totalDeposits == startingDeposits) x += 1; #expect(try bank.account(for: id1).balance == 50) x += 1; #expect(try bank.account(for: id2).balance == 150) } }
— 26:50
Even with that we still only enqueue one single job -> Job enqueued: ExecutorJob(id: 33) @ priority 25 …as opposed to the 6 jobs we enqueued in the basics test.
— 27:14
We can even perform a whole bunch more operations in the actor without incurring the cost of any more jobs: for _ in 1...50 { try bank.transfer(amount: 1, from: id1, to: id2) x += 1 } Instead of transferring 50 all at once we are transferring 1 fifty times. And still only 1 job is enqueued: -> Job enqueued: ExecutorJob(id: 33) @ priority 25
— 28:06
This now shows clear as day that we are paying the cost of synchronizing with the actor a single time, and then we are free to do whatever we want with the actor at no additional cost. And this provides measurable performance improvements over locks, which we will soon see. Next time: Actor reentrancy
— 28:41
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.
— 29:10
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
— 29:37
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.
— 29:51
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.
— 30:10
Let’s take a look at an example of actor reentrancy, show why it can be be problematic in practice…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 0362-beyond-basics-isolation-pt8 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 .