`Task + @MyActor` vs `Task + await`

Which one is better, bar() or baz() or are they the same?

@MyActor class Test {
    func foo() {}
    
    nonisolated func bar() {
        Task { @MyActor in
            foo()
        }
    }
    nonisolated func baz() {
        Task {
            await foo()
        }
    }
}

And with "actor Test" there would be no other option but baz()?

1 Like

the two are not the same. under the current language semantics the Task closure in baz() has the property that it is 'formally nonisolated' and so has to switch to the concurrent executor before switching back to the global actor's executor to invoke foo(). this means consecutive synchronous calls to baz() could result in foo() running in an arbitrary order. the Task closure in bar() OTOH is isolated to MyActor via its annotation, and so will synchronously enqueue onto MyActor's executor, so should start its Tasks in the same order that the function was called. both still have the typical caveats surrounding unstructured concurrency, but personally i think the order-preservation in the bar() example is typically more desirable than the alternative here.

3 Likes

[...] the property that it is 'formally nonisolated'

Forever a Concurrency noob here, and with that in mind: since both functions are declared nonisolated, aren't they both formally nonisolated, in the eyes of the compiler? If not, what makes only one be formally non-isolated?

Would it be correct to say that the only desirable side effect of writing a function like bar() is to make sure it is non-blocking but still isolated to the actor, with its body "potentially executing at a later time but order-preserving" (as you mentioned)? Would it also imply that if the function is already called within @MyActor isolation, the Task is free to start immediately? Or is it that Task always implies scheduling for execution at a later time, even if its closure matches the current isolation?

Thank you both!

1 Like

thanks for pointing this out – i was imprecise in the way i phrased things, which is confusing upon re-reading. you are correct that both bar() and baz() are nonisolated. what i meant was: in the baz() case, the Task closure is 'formally nonisolated'. this is because it is declared within a nonisolated function and does not use a global actor annotation in its signature. i'll update my original comment to clarify this.

1 Like

i think that would be the primary reason to do something like it, though there are other ways of achieving that behavior that don't require instantiating new Tasks in every call, via something like AsyncStream.

the Task initializer never begins running the operation synchronously in the calling context, even if there is no isolation crossing – it will always enqueue the initial execution on the appropriate actor's executor. however, support for the behavior i think you're alluding to was recently pitched and accepted via the Task.immediate API (though i'm unclear on the precise state of its implementation status).

1 Like