Explicitly captured `isolated` parameter does not change isolation of `@Sendable`/`sending` closures

Hi! I've came across an unexpected behavior when explicitly capturing an isolated parameter in TaskGroup.addTask closure and I'm trying to figure out whether this is intended behavior or a bug:

actor MyActor {}

func test(isolation: isolated MyActor) async {
    isolation.assertIsolated() // OK

    await withTaskGroup { group in
        isolation.assertIsolated() // OK

        await Task {
            isolation.assertIsolated() // OK
        }.value

        group.addTask {
            isolation.assertIsolated() // Crash: Incorrect actor executor assumption
        }

        await group.waitForAll()
    }
}

await test(isolation: MyActor())

I've confirmed the difference comes from the @_inheritActorContext attribute, which is used by Task.init but not by TaskGroup.addTask. Nevertheless, since the isolated parameter is captured explicitly in both cases I would assume that assertions should pass as the closure should become isolated to the capture.

I've made a couple more examples which illustrate this behavior in more detail:

func callWithInheritedActorContext(
    @_inheritActorContext closure: @Sendable @isolated(any) () -> Void
) async {
    await closure()
}

func test2(isolation: isolated MyActor) async {
    await callWithInheritedActorContext {
        isolation.assertIsolated() // OK
    }
}
func callWithoutInheritedActorContext(
    closure: @Sendable @isolated(any) () -> Void
) async {
    await closure()
}

func test3(isolation: isolated MyActor) async {
    await callWithoutInheritedActorContext {
        isolation.assertIsolated() // Crash: Incorrect actor executor assumption
    }
}
func test4(isolation: isolated MyActor) async {
    let closure: @Sendable @isolated(any) () -> Void = {
        isolation.assertIsolated() // Crash: Incorrect actor executor assumption
    }
    await closure()
}
extension MyActor {
    func test5() async {
        let closure: @Sendable @isolated(any) () -> Void = {
            self.assertIsolated() // Crash: Incorrect actor executor assumption
        }
        await closure()
    }
}

When using sending instead of @Sendable or making closures explicitly async rather than @isolated(any) the examples crash in the same way.

Recently accepted SE-461 seems to explain that this is not a new behavior:

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.

The closure is also inferred to be nonisolated if the enclosing context has an isolated parameter (including self in actor-isolated methods), and the closure does not explicitly capture the isolated parameter. This is done to avoid implicitly capturing values that are invisible to the programmer, because this can lead to reference cycles.

This would indicate that the @Sendable/sending closures are unconditionally nonisolated (unless annotated with @_inheritActorContext as seen above).

However, if this is indeed true and even explicit capture of isolated parameter should not change isolation of @Sendable/sending closures then closures of type like sending @isolated(any), used by TaskGroup.addTask, have rather unfortunate semantics of being nonisolated and @isolated(any) at the same time.

I would appreciate if someone could take a look and say whether this is intended behavior. Thanks!

3 Likes

This is the correct and expected behavior nowadays.

I do understand though this can be difficult to follow since we had a number of inference rules come out over time and they not have exactly intuitive interactions.

What happens here is that Task.init is the "special 'different' one", all the other APIs about child tasks do not automagically inherit through the closing over over an isolated parameter because their purpose is to introduce parallelism.

TaskGroup is designed specifically to introduce concurrency and run child tasks in parallel. If we just inferred by capture we'd constantly linearize their execution almost by accident one might say.

If you're curious on the exact mechanics:

The enqueue happens on the isolation we obtain from the @isolation(any) closure, so closure.isolation basically. That type is inferred currently only e.g. main actors, and we're missing "closure isolation control" to allow it for instance actors.

The "just capture the isolated param" is the Task.init special case, not the general rule.

Future directions

What you're expecting is what the following pitch would enable: Closure isolation control but we've not implemented it yet. Then you could do g.addTask { [isolated param] in } which then would indeed enqueue and isolate to that parameter.

Closure isolation control currently is not implemented, with the exception of global actors since you can addTask { @MainActor in } for example. In other words, this would do what you expected:

    group.addTask { @MainActor in
      MainActor.assertIsolated("inside addTask, @MainActor")
    }

but just closing over an isolated param does not.

Hope this helps

2 Likes

For what it's worth, the semantics you're asking for may be actually expressible using the new APIs we're adding right now: "immediate tasks", please check the review thread over here: [Returned for revision] SE-0472: Starting tasks synchronously from caller context - #3 by ktoso

Basically, this introduces group.addImmediateTask {} which very aggressively start the task on the calling context, regardless of any closing over it or not. The existance of that isolated parameter in the surrounding context will be enough to achieve this.

3 Likes

Thank you for the detailed response, that clarifies it for me!

1 Like