In what order do task group results arrive, and how do you limit how many children run at once?
Predict first
What are the completion order and the peak concurrency?
import Foundation
actor Gauge {
var now = 0, peak = 0
func enter() { now += 1; peak = max(peak, now) }
func leave() { now -= 1 }
func peakValue() -> Int { peak }
}
func work(_ id: Int, _ ms: UInt64) async -> Int {
try? await Task.sleep(nanoseconds: ms * 1_000_000)
return id
}
let delays: [UInt64] = [90, 10, 50]
let order = await withTaskGroup(of: Int.self) { g -> [Int] in
for (i, d) in delays.enumerated() { g.addTask { await work(i, d) } }
var r: [Int] = []
for await v in g { r.append(v) }
return r
}
print("completion order:", order)
let gauge = Gauge()
let limit = 3
await withTaskGroup(of: Void.self) { g in
var jobs = Array(0..<8).makeIterator()
func launch(_ g: inout TaskGroup<Void>) {
guard let j = jobs.next() else { return }
g.addTask { await gauge.enter(); _ = await work(j, 20); await gauge.leave() }
}
for _ in 0..<limit { launch(&g) }
while await g.next() != nil { launch(&g) }
}
print("peak concurrency:", await gauge.peakValue())
- completion order: [1, 2, 0] / peak concurrency: 3
- completion order: [0, 1, 2] / peak concurrency: 8
- completion order: [1, 2, 0] / peak concurrency: 8
Mental model. A checkout with three tills: customers are served in whatever order they finish, and a new customer steps up only when a till frees.
A task group yields results in completion order, not the order you added the children. Three children that sleep 90, 10 and 50 ms come back as [1, 2, 0]. If you need the original order, return the index with each result ((index, value)) and sort or place by index afterwards. Adding children never blocks, and a group does not cap parallelism for you: add 10,000 children and the runtime will try to run as many as its pool allows, which for network or database calls means a flood of requests.
To bound the fan-out, seed the group with limit children and add one new child each time one finishes (while await g.next() != nil { launch() }). The gauge inside the check records the peak number of children running at once: 3, never more, even with eight jobs. This is the structured-concurrency replacement for a semaphore or an OperationQueue.maxConcurrentOperationCount, and it keeps cancellation and errors flowing through the group. Pick the limit from the resource (connections, CPU cores, memory per job), not from how many items you have.
Results arrive as children finish; the refill loop keeps at most three running.
Common traps
- Assuming results come back in the order you added them. Carry an index if order matters.
- Adding one child per item with no limit. Ten thousand simultaneous requests is a self-inflicted outage.
They might follow up with
- What does
g.next()return at the end?nilonce every child has finished, which ends thewhileloop. - Where does the limit come from on the server side? The service’s own concurrency or rate limit; match it so you are not retried into a storm (Q324).
Related questions
- 10.8.2 What happens to the other children when one child in a throwing task group fails, and what does a discarding…
- 10.8.3 What happens to an async let that you never await when the scope ends?
- 10.11.1 What goes wrong with an unbalanced DispatchGroup, and how do you use a semaphore to throttle work without…
- 10.3.2 How do tasks form a tree: Task, task groups, async let and detached tasks, and what does a child inherit?
- 10.1.2 How do barriers, groups, semaphores and dispatch sources coordinate work?
- 12.5.1 How should a client retry failed requests: what to retry, how long to wait, and what idempotency has to do…
What happens to the other children when one child in a throwing task… →