withTaskGroup runs a dynamic number of child tasks in parallel and collects their results.
The group is an AsyncSequence, so you await results with a for await loop. Back-deployed to
iOS 13 and macOS 10.15.
The basic shape
The first parameter is each child's result type, the second the accumulated return type, and
the closure populates the group with addTask.
func fetch(urls: [URL]) async {
messages = await withTaskGroup(of: String.self, returning: [String].self) { group in
for url in urls {
group.addTask(priority: .high) { await fetch(url) }
}
var messages: [String] = []
for await message in group where !message.isEmpty {
messages.append(message)
}
return messages
}
}
The group does not inherit the parent's actor. Child tasks run on the cooperative thread pool,
so a @MainActor caller does not drag the work onto the main thread.
Cancellation is cooperative
Cancelling the group, with cancelAll() or through the parent task, does not stop a child by
itself. Check Task.isCancelled inside the child and bail out.
group.addTask {
if Task.isCancelled { return "" }
return await fetch(url)
}
for await message in group where !message.isEmpty {
messages.append(message)
if messages.count > 3 { group.cancelAll() } // stop after enough results
}
Bound the in-flight tasks
Adding 1000 children at once allocates 1000 suspended tasks while the pool works through them,
which spikes memory. Seed a small batch, then add one task each time a result arrives.
addTaskUnlessCancelled skips the add once the group is cancelled.
let maxConcurrentRequests = min(urls.count, 10)
var index = -1
for _ in 0..<maxConcurrentRequests {
group.addTask { index += 1; return await self.fetch(urls[index]) }
}
for await message in group where !message.isEmpty {
messages.append(message)
if index < urls.count {
group.addTaskUnlessCancelled { index += 1; return await self.fetch(urls[index]) }
}
}