Actor Reentrancy

I am reading the Actors Proposal (SE-0306), and I am totally confused :confused:

I have no problem understanding this:

Actor isolation

The second form of permissible cross-actor reference is one that is performed with an asynchronous function invocation. Such asynchronous function invocations are turned into "messages" requesting that the actor execute the corresponding task when it can safely do so. These messages are stored in the actor's "mailbox", and the caller initiating the asynchronous function invocation may be suspended until the actor is able to process the corresponding message in its mailbox. An actor processes the messages in its mailbox sequentially, so that a given actor will never have two concurrently-executing tasks running actor-isolated code. This ensures that there are no data races on actor-isolated mutable state, because there is no concurrency in any code that can access actor-isolated state.

But my confusion starts after reading this:

Actor reentrancy

Actor-isolated functions are reentrant. When an actor-isolated function suspends, reentrancy allows other work to execute on the actor before the original actor-isolated function resumes, which we refer to as interleaving.
...

Since an actor processes messages in its input queue sequentially and one at a time, how can other work be executed on the same actor?

I feel that I have missed something. :confused:

Could anyone unconfuse me please?

That's actually very easy:

actor Foo {
  var state = 0

  func work() async {
    // assume it's still 0 at this time
    print(state)

    // suspension point of `work`
    await doSomeLongWork() 

    // this is no longer guaranteed to be `0` at this point
    print(state) 
  }
  
  func setState(to newValue: Int) {
    state = newValue
  }
}

As far as I can tell an actor only guarantees that it executes one Task at a time, but if the task gets suspended, it's an opportunity for other tasks to run, regardless if the previous task was finished or not. The actor only needs to guaranteed that no task run in parallel. If we would always wait until the active but suspended task would eventually finish we would have a great chance eventually hitting deadlocks. That said when doSomeLongWork is suspended, another task can access the actor and potentially use setState(to:) to mutate the value. Hence when doSomeLongWork resumes, state is no longer guaranteed to have the same value as before the previous suspension point.

Feel free to correct me or the terminology I used in case there is something wrong with that. ;)

10 Likes

More than the possibility that someone will call setState() while work() is suspended, it's also possible for something to invoke work() again while work() is suspended.

8 Likes

Following is my understanding:

  • Synchronous functions on an actor run synchronously
  • Asynchronous functions on an actor can suspend (when encountering await) which gives an opportunity for other asynchronous functions (of the same actor or others) to run

Code

actor A {
    func f1() {
        print("f1 started")
        for _ in 1..<4_000_000 {}
        print("f1 ended")
    }
    
    func f2() async {
        print("f2 started")
        try? await Task.sleep(nanoseconds: 4_000_000_000)
        print("f2 ended")
    }

    func f3() async {
        print("f3 started")
        try? await Task.sleep(nanoseconds: 4_000_000_000)
        print("f3 ended")
    }
}

let a = A()

Task.detached {
    await a.f1()
}

Task.detached {
    await a.f2()
}

Task.detached {
    await a.f3()
}

RunLoop.main.run()

Output

f1 started
f1 ended
f2 started
f3 started
f3 ended
f2 ended
2 Likes

Great explanations so far, perhaps allow me to address one thing specifically:

Well, because a "message" in this case it not necessarily an entire function. In case of an asynchronous function, it's basically the blocks separated by awaits. In @DevAndArtist's example the work function basically becomes two "messages". And I think it is important to understand that the second "message" is only "arriving" in the actor once the await doSomeLongWork() is done.
So if the first message (the first print(state)) is done the actor is effectively idle. Any other of its functions (including work) can be called, thus "re-entering" the actor again, resulting in it processing another "message".
Then, eventually, doSomeLongWork returns, giving it yet another "message" to handle, this time for the second print(state).

I guess the confusion stems from comparing this with "the olden ways if DispatchQueue": The two "messages" of work are not immediately enqueued into something that matches a serial DispatchQueue. If that were the case, of course you would not be able to call setState and have it affect the property before the second part of work is done, but that would also block the entire actor until doSomeLongWork is returning.
Instead it works as I described above, or in other words: The compiler is way smarter when it comes to compiling async functions and interpreting the await.

I'm not sure why you're saying that, dispatch queues have the exact same behavior in that regard, that is:

func work() {
    queue.async {
        // first piece of work
        doSomethingAsynchronously(callBackOn: queue) {
            // second piece of work
        }
    }
}

work() can be called in a reentrent fashion before resuming from doSomethingAsynchronously() and executing the second piece of work.

Ah, of course that one is reentrant, but I meant this differently:
I think that a lot of people who are only familiar with dispatch queues would interpret the explanations about actors, isolation, and the "messages" such that isolated async functions are "split" into "messages" and enqueued when the method is called.

So this:

func work() async {
    print("First print: \(state)")
    await doSomeLongWork() 
    print("Second print: \(state)") 
}

would become this:

func work() {
    // as the "mailbox" processes messages sequentially an actor would have a serial queue:
    theActorsSerialQueue.async {
        print("First print: \(state)")
        doSomeLongWork()
    }
    theActorsSerialQueue.async {
        print("Second print: \(state)")
    }
}

Of course work is technically still reentrant, but since both "messages" it was "split into" have already been queued onto a serial queue there's no way the second call's first part can be executed before the first call's second part is done.

I think (?) a better way to think of the actor isolation and async/await mechanic if you want to mentally "wrap" it into queue-like code would be this (which is exactly what you wrote, but again, my hunch is that people don't think it actors and the way await is implemented works):

func work() {
    theActorsSerialQueue.async {
        print("First print: \(state)")
        doSomeLongWorkNowWithCallback() {
            theActorsSerialQueue.async {
                print("Second print: \(state)")
            }
    }
}

This way, reentrant calls to work can be processed before the second "message" is queued onto the actor's serial isolation queue.
But my hunch is that on first read people think it would do it in the first way.
"Re-entrant" in this context then is less of a way to express that you can call any function repeatedly to schedule it several times, but more that the actor's scheduling "queue" is able to "re-enter" at various points (if you "schedule" async functions, it gets more "re-entry-opportunities" than just at the function call itself).

This is only true if work() cannot be called concurrently. Otherwise you still have a possible execution in-between the two parts.

Sure, but that is a whole other can of worms. I just wanted to describe what I believe people who are more familiar with dispatch queues interpret into the more modern syntax of actors and await/async. Of course since work itself is defined on an actor you get this trap prevented "for free", but this, I think, people understand quickly and don't "translate" it into queue-code in their minds.

I wasn't trying to rewrite the entire exact same thing using dispatch queues here, just trying to highlight the differences.