async let, task groups and cancellation

Swift · Advanced · 14 min

Parallelism with automatic cleanup and cancellation is what makes Swift concurrency safer than raw threads or GCD.

Structured concurrency means child tasks live inside a scope: the parent can't finish until all its children have, errors propagate up, and cancelling the parent cancels the children.

`async let` starts a child task immediately and lets you await its result later:

async let profile = loadProfile()
async let posts = loadPosts()
let page = try await Page(profile: profile, posts: posts)

Both loads run in parallel. If you leave the scope without awaiting one, it is cancelled and awaited implicitly.

For a dynamic number of children, use a task group: withTaskGroup(of:) or withThrowingTaskGroup(of:). Inside, group.addTask { ... } spawns children and for await result in group collects results in completion order. In a throwing group, the first error thrown out of the body cancels the remaining children.

Cancellation is cooperative: cancelling only sets a flag. Long-running code must check it with try Task.checkCancellation() (throws CancellationError) or Task.isCancelled. Many system APIs such as Task.sleep and URLSession already throw when cancelled. Use withTaskCancellationHandler to react immediately, e.g. to cancel a callback-based operation.

Task { } and Task.detached { } create unstructured tasks that are not tied to the current scope; you must manage and cancel them yourself.

Example

func square(_ n: Int) async throws -> Int {
    try await Task.sleep(for: .milliseconds(100 * n))
    try Task.checkCancellation()
    return n * n
}

func sumOfSquares(_ ns: [Int]) async throws -> Int {
    try await withThrowingTaskGroup(of: Int.self) { group in
        for n in ns { group.addTask { try await square(n) } }
        var total = 0
        for try await value in group { total += value }  // completion order
        return total
    }
}

async let a = square(2)
async let b = square(3)
print(try await a + b)                       // 13, in about 0.3 s not 0.5 s
print(try await sumOfSquares([1, 2, 3]))     // 14

let slow = Task { try await sumOfSquares([10, 20]) }
slow.cancel()
print(await slow.result)                     // failure(CancellationError())

Remember

  • async let and task groups run children in parallel within a scope
  • Group results arrive in completion order
  • Cancellation is cooperative — check it in long loops

Watch out for

  • Spawning Task { } inside loops instead of a task group, losing cancellation and error propagation.

Check yourself

  1. You cancel a task whose code never awaits or checks cancellation. What happens?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Tasks-and-Task-Groups

Builds on

Unlocks

Nothing builds on this yet.