Chapter 6 — Task groups: dynamic concurrency
Creating a group
// ImageDownloader/Downloader.swift
func loadThumbnails(from urls: [URL]) async throws -> [URL: UIImage] {
try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in
// 1. Add one child task PER url — the count is dynamic, from the array.
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { throw FetchError.badImage }
return (url, image.preparingThumbnail(of: thumbSize) ?? image)
}
}
// 2. Collect results as they COMPLETE (not in submission order).
var thumbnails: [URL: UIImage] = [:]
for try await (url, image) in group {
thumbnails[url] = image
}
return thumbnails
}
}
flowchart TB
G["withThrowingTaskGroup"]
G --> A1["addTask: download url1"]
G --> A2["addTask: download url2"]
G --> A3["addTask: download urlN"]
A1 & A2 & A3 --> ITER["for try await result in group
(results arrive as each finishes)"] ITER --> DONE["group returns when all children done"]
(results arrive as each finishes)"] ITER --> DONE["group returns when all children done"]
Results arrive out of order
// Preserving input order: children return their index; rebuild the array afterward.
func loadInOrder(from urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
for (index, url) in urls.enumerated() {
group.addTask { (index, try await downloadThumbnail(url)) }
}
// Collect into a dictionary keyed by index, then sort back into an array.
var byIndex: [Int: UIImage] = [:]
for try await (index, image) in group { byIndex[index] = image }
return urls.indices.map { byIndex[$0]! }
}
}
Errors cancel the group
// Tolerant version: a failed download becomes nil instead of failing everything.
func loadBestEffort(from urls: [URL]) async -> [URL: UIImage] {
await withTaskGroup(of: (URL, UIImage?).self) { group in
for url in urls {
group.addTask {
(url, try? await downloadThumbnail(url)) // failure → nil, no throw
}
}
var result: [URL: UIImage] = [:]
for await (url, image) in group where image != nil {
result[url] = image
}
return result
}
}
Limiting concurrency
// Keep at most `limit` downloads running at once.
func loadThrottled(from urls: [URL], limit: Int = 6) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
var images: [UIImage] = []
var next = 0
// Prime the window with `limit` tasks.
while next < min(limit, urls.count) {
let url = urls[next]; next += 1
group.addTask { try await downloadThumbnail(url) }
}
// Each time one finishes, add the next — holding the window at `limit`.
while let image = try await group.next() {
images.append(image)
if next < urls.count {
let url = urls[next]; next += 1
group.addTask { try await downloadThumbnail(url) }
}
}
return images
}
}
flowchart LR
W["window of 6 in flight"] --> DONE["one finishes"]
DONE --> ADD["add the next url"]
ADD --> W
Discarding task groups: fire-and-forget, safely
// A server-style loop: handle each connection concurrently, keep nothing.
func serve(_ listener: Listener) async throws {
try await withThrowingDiscardingTaskGroup { group in
while let connection = try await listener.accept() {
group.addTask { await handle(connection) } // result discarded on completion
}
}
}
A note on Sendable
What we built in this chapter
- A parallel image downloader using
withThrowingTaskGroup+group.addTaskto run a - The fact that a group is an
AsyncSequenceof results in completion order, and the idiom of - The failure-policy choice: let errors escape (all-or-nothing,
withThrowingTaskGroup) or catch - The essential bounded-concurrency (sliding-window) pattern with
group.next()to avoid firing withDiscardingTaskGroupfor fire-and-forget work that must not accumulate results, and a first
Mental model to take away
- Task groups are
async letfor a dynamic count:addTaskin a loop to launch N concurrent - Results come back in completion order, not submission order — return a key with each result if you
- Structured concurrency doesn't throttle for you. Over large collections, use a sliding window
- Use
withDiscardingTaskGroupfor result-less, long-lived fan-out so finished children don't pile