How do tasks form a tree: Task, task groups, async let and detached tasks, and what does a child inherit?
Predict first
What does a task group yield for three children, collected and sorted?
let results = await withTaskGroup(of: Int.self) { grp in
for i in 1...3 { grp.addTask { i * 10 } }
var out: [Int] = []
for await v in grp { out.append(v) }
return out.sorted()
}
print(results)
- [10, 20, 30]
- [30, 20, 10]
- The order is not defined, so it varies without the sort
Predict first
A task-local value is set around a child Task and a detached one. What does each see?
enum Ctx { @TaskLocal static var id = 0 }
await Ctx.$id.withValue(9) {
await Task { print("child", Ctx.id) }.value
await Task.detached { print("detached", Ctx.id) }.value
}
- child 9, detached 9
- child 9, detached 0
- child 0, detached 0
Mental model. A structured task is a delegated errand that you must wait for before leaving the room. An unstructured task is a letter you mailed: it goes on without you unless you kept the tracking number.
A structured child task is created by async let or group.addTask. It belongs to its parent’s scope: the scope does not finish until all its children do, cancellation of the parent propagates down, and a thrown error from a child can cancel its siblings and rethrow in the parent. A task group (withTaskGroup, withThrowingTaskGroup) creates a dynamic number of children of the same result type, and you consume results as they complete (for await, not in submission order): collect and sort them if order matters, and limit concurrency by adding tasks gradually (start a few, then add one as each finishes). Leaving a group’s scope waits for, and if you exit by throwing cancels, the remaining children.
An unstructured Task { } starts a new top-level task that inherits the current actor isolation, priority and task-local values, and is not awaited unless you keep and await its handle; it is the bridge from synchronous code. Task.detached { } inherits nothing (no actor, no task locals, its own priority): use it rarely, for work that must not be tied to the caller, and note that it is not cancelled when the creator is. Structured tasks are preferred because lifetimes, cancellation and errors take care of themselves, while unstructured tasks need manual tracking (store the handle, cancel it in deinit, or tie it to a view’s lifetime with .task). Task priorities are inherited and can be raised when a higher-priority task awaits a lower one. Use Task.currentPriority to read it, and avoid starting work at a priority different from what the user sees.
Results arrive in completion order, so sorting gives a stable answer.
A Task inherits task locals, a detached task starts clean.
Common traps
- Expecting group results in submission order. They arrive as each child finishes; sort afterwards.
- Using
Task.detachedto “get off the main actor”. It drops task locals and priority; prefer anonisolated asyncfunction.
They might follow up with
- What does a
Task { }inherit? Actor isolation, priority and task-local values. - How do you cap the number of concurrent child tasks? Add a few to the group, then add the next one each time one completes.
Related questions
- 10.3.1 What does async/await actually do, and what is a suspension point?
- 10.3.3 How does cancellation work in Swift concurrency, and what are task priorities and task locals for?
- 10.4.2 How do @MainActor, global actors, isolated and nonisolated work, and how does isolation propagate?
← What does async/await actually do, and what is a suspension point? How does cancellation work in Swift concurrency, and what are task… →