Guaranteeing an actor executes off the main thread

I feel I might be missing something obvious, but I can't find a good resource explaining the best way to guarantee that an actor uses a background thread. From what I've gathered, if you create your actor in a detached task then it will stay isolated in the specified priority. So something like:

@MainActor
func foo() async {
  /// Technically, "same executor as the main actor"?
  let actorWhichExecutesOnTheMainActor = MyActor()
  let actorWhichExecutesInTheBackground = await Task.detached(priority: .background) {
     MyActor()
  }.value
  /// body of `synchronousFunction` executes in the background?
  let foo = await actorWhichExecutesInTheBackground.synchronousFunction()
  /// body of `synchronousFunction` executes on the main thread?
  let foo = await actorWhichExecutesOnTheMainActor.synchronousFunction()
}

I've also seen some folks recommending defining a custom @globalActor, but I'm not clear if this is sufficient to guarantee that this global actor runs on a background thread. You can also define a custom executor using a framework like Dispatch, which seems like it would work but feels like a bunch of ceremony just to guarantee an actor is executed off of the main thread.

Am I understanding this correctly? Is there consensus on what is the correct approach?

Every actor which is not the main actor executes on a background thread; this is actually a public guarantee and is stated in the documentation: Actor | Apple Developer Documentation. More specifically, if an actor doesn't specify a custom executor, it's given a default executor instance, which simply delegates to that background thread pool.

I think you're assuming that an actor becomes "bound" to whatever place it has been created in, but this is not true; an actor is always its own isolation domain regardless of whether its initializer got called by something running on the main actor or not, and because it already is concurrency-safe, it can execute concurrently with its creator ā€” through using the global threadpool.

The good news is that you don't have to do anything :slight_smile: Looks like you want precisely the default behaviour.


Also, priority is a property of a task, not an actor. That is, if you call the same actor method twice from tasks with different priorities, like so:

func foo() async {
    let myActor = MyActor()
    
    Task(priority: .high) {
        await myActor.synchronousFunction(1)
    }
    
    Task(priority: .low) {
        await myActor.synchronousFunction(2)
    }
}

it only affects the scheduling order of the calls, but never "infects" the actor for the rest of its lifetime.

3 Likes

This is not necessarily true if the actor uses a custom executor.

1 Like

Thanks! Not sure why I thought differently!

If you do find documentation that suggests otherwise, definitely point it out so we can get it fixed! This is a surprisingly common question, which makes me wonder if there's something documented somewhere that's either incorrect or unclear.

1 Like

Honestly Iā€™m pretty sure I knew this at some point in the past, but going deeper on some implementation details of structured concurrency, particularly isolation / custom executors (incorrectly) broke my understanding.