You are currently viewing The Actor Reentrancy Problem in Swift

The Actor Reentrancy Problem in Swift

When the first time I saw the WWDC presentation about actors, I was thrilled with what it is capable of and how it will change the way we write asynchronous code in the near future. By using actors, writing asynchronous code that is free from data races and deadlocks has never been easier.

All that aside, that doesn’t mean that actors are free from threading issues. If we are not careful enough, we might accidentally introduce a reentrancy problem when using actors.

In this article, I am going to walk you through what is a reentrancy problem, why it is problematic, and how you can prevent it from happening. If this is the first time you heard of the reentrancy problem, definitely read on so that it won’t catch you off guard next time when you use actors.


The Real-life Example

The best way to showcase the reentrancy problem is by using a real-life example. Consider the following BankAccount actor that has a balance variable.

actor BankAccount {
    
    private var balance = 1000
    
    // ...
    // ...
}

We will give this BankAccount actor a withdrawal functionality later on, but before that, let’s give it a private function that checks whether the account has sufficient balance for withdrawal:

private func canWithdraw(_ amount: Int) -> Bool {
    return amount <= balance
}

On top of that, we will define another private function that simulate an authorization process:

private func authorizeTransaction() async -> Bool {
    
    // Wait for 1 second
    try? await Task.sleep(nanoseconds: 1 * 1000000000)
    
    return true
}

Realistically the authorization process should be a fairly slow process, therefore we will make it an async function. We are not going to implement the actual authorization workflow, but instead, we will wait for 1 second and then return true to simulate a condition where the transaction has been authorized.

With that, we can now implement the withdrawal functionality like so:

func withdraw(_ amount: Int) async {
    
    guard canWithdraw(amount) else {
        return
    }
    
    guard await authorizeTransaction() else {
        return
    }
    
    balance -= amount
}

The implementation is quite straightforward. We will first check the account balance. If the balance is sufficient, we will proceed to authorize the transaction. Once we have successfully authorized the transaction, we will deduct the withdrawal amount from the balance, indicating that money has been withdrawn from the account.

After that, let’s add some print statements to help us in monitoring the flow of the entire withdrawal process.

func withdraw(_ amount: Int) async {
    
    print("🤓 Check balance for withdrawal: \(amount)")
    
    guard canWithdraw(amount) else {
        print("🚫 Not enough balance to withdraw: \(amount)")
        return
    }
    
    guard await authorizeTransaction() else {
        return
    }
    print("✅ Transaction authorized: \(amount)")
    
    balance -= amount
    
    print("💰 Account balance: \(balance)")
}

Here’s the full implementation of the BankAccount actor:

actor BankAccount {
    
    private var balance = 1000
    
    func withdraw(_ amount: Int) async {
        
        print("🤓 Check balance for withdrawal: \(amount)")
        
        guard canWithdraw(amount) else {
            print("🚫 Not enough balance to withdraw: \(amount)")
            return
        }
        
        guard await authorizeTransaction() else {
            return
        }
        
        print("✅ Transaction authorized: \(amount)")
        
        balance -= amount
        
        print("💰 Account balance: \(balance)")
    }
    
    private func canWithdraw(_ amount: Int) -> Bool {
        return amount <= balance
    }
    
    private func authorizeTransaction() async -> Bool {
        
        // Wait for 1 second
        try? await Task.sleep(nanoseconds: 1 * 1000000000)
        
        return true
    }
}

Simulating the Reentrancy Problem

Now, let’s consider a situation where 2 withdrawals happen concurrently. We can simulate that by triggering the withdraw(_:) function in 2 separate asynchronous tasks.

let account = BankAccount()

Task {
    await account.withdraw(800)
}

Task {
    await account.withdraw(500)
}

What do you think the outcome will be?

At first glance, you might think that the 1st withdrawal (800) should go through while the 2nd one (500) will get rejected due to insufficient balance. Unfortunately, that is not the case. Here’s the result we get from the Xcode console:

🤓 Check balance for withdrawal: 800
🤓 Check balance for withdrawal: 500
✅ Transaction authorized: 800
💰 Account balance: 200
✅ Transaction authorized: 500
💰 Account balance: -300

As you can see, both transactions have gone through and the user is able to withdraw more than the account balance. If you are the bank owner, you do not want this to happen!

Now let’s take a closer look at the withdraw(_:) function implementation, you will notice that the problem we currently face is actually caused by the following 3 reasons:

  1. A suspension point exists in the withdraw(_:) function, which is the await authorizeTransaction().
  2. The BankAccount‘s state (the balance value) for the 2nd transaction is different before and after the suspension point.
  3. The withdraw(_:) function is being called before its previous execution is completed.

Because of the suspension point in the withdraw(_:) function, the balance check for the 2nd transaction happens before the 1st transaction is completed. During that time, the account still has enough balance for the 2nd transaction, that’s why the balance check for the 2nd transaction went through.

This is a very typical reentrancy problem and it seems like Swift actors will not give us any compiler error when it happens. If so, what should we do to prevent this from happening?


Designing Actor for Reentrancy

According to Apple, actor reentrancy prevents deadlocks and guarantees forward progress. However, it does not guarantee that the actor’s mutable state will stay the same across each await.

Therefore, we as developers must always be mindful that each await is a potential suspension point and the actor’s mutable state could change after each awaits. In other words, it is our responsibility to prevent the reentrancy problem from happening.

If so, what prevention approaches do we have?

Perform State Mutation in Synchronous Code

The first approach suggested by Apple engineers is to always mutate the actor state in synchronous code. As you can see in our example, the point where we mutate the actor state is when the balance deduction happens, and the point where we read the actor state is when we check the account balance. These 2 points are separated by a suspension point.

The root cause of the actor reentrancy problem
The suspension point location

Therefore, to ensure that the balance check and balance deduction run synchronously, all we need to do is to authorize the transaction before performing a balance check.

func withdraw(_ amount: Int) async {
    
    // Perform authorization before check balance
    guard await authorizeTransaction() else {
        return
    }
    print("✅ Transaction authorized: \(amount)")
    
    print("🤓 Check balance for withdrawal: \(amount)")
    guard canWithdraw(amount) else {
        print("🚫 Not enough balance to withdraw: \(amount)")
        return
    }
    
    balance -= amount
    
    print("💰 Account balance: \(balance)")
    
}

If we run the code again, we will get the following output:

✅ Transaction authorized: 800
🤓 Check balance for withdrawal: 800
💰 Account balance: 200
✅ Transaction authorized: 500
🤓 Check balance for withdrawal: 500
🚫 Not enough balance to withdraw: 500

Great! Our code now successfully rejects the 2nd transaction. However, the withdrawal workflow doesn’t really make sense. What’s the point of authorizing a transaction if the account has insufficient balance?

If that’s the case, what other alternative do we have so that we can maintain the original withdrawal workflow while solving the reentrancy problem?

Check the Actor State After a Suspension Point

Another prevention approach suggested by Apple engineers is to perform a check on the actor state after a suspension point. This can ensure that any assumptions we make regarding the actor mutable state remain the same across suspension points.

For our case, we are assuming that the account balance is sufficient after the authorization process. Therefore, to prevent the reentrancy problem, we must check the account balance again after the transaction has been authorized.

func withdraw(_ amount: Int) async {
    
    print("🤓 Check balance for withdrawal: \(amount)")
    guard canWithdraw(amount) else {
        print("🚫 Not enough balance to withdraw: \(amount)")
        return
    }
    
    guard await authorizeTransaction() else {
        return
    }
    print("✅ Transaction authorized: \(amount)")
    
    // Check balance again after the authorization process
    guard canWithdraw(amount) else {
        print("⛔️ Not enough balance to withdraw: \(amount) (authorized)")
        return
    }

    balance -= amount
    
    print("💰 Account balance: \(balance)")
    
}

Here’s the result we get from the above withdraw(_:) function:

🤓 Check balance for withdrawal: 800
🤓 Check balance for withdrawal: 500
✅ Transaction authorized: 800
💰 Account balance: 200
✅ Transaction authorized: 500
⛔️ Not enough balance to withdraw: 500 (authorized)

With that, we have successfully prevented the reentrancy problem from happening while maintaining the original withdrawal workflow.

As you can see, there is no silver bullet that can prevent all kinds of reentrancy problems. We need to adjust the approach we take based on what we really need.

If you would like to see a more complicated real-life reentrancy problem, I highly recommend this article by Donny Wals. In the article, you will see how he uses a dictionary to prevent his image downloader actor from downloading an image twice due to a reentrancy problem.


Thread Safety vs Reentrancy

Now that you have learned what causes a reentrancy problem and how we can prevent it, let’s switch our focus to talk a bit about the differences between thread safety and reentrancy.

As we all know, an actor will guarantee thread safety within its own context, then why are we still getting a reentrancy problem?

According to Apple, an actor will guarantee mutually exclusive access to its mutable state. That’s why in the example just now, the transaction with amount = 800 always happens 1st. If an actor is not thread-safe, we won’t be able to get such a consistent outcome. We might sometimes get results where the transaction with amount = 500 gets triggered 1st.

Even though the reentrancy problem is happening in a multithreaded context, it doesn’t mean that it is a thread safety issue. The reentrancy problem occurs because we are assuming that the actor state will not change across a suspension point, not because we are trying to change the actor mutable state concurrently. Therefore, a reentrancy problem is not equivalent to a thread-safety problem.


Wrapping Up

In this article, you have learned that actors can guarantee thread safety, but it doesn’t prevent reentrancy problems. Therefore, we must always be mindful that an actor state might change across a suspension point, and it is our responsibility to ensure that our code can still run correctly even after the actor state changed.

If you would like to try out the sample code in this article, you can get it here.


Do you find this article helpful? If you do, feel free to check out my other articles that are related to Swift concurrency:

For more articles related to iOS development and Swift, make sure to follow me on Twitter and subscribe to my monthly newsletter.

Thanks for reading. 👨🏻‍💻


👋🏻 Hey!

While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.

  • Bartender: Superpower your menu bar and take full control over your menu bar items.
  • CleanShot X: The best screen capture app I’ve ever used.
  • PixelSnap: Measure on-screen elements with ease and precision.
  • iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.