Actor inheritance when spawning task indirectly via synchronous non-isolated function

Checking my understanding here:

When spawning an unstructured Task from an actor isolated function, like so:

  @MainActor func myFunc() {
    Task {
      print("hi from the MainActor!")
    }
  }

The task concurrency context inherits from @MainActor as expected (breaks on main thread).

However, if we add another call that spawns an unstructured Task indirectly, from a @MainActor annotated function, via a non-isolated synchronous function like so:

  @MainActor func myFunc() {
    Task {
      print("hi from the MainActor!")
    }
    myDelegatedFunc()
  }

  func myDelegatedFunc() { // not called directly, called via `myFunc()`
    Task {
      print("hi from the Cooperative Thread Pool!")
    }
  }

And the new Task inherits launches on the cooperative thread pool. Is that expected?

2 Likes

Yes :slight_smile: swift-evolution/0338-clarify-execution-non-actor-async.md at main · apple/swift-evolution · GitHub

1 Like

The linked article talks about async funcs, this is a synchronous function. Still applies?

Ok, just found @ole ‘s article Where View.task gets its main-actor isolation from – Ole Begemann

That’s surprising. In my opinion It would be more intuitive if either Task’s always spawned on the cooperative pool, or synchronous non isolated functions inherited their caller’s actor context.

@tcldr It is my understanding that's the case. myDelegatedFunc will be called on the main executor, but the Task will be run on the default executor, unless you're marking myDelegatedFunc with @MainActor too.

Yup, looks like it. Surprised by that. It makes it very difficult to keep execution on the main actor.

It rules out the idea of creating utility code that can run on a chosen global actor unless you can annotate it statically in code. With concerns raised around the performance cost of actor hopping that seems quite a limiting constraint,

Would something like this work?

func spawnTaskOnGlobalActor<T: GlobalActor>(_ type: T.Type, body: @Sendable @escaping () async -> Void) {
    Task {
        await { (_: isolated T.ActorType) in
            await body()
        }(T.shared)
    }
}

@Sendable func myUtilityFunc() async {
    print("Hello from wherever I am!")
}

spawnTaskOnGlobalActor(MainActor.self, body: myUtilityFunc)

It's a bit verbose, but it compiles at least, and I think it should work.

1 Like

That's not working for me, unfortunately. It still runs on the cooperative thread pool. If it did work, I imagine you'd actually get two hops as well. The initial hop on to the cooperative thread pool when the Task is spawned, then another to go back to the GlobalActor when the body is called.

Interesting idea though!

1 Like