Task: Is order of task execution deterministic?

I have some code that helps with awaiting emitted values from a Publisher. The glue code uses an actor to synchronize state.

I have some code that looks like this:

        ...
        subscription = publisher
            .first()
            .sink { [weak self] completion in
                guard let self = self else { return }
                Task { await self.handle(completion: completion) }
            } receiveValue: { [weak self] value in
                guard let self = self else { return }
                Task { await self.handle(received: value) }
            }

where self is an actor, so the handle calls must be called from a concurrent context.

What I'm finding is a subtle bug that happens about 5-10% of the time on a colleague's machine where the Task order of execution is not deterministic. The receiveValue block is always called first, and the completion second. But about 5-10% the call to self.handle(completion:) is called before the call to self.handle(received:).

My question is, is this by design or a bug? I cannot repro on my machine. My machine has an M1, his is a recent 16" MBP. We are both using Xcode 13 beta 5.

1 Like

This is, sadly, as expected.

The problem can be illustrated by the following:

  • create task 1
  • create task 2
  • task 2 runs / PERHAPS even concurrently to: task 1 runs
  • task 2 invokes handle(received:)
    • task 2: just happens to be slightly quicker to get to the invocation
  • task 1 invokes handle(completion:)

There is zero ordering guarantees between “invoke handle(received:)” and “invoke(handle:completion)” because the moment we hit the Task{} we left all scheduling to completely independent tasks with no relationship to eachother at all.

I feel a lot of code will be “bitten” by this and—personally—would love for the introduction of something akin to “send” i.e. enqueue the invocation on the actor and then return — thanks to such operation (sometimes called “tell” in other actor runtimes), we’d be able to guarantee ordering in such uni-directional calls.

We can’t express this today.

We either can await for the entire call to complete, or we have to Task{} and cause concurrent execution. The actor has no idea that it will have any calls until those concurrent tasks eventually run and invoke things on it.

A “tell”/“send” operation would operate differently: Instead of Task { await self.handle(completion: completion) } we’d say send self.handle(completion: completion). which would mean “don’t wait for the response but return once the invocation has been enqueued in the actor’s mailbox, this way there is no additional indeterminism injected into the order by the addition of those Tasks that we only created because we don’t want to wait for the results really.

It’s up in the air if we’ll get such operations though — we definitely have hit this issue multiple times already thought and I (personally) really do think it’d be useful to allow such thing.

The entire issue can already be seen in a simplified case, like this:

async let a = actor.tell(“A”)
async let b = actor.tell(“B”)
// but we actually meant
// don’t wait actor.tell(“A”)
// don’t wait actor tell(“B”)

It is not deterministic what order the actor gets those messages in, and we have no way to express “enqueue but don’t wait”, so the only way to get the ordering we want to so await on both calls in the current task. The actor is sequential, sure, but the async lets create new tasks and those are executing concurrently, so the actor will get the invocations enqueued in any order.

11 Likes

I can see now reading your post that this is by design.

I heartily agree that this would be a welcome addition to actors. I guess developers could roll their own maybe, but I'm sure quite a few developers would have a hard time getting this right. What is even more insidious about this problem is that most people may not even be aware this pattern is a source of bug with actors; because in my case the bug never manifested on my machine, only on a colleague's machine. I tested it thousands of times. He could get it to reproduce about 1/10 times.

Having and documenting a solution to it, would then make them aware of the problem, and the solution would be put to use. It's a bit like how swift makes you use self within closures, that clues you in as to what has been captured. Having a send/enqueue/tell function on the actor would clue you in that there is a reason for it.

For now, I've found a different solution to our particular bug. But again I agree, I really think it would be useful.

2 Likes

I guess you could create another actor to operate a queue for the first actor?

Then you could say something like

let actorQueue = ActorQueue(self)
:
await actorQueue.send { await self.handle(completion: completion) }

and the ActorQueue would simply run all such enqueued blocks sequentially.

How would you send a completion block to the ActorQueue without await?

I think that's the tricky bit. It would have to be a call to a nonisolated actor method, and that would need to somehow enqueue the work.

Is there a workaround for this? I tried out all the new async features in a sample App and I also stumbled across this problem in the context of UI code triggering async methods. Basically the order of the user input is not deterministic anymore when it comes to async work - which is a problem. Actually, I think this is a missing feature that we need - or do I miss something?

As a workaround, i scheduled user input as messages to an AsyncStream and where reading them out in an async loop to get back to correct order - but this introduces new problems (like loosing the information when an async action is over).

Exactly - same problem again

I'm not sure what you mean. I do have an await before send().

Something like this?

actor ActorQueue<TargetActor: Actor> {
    typealias Work = @Sendable (TargetActor) async -> Void
    let actor: TargetActor
    var queue: [Work] = []
    var isProcessing = false
    init(_ actor: TargetActor) { self.actor = actor }
    func send(_ work: @escaping Work) async {
        queue.append(work)
        if !isProcessing {
            isProcessing = true
            Task {
                while !queue.isEmpty {
                    let work = queue.removeFirst()
                    await work(actor)
                }
                isProcessing = false
            }
        }
    }
}

Used like

await actorQueue.send { myActor in await myActor.handle(completion: completion) }

But then you need again a Task to use send in a Sync context.

Yeah, sorry, I guess I tried solving a different problem that nobody actually needs solving :grinning:

I didn't at all solve the problem of going from sync to async while keeping the order. One more reason to use AsyncSequence instead of Combine, I guess :slight_smile:

1 Like

So I guess you need Old Style concurrency to solve that gap? E.g. in my example if you put queue and isProcessing in some sort of mutex then send() could be sync.

It looks like you potentially can workaround this actor limitation with something like this Messenger. It is similar to what @JJJ also posted:


class Messenger<A: Actor> {
    typealias Message = (A) async -> Void
    
    let messages = ThreadSafe(value: Array<Message>())
    let isProcessing = ThreadSafe(value: false)
    let actor: A
    
    init(actor: A) {
        self.actor = actor
    }
    
    func send(_ message: @escaping Message) {
        messages.withValue {
            $0.append(message)
        }
        isProcessing.withValue { processing in
            if !processing {
                startProcessing()
                processing = true
            }
        }
    }
    
    func startProcessing() {
        Task { [weak self] in
            while true {
                guard let self = self else { return }
                let work = self.messages.withValue { $0.popFirst() }
                if let work = work {
                    await work(self.actor)
                } else {
                    await Task.yield()
                }
            }
        }
    }
}


actor MyActor {
    var list: [Int] = []

    func add(_ i: Int) {
        list.append(i)
        print("- \(i)")
    }
}

and the usage would be something like this:

        ...
        let actor = MyActor()
        let messenger = Messenger(actor: actor)
        
        for i in 0...10 {
            messenger.send { actor in
                await actor.add(i)
            }
        }
        ...

Where ThreadSafe is basically an UnfairLock implementation.

This seems to work, but I can't be certain it's correct. And the obvious downside is that there is a spinning while loop with a Task.yield in it, to keep it alive. I'm not sure that's acceptable, but it might be the best we can do without something built-in.

Either way, to achieve something like this, the code is not trivial in my estimation.

It is not deterministic what order the actor gets those messages in, and we have no way to express “enqueue but don’t wait”, so the only way to get the ordering we want to so await on both calls in the current task.

This is really a bit problematic for some use cases.

To be able to use send instead of Task to just enqueue an item in the mailbox would help, I'd view the send to fundamentally be identical to an await that it allows calling an async function, but with the semantical difference that is not an suspension point, but just enqueue it in the mailbox (only valid for void functions). Basically it's a noawait. Is that a reasonable understanding?

The simplified case with two async lets are conceptually similar to if I would had two lines with pthread_create (sorry for mixing analogues, that's my mental model for Task creation) - I'd definitely need to await the completion of the first call if I want to guarantee sequencing on the actor call, and I can't see how even a 'send' actor operation would help in that case (unless you simply would remove the async let and just to send two lines after each other). I think one can't ever expect any dependencies or ordering between async lets without explicit await/sync in between.

1 Like
Terms of Service

Privacy Policy

Cookie Policy