Multi-actor execution scenario

I'm trying to convince myself that, in the following code, it is impossible for the calls to finish out of order, but, I'm really not sure.

In practice, I am always seeing the order I expect (if it happens out of order, the precondition will trigger), but would that hold forever, or is this just waiting to break?

final actor A {
    
    private var counter = 0
    
    func produceState() -> Int {
        counter += 1
        return counter
    }
}

final actor B {
    
    private var lastConsumedState = 0
    
    func consumeState(state: Int) {
        precondition(lastConsumedState < state)
        lastConsumedState = state
    }
}

final actor C {
    // simple example here, but assume this method does actually need to be isolated in a more complex scenario.
    func run(a: A, b: B) async {
        let state = await a.produceState()
        await b.consumeState(state: state)
    }
}

func start() {
    
    let a = A()
    let b = B()
    
    let c = C()
    
    var streamContinuation: AsyncStream<Void>.Continuation!
    let stream = AsyncStream<Void> { continuation in
        streamContinuation = continuation
    }
    
    Task.detached {
        for await _ in stream {
            await c.run(a: a, b: b)
        }
    }
    
    streamContinuation.yield()
    streamContinuation.yield()
    streamContinuation.yield()
}

Thanks :slight_smile:

Edit: Changed the code snippet to hopefully make more sense. The "wrong" order would occur if for example "state = 2" is consumed before "state = 1", which would happen if the second call to "run" finished before the first call to "run".

1 Like

Tasks are concurrent, even when constrained to the same actor, so they'll finish in any order AFAIK. Seems you could add print statement to your example here and tell for sure.

Sorry, I think the example isn't necessarily showing what I want it to, let me see if I can reconfigure this example a little bit to make more sense.

As-written, every call to consume is preceded by a call to produce, so the precondition will always hold. The order in which the 3 tasks executed may be undefined, but the ordering of produce-consume is not.

1 Like

Ok the edits are in, hopefully this makes more sense.

Edit: I guess the order here is now pretty much guaranteed because of the second await here:

    Task.detached {
        for await _ in stream {
            await c.run(a: a, b: b)
        }
    }

Idk if I can actually make this work in my project though, we'll see. I may come back to this thread if I can figure out what I'm even trying to ask :sweat_smile:

Yeah, you're getting a total order because you have a single task. If you had multiple tasks calling C.run, then no, your precondition would not hold reliably.

I suspect that what you're trying to ask is how to guarantee that a linear sequence of events will be handled in order. If actors A and B were serial dispatch queues, and you had a linear sequence of events, and for each event you (1) dispatched something onto A, and then, while still on A, you (2) dispatched something onto B, then you would be guaranteed at each step to handle the events in their original order, because dispatch queues are FIFO. This does not reliably happen with Swift actors: Swift actors are not strictly FIFO, and even if they were, it's not easy to get Swift to guarantee that you'll transition directly from one actor to the next.

If you want this kind of guarantee in Swift, the right way to get it is with a task. Tasks run in a linear order, just like any other code, so if the task receives an event and then passes it off before receiving the next event, the stream of events remains in that linear order. (Of course, each task won't be processing events concurrently, but neither were the dispatch queue in the original example.) You can send events between tasks with an AsyncStream.

6 Likes

Thank you for making my question make sense :laughing:, you're basically spot on.

Previously, in my real project, calls to "run" on "actor C" were each creating a new Task (in actor C). I see now that this was indeed broken for my purposes! Probably the only reason that the precondition in my real project never hit is because there was always some interval between calls to "run" that was likely long enough for the Task created by a call to "run" to finish before the next call to "run" was made.

Now, I have a single Task (in actor C), that awaits an AsyncStream, and then awaits calls to "run" every time a value comes in on the stream, which guarantees that the values will be consumed in the order they were yielded to the stream :)

1 Like

That seems very reasonable. It’s a common pattern, and we’ve thought about ways to support it better, but for now explicit tasks and streams are the way to go.

1 Like