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:
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.
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.
the relevant rule is described in the last paragraph of this proposal section here↩︎
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
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.
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).
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.