Calling actor method from another actor

so ive got an actor that im using to control concurrent access to some shared data. I have a second actor that controls concurrent access to another set of different shared data

actor DataManager {
       static let shared = DataManager()
       func fetchData() async {
           modifySomeData()
           let blah = await SecondDataManager.shared.fetchDifferentData()
       }
}

actor SecondDataManager {
       static let shared = SecondDataManager()
       func fetchDifferentData() -> [SomeData] {
           return stuff
       }
}

im finding that because of the await in the fetchData() method, the actor no longer ensures that the DataManager.fetchData() function can only be executed one at a time thus corrupting my shared data because fetchData() needs to be async due to the call on the second actor method.

Any way to ensure DataManager.fetchData() can only be executed one at a time? normally id use a DispatchQueue or locks. but it seems these can't be combined with actors.

Replies

It’s hard to be sure without seeing more of your code but I think you’re hitting a well-known limitation of the Swift concurrency model, namely actor reentrancy. An actor serialises access to its isolated properties but that doesn’t mean that an actor-isolated method runs from start to end without interruption. If you call an async function, your task might suspend waiting for the result and some other task can enter your actor.

Let me illustrate this with a tiny test program:

func log(_ i: Int) async {
    print("value:", i)
    // This delay make this problem easier to reproduce but it could potentially
    // happen without it.
    try! await Task.sleep(nanoseconds: 1_000_000_000)
}

actor Counter {

    var count: Int = 0
    
    func increment() async -> Int {
        let old = self.count
        await log(old)
        let new = old + 1
        self.count = new
        return new
    }
}

@main
enum Main {
    static func main() async {
        let counter = Counter()
        async let i1 = counter.increment()
        async let i2 = counter.increment()
        await print("i1:", i1)
        await print("i2:", i2)
    }
}

If you run this (Xcode 13.4.1 on macOS 12.5.1) you see this:

value: 0
value: 0
i1: 1
i2: 1

In this example the count property is actor-isolated, and so concurrent calls to increment() should be safe. But somehow they’re not, resulting in a missing increment.

The problem is that call to log(_:). It’s an async call, and hence a suspension point. The first call to increment() suspends at that point, allowing the second call to increment() to enter the actor. So both tasks read count as 0, cache that value in old, and then increment it to 1.

Actor reentrancy isn’t a bug in Swift concurrency. It’s specific design choice, based on a fundamental feature of this model of concurrency: If you disallow reentrancy then you open yourself up to deadlock. The designers of Swift concurrency decided that reentrancy was a lesser evil than deadlock.

Personally I think that was the right choice but there are other points of view. If you look through the Swift Evolution forums threads for this feature, you’ll see that it was not an easy choice to make.

So how do you deal with this? There are many techniques but the one I like the most is to carefully separate your synchronous code and asynchronous code. That is, don’t intermingle complex data structure manipulations with async calls. Rather, try to move the data structure manipulations into synchronous code, where you can guarantee that there’ll be no async calls and thus no reentrancy.

In my example that’s easy. Change the increment() method to this:

func increment() async -> Int {
    let old = self.count
    let new = old + 1
    self.count = new
    await log(old)
    return new
}

The ‘complex’ data structure update — well, the increment (-: — completes before the async call, and thus reentrancy isn’t a concern.

You’ll also find a wealth of other techniques discussed in standard concurrency texts. For example, I sometimes reach for this technique:

func increment() async -> Int {
    while true {
        let old = self.count
        await log(old)
        guard self.count == old else { continue }
        let new = old + 1
        self.count = new
        return new
    }
}

This checks that count is still what we think it should be and retries if it isn’t.

One technique I recommend against is disabling actor reentrancy. It’s relatively easy to do this [1] but doing so exposes you to deadlocks, and deadlock problems are much less fun to debug than reentrancy ones.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] The language does not provide a direct way to do this but you’ll find lots of examples out there on the ’net.

  • thanks. I ended up implementing a custom queue in the first actor to manage and queue the 'async' calls to the second actor. A decent amount of code, would be nice if actors had some standard functionality to do something like this, as the custom queue has quickly become boilerplate code in almost all of my actors now.

Add a Comment