Hi, I'm experimenting with the @isolated(any) attribute on functions introduced in SE-0431.
If I understand it correctly, closures annotated with @isolated(any) should carry isolation (Actor reference) from the context where they were created.
It works when the said context is some global actor like MainActor or a GlobalActor, but it fails when using a "regular" actor isolation — the closure.isolation attribute is always nil.
actor Shop {
init() {
Task {
await onMainActor() // OK, prints MainActor
await onGlobalActor() // OK, prints UtilityActor
await onSelfActor() // callback.isolation is nil, should be "Shop" as this is isolated to Shop actor
}
}
func onMainActor() {
Task { @MainActor in
print(#isolation) // MainActor
printIsolationName {
}
}
}
func onGlobalActor() {
Task { @UtilityActor in
print(#isolation) // UtilityActor
printIsolationName {
}
}
}
func onSelfActor() async {
print(#isolation) // Shop
printIsolationName {
}
}
}
func printIsolationName(callback: @isolated(any) () -> Void) {
let actor = callback.isolation
print(String(describing: actor)) // Prints nil for 'onSelfActor()' function
}
You've hit a weirdness in how isolation inference works.
This is "expected", but it can be pretty confusing and it's come up in debate a few times if we should fix the behavior to be what you expected -- since the closure was formed in the actor, and is not sendable etc, it should be isolated to that actor right?
Well, today, that's not how it works... The closure gets the isolation from "an isolated value it closes over", so... since the closure is empty, it does not close over any state and therefore was inferred to be nonisolated.
To get the behavior you expected you have to close over the self:
I came to realize why in OP's case, the first two closures passed to printIsolationName are inferred to be isolated to respective global actors. Because printIsolationName accepts a NonSendable closure, the compiler can then reason from the fact that there cannot be any isolation crossing (in this case, both global actor isolated and nonisolated are OK, the compiler prefers global actor isolated apparently).
actor Shop {
init() {
Task {
await onMainActor()
}
}
func onMainActor() {
Task { @MainActor in
printIsolationName {
callableInMain() // perfectly fine, which indicates this closure is actually inferred as @MainActor.
}
}
}
}
@MainActor
func callableInMain() {}
func printIsolationName(callback: () -> Void) { }
If we make it Sendable, the compiler will complain.
actor Shop {
/*.. same as before .. */
func onMainActor() {
Task { @MainActor in
printIsolationName {
callableInMain() // <- Error: no no, it must be awaited. It indicates the closure is not MainActor isolated.
}
}
}
}
func printIsolationName(callback: @Sendable () -> Void) { }
For the above to work, now we need @_inheritActorContext:
Just wanted to comment that I find the current behavior very non-intuitive. Running the following code, I get:
@main struct Playground {
static func main() async throws {
print(#isolation) // 1. MainActor
printIsolation {} // 2. MainActor
let a = A(), b = B(), c = C()
await a.foo(b) // 3. A
await b.foo(c) // 4. nil
await c.foo(a) // 5. nil
}
}
actor A {
func foo(_ `actor`: some Actor) {
printIsolation { _ = self }
}
}
actor B {
func foo(_ `actor`: some Actor) {
printIsolation { _ = `actor` }
}
}
actor C {
func foo(_ `actor`: some Actor) {
printIsolation { }
}
}
func printIsolation(_ operation: @isolated(any) () -> Void) {
print(operation.isolation)
}
Before @ktoso explanation, I would have expected 4 and 5 to be their respective actor callers (i.e. B and C). After the explanation, I would have expected 4 to be B
I still can’t understand this — onGlobalActor calls the printIsolationName method while being in its own isolated context ( print(#isolation) prints "UtilityActor" ), and similarly, onSelfActor calls printIsolationName while also being in its own isolated context (print(#isolation) prints "Shop").
Neither of them captures anything, and yet one brings its isolation with it, and the other doesn’t. I don’t understand what rule causes the global actor to be treated differently here.
In this proposal SE-0420, we find the following text:
According to SE-0304, closures passed directly to the Task initializer (i.e. Task { /*here*/ }) inherit the statically-specified isolation of the current context if:
the current context is non-isolated,
the current context is isolated to a global actor, or
the current context has an isolated parameter (including the implicit self of an actor method) and that parameter is strongly captured by the closure.
if the inheritance of isolation in Task initializer closure is the same as for regular closures (both are non-sendable BUT only Task initializer closure has @_inheritActorContext), then the third rule clarifies this:
We need to specify isolation via an isolated parameter (which self provides implicitly) and that's why 3 prints "A".
However, we currently cannot specify isolation in closures directly (closure isolation proposal) and that's why 4 prints "nil" because there is no [isolated actor] in in closure.
And second rule explains why, in my examples, Global Actor isolation is carried over into the closure without any captures. @CrystDragon
FWIW, I find the reason for the subtle difference in SE-0461:
The isolation of a closure can be explicitly specified with a type annotation or in the closure signature. If no isolation is specified, the inferred isolation for a closure depends on two factors:
The isolation of the context where the closure is formed.
Whether the contextual type of the closure is @Sendable or sending.
If the contextual type of the closure is neither @Sendable nor sending, the inferred isolation of the closure is the same as the enclosing context.
...
If the type of the closure is @Sendable or if the closure is passed to a sending parameter, the closure is inferred to be nonisolated.
That explains why your second example doesn't compile without @_inheritActorIsolation.
Caveat: Your examples are unrelated to OP's original example, because they don't use @isolated(any).