Why does `withTaskGroup`'s closure not run in the same isolation as `withTaskGroup`'s isolation parameter?

I thought that withTaskGroup's isolation parameter specified the isolation where the closure actually runs at runtime. However, the folloiwng example's output indicates that's not the case.

actor A {
    func foo() async {
        print("foo: \(#isolation as (any Actor)?)")

        await withTaskGroup(of: Void.self, returning: Void.self, isolation: #isolation) { group in
            print("withTaskGroup closure: \(#isolation as (any Actor)?)")
        }
    }
}

@MainActor
func test() async {
    let a = A()
    await a.foo()
}

await test()

// Output:
// foo: Optional(output.A)
// withTaskGroup closure: nil

Note: I think the nil value of #isolation macro accurately indicates the executor where the closure runs, because:

  1. My experiment shows that #isolation macro in function taking isolaiton parameter has similar behavior as in nonisolated(nonsending) function. Its value shows the actual executor where the function runs.

    Example
    func bar(isolation: isolated (any Actor)? = #isolation) async {
        print("\(#isolation as (any Actor)?)")
    }
    
    @MainActor
    func test() async {
        await bar()
    }
    
    await test()
    
    // Output:
    // Optional(Swift.MainActor)
    
  2. An alternative way to prove that withTaskGroup's closure in the original example doesn't run on the actor's executor: Compiler Explorer

All the above code were tested on nightly build.

1 Like

Does not answer the question, but why does the following not compile?

await withTaskGroup (of: Void.self, returning: Void.self, isolation: A ()) { group in
    print ("withTaskGroup.closure: \(isolation)")
//                                  ^
//                                  |
// Expansion of macro 'isolation()' requires leading '#'
}

It's because isolation parameter is "invisible" to the closure. Your usage is a bit similar to the following code:

func fn(a: Int, b: Int) {}
func test() {
    let n = 0
    fn(a: n, b: a) // this doesn't compile
}
1 Like

The isolation parameter only influence where the withTaskGroup function itself runs, the closure is not affected under the current implementation.

We can use a manually altered version of withTaskGroup to see this:

func withMyTaskGroup<ChildTaskResult, GroupResult>(
  of childTaskResultType: ChildTaskResult.Type = ChildTaskResult.self,
  returning returnType: GroupResult.Type = GroupResult.self,
  isolation: isolated (any Actor)? = #isolation,
  body: (inout MyGroup<ChildTaskResult>) async -> GroupResult
) async -> GroupResult {
  // not a real implementation, but close enough to show executor hopping
  print("enter withMyTaskGroup ")
  var myGroup = MyGroup<ChildTaskResult>()
  print("before closure call")
  let result = await body(&myGroup)
  print("leaving withMyTaskGroup")
  return result
}

It we replace the withTaskGroup in your godbolt snippet with withMyTaskGroup, we can see when entering withMyTaskGroup we are still on actor A, but because the body argument closure in somehow inferred to be nonisolated [1], Swift dispatch it to be run on the global concurrent executor.


  1. the relevant rule is described in the last paragraph of this proposal section here ↩︎

3 Likes

Thanks, that makes sense. A related quesiton. Are there valid scenarios where a user would like withMyTaskGroup function to run in a different isolation than the caller's isolation? I doubt it, because there is no user code that can be influenced by isolation parameter value. It appears the only purpose of isolation parameter is to inherit caller's isolation. If so, why not remove isolation parameter and change withMyTaskGroup function to nonisolated(nonsending) instead?

For your first question, I kind of agree with you, I cannot come up with any situations where we might not want body to be isolated to the same domain as the enclosing one. So for me, maybe the ideal signature should be

func withTaskGroup<ChildTaskResult, GroupResult>(
  ...,
  isolation: isolated (any Actor)? = #isolation,
  body: nonisolated(nonsending) (inout TaskGroup<ChildTaskResult>) async -> GroupResult
) async -> GroupResult

The change is that the type of body is explicitly nonisolated(nonsending).


I read your second question as, how about changing the signature into

nonisolated(nonsending) func withTaskGroup<ChildTaskResult, GroupResult>(
  ...,
  body: (inout TaskGroup<ChildTaskResult>) async -> GroupResult
) async -> GroupResult

For me this is almost identical to the current signature, marking a function nonisolated(nonsending) achieve the same isolation semantics as giving the function an isolated (any Actor)? = #isolation parameter.

1 Like

This is what I meant.

Using isolation parameter is more general, which is usually an advantage but IMO is a disadvantage in this case. Using nonisolated(nonsending) is more clear in API's semantics. For example, I wouldn't have had the confusion if the function had been defined as nonisolated(nonsending).

Not much to add here. Mr. Wu already gave an excellent answer, but FYI, there is an open PR adopting nonisolated(nonsending) in the with{...}TaskGroup APIs: [Concurrency] Adopt nonisolated(nonsending) in withTaskGroup

Also, since normal closure isolation rules apply, you can isolate with{...}TaskGroup’s body closure to the context’s actor by either capturing the isolated parameter, including the implicit self, or calling withTaskGroup from a global-actor-isolated context.

IIUC, the #isolation macro no longer works only for static isolation, but should also work for dynamic isolation.

2 Likes

Thanks. The new signature of withTaskGroup contains the changes I and @CrystDragon talked about:

public nonisolated(nonsending) func withTaskGroup<ChildTaskResult, GroupResult>(
  of childTaskResultType: ChildTaskResult.Type = ChildTaskResult.self,
  returning returnType: GroupResult.Type = GroupResult.self,
  body: nonisolated(nonsending) (inout TaskGroup<ChildTaskResult>) async -> GroupResult
) async -> GroupResult