How to keep only one whole Task (or async func) happened in an actor?

actor Account {
     let value = 0
     let storeA = Store()
     let storeB = Store()
     func increment() async -> Int {
        value += 1
        print(value, storeA.value, storeB.value)
        await storeA.saveValue(value)
        await storeB.saveValue(value)
        print(value, storeA.value, storeB.value)
    }
}

For example, I have an increment func to modify the store, but the store needs an async version of the increment. But this will not guarantee one whole increment func running. How to resolve this scenario?

If there is an await in the increment function, there is an opportunity to start another increment func at the suspension of the current running function.

You can solve this in a few ways.

  1. Add explicit waiting if increment in progress:
actor Account {
    private var queue: [CheckedContinuation<Void, Never>] = []
    private var isIncrementing = false

    func increment() async {
        if isIncrementing {
            await withCheckedContinuation { continuation in
                continuations.append(continuation)
            }
        }
        isIncrementing = true
        defer {
            isIncrementing = false
            queue.popFirst()?.resume()
        }
        // do work
    }
}

That will work good enough, but might be a bit too complex.

  1. As another solution, considering that Store itself should be either an actor or be isolated to some actor, you can modify it to inherit isolation:
func saveValue(_ value: Int, isolation: isolated any Actor) async {
}

In that way, your call to saveValue can be isolated to the Account actor on call:

await storeA.saveValue(value, isolation: self)

And there will be no executor hop, and therefore no suspension.


I really with there was some way to say @isolated let storeA = Store() to make calls to Store isolated on the enclosing actor, but currently the second option is the best you can have.

2 Likes

Thanks! I also find a version of downloading image of WWDC video: Protect mutable state with Swift actors - WWDC21 - Videos - Apple Developer

But there is still an improvement of the code to let the actor only running one task waits for another (using for await loop). I'll try to figure it out. But no one looks simple.

Yes. I'd like Swift to add some mechanism to limit the running task count, this may be a solution.

1 Like

Yes, there could be more details. I also realised that my example is a bit incorrect for your case - it prevents duplication, not just queueing, but I think the general idea is clear.

You actually need less code to have queue behaviour, than in my example. You need wait if increment in progress and resume one continuation at a time.

UPD: I've changed code example to not it be misleading for the case :slight_smile:

This won't work though if the Store actor needs to access its own isolated state inside saveValue(_:isolation:), right?

1 Like

Yes, if Store itself an actor and save modifies its internal state, a hop to Store's executor will be required anyway. That's actually made me think about other options I felt should be there and 3rd option to avoid executor hop is to use custom actor executors, in case Store is an actor:

actor Account {
    // I think that should work well
    let storeA = Store(executor: unownedExecutor)
}

actor Store {
    let unownedExecutor: UnownedSerialExecutor
}

I haven't run this, so not sure if that's correct, but from my experience it should work as intended.

1 Like

But what if store is shared? For example, another actor called actor Message also need the storeA and StoreB, than the second solution could not work. Right?

Yes, that’s another case to be aware of. I think we actually need to go into why store itself is async? If it’s just because it is an actor, but inside it has sync implementation of save method, then you can introduce some global actor and isolate store and types that use it on the same actor. Which is one more way to avoid hop, actually :slight_smile:

If store doesn’t involves shared mutable state, one the other hand, but just an interface, you can make it a regular but Sendable class, an still use 2nd approach.

it is just a simulation of a complex task, for example url session task+swiftdata background save.

And for reducing the complex, only allow one whole complex task to running.

Well, then first approach is the solution. If save involves execution of requests, then there will be an executor hop anyway, I think. Because you cannot (without task executors which isn’t available yet) force nonisolated async code run on the same executor without modifying its implementation.

I would also reconsider design change on a larger scale, e.g. remove async operations from the Account, having in it solely sync code.

Or — given that major issue of reentrancy here is store race conditions — used first approach to make the store sequential, so the save operation persists ordering. Paired with isolating mutation inside function (introduce newValue to be passed to stores to avoid mutations across suspension points) will also work.

Also, there is an issue of value mutation only after suspension points — which is two store calls. If you introduce local value or unite two stores under umbrella type that combines two stores into one call on the actor side, and won’t be accessing to value after suspension points, the reentrancy itself won’t cause you issues.