Skip to content
AgileLogics
7 min read

Structured Concurrency: async let and Task Groups

Prerequisites: Async/await

Structured concurrency runs several pieces of work at the same time, with a guarantee: child tasks can't outlive the scope that created them. Errors and cancellation flow through the structure automatically.

async let — a fixed number of concurrent tasks

func fetchUser(id: Int) async -> String {
    try? await Task.sleep(for: .seconds(1))
    return "User \(id)"
}

func loadBoth() async -> [String] {
    async let first = fetchUser(id: 1)
    async let second = fetchUser(id: 2)
    return await [first, second]
}

let start = ContinuousClock.now
let users = await loadBoth()
let elapsed = ContinuousClock.now - start
print(users)
print("Took about \(elapsed.components.seconds) second(s)")
["User 1", "User 2"]
Took about 1 second(s)

async let starts each call immediately as a child task. The program waits only at await, so two one-second calls finish in about one second.

Task groups — a dynamic number of tasks

When the number of tasks depends on data, use a task group:

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

let total = await withTaskGroup(of: Int.self) { group in
    for n in 1...5 {
        group.addTask { await square(n) }
    }

    var sum = 0
    for await value in group {
        sum += value
    }
    return sum
}

print(total)
55

Results arrive in completion order, not the order tasks were added — collect them into a dictionary keyed by input if order matters. Use withThrowingTaskGroup when child tasks can throw; if a child's error is rethrown out of the group (for example, by for try await), the remaining tasks are cancelled.

To limit how many run at once (say, 4 downloads at a time), add the first 4 tasks, then add one more each time one finishes.

Cancellation

Cancellation in Swift is cooperative: a cancelled task isn't stopped forcibly — it's asked to stop, and well-behaved code checks:

func processAll(_ items: [Int]) async throws -> Int {
    var done = 0
    for _ in items {
        try Task.checkCancellation()     // throws CancellationError if cancelled
        try await Task.sleep(for: .milliseconds(50))
        done += 1
    }
    return done
}

let task = Task { try await processAll(Array(1...100)) }
try? await Task.sleep(for: .milliseconds(120))
task.cancel()

do {
    _ = try await task.value
} catch is CancellationError {
    print("Cancelled")
}
Cancelled

Task.sleep and many system APIs, such as URLSession, respond to cancellation automatically.

Common mistakes

  • Creating many unstructured Task { }s in a loop. Nothing waits for them or cancels them; a task group does both.
  • Long loops that never check Task.isCancelled, so cancelling does nothing.