Skip to Content
Course content

34: Task Groups and Structured Concurrency

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're the head chef in a busy kitchen preparing a massive banquet. You have five different appetizers that need to be prepped. You could do them one by one—chop the onions, then mince the garlic, then slice the peppers—but you'd be there all night. Instead, you call over five junior cooks. You give each one a specific ingredient to prep and tell them, "Let me know as soon as your pile is ready."

You aren't just throwing tasks into the void; you're managing a coordinated effort. If the banquet is suddenly canceled, you don't let the junior cooks keep chopping; you tell everyone to stop immediately. This is exactly how Task Groups work in Swift. They provide a way to launch a dynamic number of concurrent operations while keeping them "structured," meaning the parent task stays in control of its children.

Orchestrating Dynamic Work with withTaskGroup

You've already seen async let, which is great when you know exactly how many things you're waiting for (like "fetch user profile" AND "fetch user posts"). But what happens when you have an array of ten different cities and you need to fetch the current weather for all of them? You can't write async let ten times.

That's where withTaskGroup (or withThrowingTaskGroup if the work can fail) comes in. It creates a scope where you can spawn as many child tasks as you need.

struct WeatherReport {
    let city: String
    let temperature: Double
}

func fetchAllWeather(for cities: [String]) async throws -> [WeatherReport] {
    // We use withThrowingTaskGroup because network calls can fail
    try await withThrowingTaskGroup(of: WeatherReport.self) { group in
        var reports = [WeatherReport]()
        
        for city in cities {
            // We "spawn" a child task for each city
            group.addTask {
                // This runs concurrently with other tasks in the group
                return try await self.fetchWeather(for: city)
            }
        }
        
        // Now we collect the results as they finish
        for try await report in group {
            reports.append(report)
        }
        
        return reports
    }
}

// Mock function for the example
func fetchWeather(for city: String) async throws -> WeatherReport {
    try await Task.sleep(nanoseconds: 1_000_000_000) // Simulate network lag
    return WeatherReport(city: city, temperature: Double.random(in: 15...30))
}

Collecting Results in Any Order

Notice the for try await report in group line. This is where the magic happens. Unlike async let, where you wait for specific variables, a Task Group acts like an asynchronous sequence. The results arrive in the order they finish, not the order you started them. If the weather for Tokyo returns faster than the weather for London, Tokyo hits your loop first.

I've seen developers try to use a shared array inside the addTask block to collect results. Don't do that. You'll run straight into data races because multiple child tasks would be trying to mutate that array at the same time. The pattern I used above—returning a value from the child task and collecting it in the parent loop—is the safe, Swift-native way to do it.

The Safety Net of Structured Concurrency

The reason we call this "structured" concurrency is because of the hierarchy. The withTaskGroup block creates a boundary. The function fetchAllWeather cannot return until every single child task inside that group has either finished or been cancelled.

Here is the real kicker: if one of your child tasks throws an error and you don't catch it inside addTask, the entire group is notified. Swift can automatically cancel the remaining tasks in the group because it knows the overall operation has already failed. It's like the head chef seeing a fire in one pan and telling the whole kitchen to stop everything immediately. It prevents "zombie tasks" from eating up your CPU and battery in the background.




📋 Practical Task

Build a Concurrent Product Price Aggregator

You are building a price comparison tool. You have a list of "Store" objects, and each store has a method to fetch the current price of a specific product. Because some stores have slow APIs, you need to fetch these prices concurrently using a Task Group.

Requirements:

  • Create a ProductPrice struct with storeName: String and price: Double.
  • Implement a function aggregatePrices(for productID: String, stores: [Store]) async throws -> [ProductPrice].
  • Use withThrowingTaskGroup to fetch the price from each store simultaneously.
  • Ensure the function returns an array of all fetched prices.
  • Handle the scenario where a store's API might throw an error; the aggregator should still try to collect prices from the other stores (Hint: wrap the await store.fetchPrice() call in a do-catch block inside addTask and return nil or a default value for failed fetches).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.