So far, it seems that actors are a golden solution to data races and thread-safe access to shared mutable state. However, actors have their challenges too! One of them is called actor reentrancy and it’s important for you to know what it means when working with actors.
What is actor reentrancy?
It’s kind of in the name: you re-enter the actor. If you enter an actor inside a method, jump out of it to do some external work, and re-enter back into the actor, the state might no longer be similar to when you left. This is because as soon as you suspend, you’ll free up the actor for any other tasks to jump in.
Let’s take the BankAccount actor as an example again, but this time, we add an activity logger to it:
actor BankAccount {
var balance: Double
let activityLogger = BankAccountActivityLogger()
init(initialDeposit: Double) {
self.balance = initialDeposit
}
func deposit(amount: Double) async {
balance = balance + amount
await activityLogger.log(BankAccountActivity(activity: "Increased balance by \(amount)"))
print("Balance is now \(balance)")
}
}
The activity logger is also an actor and looks as follows:
struct BankAccountActivity {
let date: Date = .now
let activity: String
}
actor BankAccountActivityLogger {
private var activities: [BankAccountActivity] = []
func log(_ activity: BankAccountActivity) async {
activities.append(activity)
/// Adding a sleep to simulate a remote syncing of the activity.
try? await Task.sleep(for: .seconds(1))
}
}
We’ve added a sleep inside the activity logger to simulate a scenario where activities are synced with some kind of remote server. In other words, each activity log will take some time to complete.
Let’s zoom into our deposit method for a bit:
func deposit(amount: Double) async {
/// We update the balance with the amount:
balance = balance + amount
/// We temporarily exit the actor isolation domain to go into the activity logger
/// and log the activity:
await activityLogger.log(BankAccountActivity(activity: "Increased balance by \(amount)"))
/// We re-enter the actor and we print out the balance:
print("Balance is now \(balance)")
}
At first sight, everything might look okay. We just go out of the actor to log the activity and we finalize by logging the new balance. However, if we would use this code as follows:
let bankAccount = BankAccount(initialDeposit: 100)
async let _ = bankAccount.deposit(amount: 50)
async let _ = bankAccount.deposit(amount: 50)
async let _ = bankAccount.deposit(amount: 50)
We would get the following print statements:
Balance is now 250.0
Balance is now 250.0
Balance is now 250.0
This is unexpected! It would have made more sense if we would get something like:
Balance is now 150.0
Balance is now 200.0
Balance is now 250.0
This is a typical example of an actor reentrancy bug. What happens is this:
- We deposit an amount of 50.
- We leave the
BankAccountactor to log the activity, opening up the actor for new tasks. - The second deposit executes and adds another 50. At this point, we’re still syncing the first activity.
- We once again leave the
BankAccountactor to log the second activity. - The third deposit executes, adds 50, and logs another activity.
- The activity logs are completed one by one, and we re-enter the actor. At this point, the balance has been updated three times with 50, and we print out 250 for each print statement.
An obvious fix for this example would be to move the print statement up in the method:
func deposit(amount: Double) async {
balance = balance + amount
print("Balance is now \(balance)")
await activityLogger.log(BankAccountActivity(activity: "Increased balance by \(amount)"))
}
In other words, the actor should only be left if all the actor-related work is done. This is a case-per-case problem, and it might not always be easy to prevent actor reentrancy. You’ll have to carefully design your code to prevent these bugs from happening.
Summary
Actor reentrancy can result in unexpected behavior. Whenever you return to an actor’s method after stepping out into another isolation domain, it’s important to consider whether you’re introducing unexpected behavior.
In the next lesson, we will look into actor inheritance using the #isolation macro.