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.