Reducing TaskGroup results

Consider the following where I want to use TaskGroup to perform a series of tasks concurrency, and then want to collate the results:

func loadObjects1() async -> [Int: Foo] {
    await withTaskGroup(of: (index: Int, foo: Foo?).self) { group in
        for index in 0 ..< 10 {
            group.addTask {
                (index, try? await foo(for: index))
            }
        }

        var values: [Int: Foo] = [:]
        for await result in group where result.foo != nil { // this is fine
            values[result.index] = result.foo
        }
        return values
    }
}

Historically, I have generally simplified the last four lines with reduce:

func loadObjects2() async -> [Int: Foo] {
    await withTaskGroup(of: (index: Int, foo: Foo?).self) { group in
        for index in 0 ..< 10 {
            group.addTask {
                (index, try? await foo(for: index))
            }
        }

        return await group.reduce(into: [:]) { $0[$1.index] = $1.foo } // Sending main actor-isolated 'group' to nonisolated callee risks causing data races between nonisolated and main actor-isolated uses
    }
}

But with Xcode 16.2, if that function is declared within an actor-isolated type, it produces an warning in Swift 5 with strict concurrency checking turned on (and it is an error in Swift 6 mode):

Sending main actor-isolated 'group' to nonisolated callee risks causing data races between nonisolated and main actor-isolated uses

Now, I obviously can accumulate my results manually like I did in loadObjects1. Or, the problem goes away if I make this function nonisolated, as I have in loadObjects3, below:

nonisolated func loadObjects3() async -> [Int: Foo] {
    await withTaskGroup(of: (index: Int, foo: Foo?).self) { group in
        for index in 0 ..< 10 {
            group.addTask {
                (index, try? await foo(for: index))
            }
        }

        return await group.reduce(into: [:]) { $0[$1.index] = $1.foo } // now OK
    }
}

So, clearly, I have at least two workarounds; either make the function nonisolated or just manually accumulate the results. But, it just feels wrong. It seems that reduce should do the job, irrespective of the isolation of the method.

Is this a mistake in the reduce implementation? Or is this really the status quo?


FWIW, the Foo type and the foo(for:) implementation are really irrelevant to the question, but for the sake of a minimal and reproducible example, here they are:

struct Foo: Decodable, Sendable {
    let bar: String
}

nonisolated func foo(for index: Int) async throws -> Foo {
    try await Task.sleep(for: .seconds(2))
    return Foo(bar: "\(index)")
}
1 Like

Adding @Sendable to the task group closure (ie, await withTaskGroup(...) { @Sendable group in ...) also seems to solve the error, but I agree, that's less than ideal. I'm just not sure if it's a case of the compiler not being able to actually tell if things are @Sendable otherwise, or if this is a bug.

2 Likes