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 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.addTask to run a
  • The fact that a group is an AsyncSequence of 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
  • withDiscardingTaskGroup for fire-and-forget work that must not accumulate results, and a first

Mental model to take away

  • Task groups are async let for a dynamic count: addTask in 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 withDiscardingTaskGroup for result-less, long-lived fan-out so finished children don't pile