Swift Task Groups
Introduction
In Swift's concurrency model, you'll often need to run multiple asynchronous operations simultaneously and then work with their collected results. While individual tasks are great for one-off operations, Task Groups provide an elegant solution for handling multiple related concurrent tasks.
Task groups are part of Swift's structured concurrency model. They allow you to:
- Launch multiple child tasks that run concurrently
- Collect results from these tasks in a coordinated way
- Maintain parent-child task relationships for proper cancellation handling
- Enforce type safety on the results
In this guide, we'll explore how Task Groups work, how to create and use them, and see practical examples of Task Groups in action.
Understanding Task Groups
Task groups serve as a container for managing a collection of child tasks that share the same result type. They ensure structured concurrency, meaning that the parent task won't complete until all child tasks have completed or been cancelled.
Key Concepts
- Parent-Child Relationship: All tasks in a group are children of the parent task that created the group
- Result Type: All tasks in a group must return the same type of result
- Structured Lifetime: A task group waits for all child tasks to complete before the function that created the group returns
- Cancellation Propagation: If the parent task gets cancelled, all child tasks in the group are automatically cancelled
Creating and Using Task Groups
Swift offers two main APIs for working with task groups:
withTaskGroup(of:returning:body:)
- Creates a task group where child tasks produce resultswithThrowingTaskGroup(of:returning:body:)
- Creates a task group where child tasks can throw errors
Let's see how to use the non-throwing version first:
func fetchMultipleImages(ids: [String]) async -> [String: UIImage] {
var images = [String: UIImage]()
// Create a task group that returns UIImage
await withTaskGroup(of: (String, UIImage).self) { group in
// Add child tasks to the group
for id in ids {
group.addTask {
// Each task fetches an image concurrently
let image = await fetchImage(for: id)
return (id, image)
}
}
// Process results as they complete
for await (id, image) in group {
images[id] = image
}
}
return images
}
// Helper function to fetch a single image
func fetchImage(for id: String) async -> UIImage {
// Simulating network delay
try? await Task.sleep(nanoseconds: UInt64.random(in: 1_000_000_000...3_000_000_000))
return UIImage(named: id) ?? UIImage()
}
Breaking Down the Code
- We create a task group using
withTaskGroup(of:)
specifying that each child task returns a tuple of(String, UIImage)
- We add child tasks to the group using
group.addTask { ... }
- Each child task runs the
fetchImage
function concurrently - We collect the results using a
for await
loop that processes each result as it completes - The task group completes when all child tasks have finished and the results are collected
Working with Throwing Task Groups
When your child tasks might throw errors, use withThrowingTaskGroup
:
func fetchMultipleImagesWithErrorHandling(ids: [String]) async throws -> [String: UIImage] {
var images = [String: UIImage]()
try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
for id in ids {
group.addTask {
let image = try await fetchImageWithPossibleError(for: id)
return (id, image)
}
}
// Process results as they complete
for try await (id, image) in group {
images[id] = image
}
}
return images
}
func fetchImageWithPossibleError(for id: String) async throws -> UIImage {
// Simulate network request that might fail
try await Task.sleep(nanoseconds: 1_000_000_000)
if Bool.random() && id == "problematic-id" {
throw URLError(.badServerResponse)
}
return UIImage(named: id) ?? UIImage()
}
Error Handling in Task Groups
If any child task throws an error:
- By default, the error propagates out of the task group
- The task group cancels all remaining child tasks
- The function containing the task group immediately throws the error
Controlling Task Group Behavior
Task groups provide several useful methods to control their behavior:
Adding Tasks with Priority
await withTaskGroup(of: Result.self) { group in
group.addTask(priority: .high) {
await performCriticalTask()
}
group.addTask(priority: .low) {
await performBackgroundTask()
}
}
Cancellation and Early Termination
You can cancel a task group at any time:
await withTaskGroup(of: String.self) { group in
// Add several tasks...
// Check a condition that might require cancellation
if shouldCancel {
group.cancelAll()
}
// Remaining tasks will be cancelled
}
Handling Partial Results
Sometimes you might want to continue even if some tasks fail:
func fetchMultipleImagesWithPartialResults(ids: [String]) async -> [String: UIImage] {
var images = [String: UIImage]()
await withTaskGroup(of: (String, UIImage?).self) { group in
for id in ids {
group.addTask {
do {
let image = try await fetchImageWithPossibleError(for: id)
return (id, image)
} catch {
print("Failed to fetch image for \(id): \(error)")
return (id, nil)
}
}
}
for await (id, image) in group {
if let image = image {
images[id] = image
}
}
}
return images
}
Real-World Example: Parallel Data Processing
Let's look at a more practical example where we need to process multiple data sources concurrently:
struct WeatherReport {
let temperature: Double
let humidity: Int
let windSpeed: Double
}
enum DataSource {
case temperature
case humidity
case windSpeed
}
func fetchWeatherReport(for location: String) async throws -> WeatherReport {
// Function to fetch a specific data point
func fetchData(for source: DataSource) async throws -> Any {
// Simulate network delay (different for each source)
try await Task.sleep(nanoseconds: UInt64.random(in: 500_000_000...2_000_000_000))
switch source {
case .temperature:
return Double.random(in: -10...35)
case .humidity:
return Int.random(in: 0...100)
case .windSpeed:
return Double.random(in: 0...30)
}
}
// Use a task group to fetch all data points concurrently
var temperature: Double = 0
var humidity: Int = 0
var windSpeed: Double = 0
try await withThrowingTaskGroup(of: (DataSource, Any).self) { group in
// Add tasks for each data source
group.addTask {
let temp = try await fetchData(for: .temperature) as! Double
return (.temperature, temp)
}
group.addTask {
let humidity = try await fetchData(for: .humidity) as! Int
return (.humidity, humidity)
}
group.addTask {
let wind = try await fetchData(for: .windSpeed) as! Double
return (.windSpeed, wind)
}
// Collect results as they arrive
for try await (source, value) in group {
switch source {
case .temperature:
temperature = value as! Double
case .humidity:
humidity = value as! Int
case .windSpeed:
windSpeed = value as! Double
}
}
}
return WeatherReport(temperature: temperature, humidity: humidity, windSpeed: windSpeed)
}
You can use this function like this:
func displayWeather() async {
do {
let report = try await fetchWeatherReport(for: "New York")
print("Current conditions:")
print("Temperature: \(report.temperature)°C")
print("Humidity: \(report.humidity)%")
print("Wind Speed: \(report.windSpeed) km/h")
} catch {
print("Failed to fetch weather: \(error)")
}
}
Output Example:
Current conditions:
Temperature: 22.5°C
Humidity: 65%
Wind Speed: 12.3 km/h
Advanced Task Group Features
Limiting Concurrency
While task groups can run many tasks simultaneously, you might want to limit concurrency based on your application's needs:
func processLargeDataset(items: [DataItem]) async throws -> [ProcessedResult] {
var results = [ProcessedResult]()
// Process with limited concurrency (4 at a time)
try await withThrowingTaskGroup(of: ProcessedResult.self) { group in
var index = 0
// Add initial batch of tasks (up to our concurrency limit)
for _ in 0..<min(4, items.count) {
let item = items[index]
group.addTask { try await processItem(item) }
index += 1
}
// As each task completes, add a new one until we've processed everything
while let result = try await group.next() {
results.append(result)
if index < items.count {
let item = items[index]
group.addTask { try await processItem(item) }
index += 1
}
}
}
return results
}
Early Exit on First Result
Sometimes you only need the first result that becomes available:
func fetchFirstAvailableServer() async throws -> Server {
try await withThrowingTaskGroup(of: Server.self) { group in
for serverAddress in serverAddresses {
group.addTask {
try await connectToServer(at: serverAddress)
}
}
// Return as soon as the first server connects successfully
let firstServer = try await group.next()!
// Cancel remaining connections
group.cancelAll()
return firstServer
}
}
Best Practices for Task Groups
- Use the right result type: Choose a result type that can hold all the information you need from each task
- Handle errors appropriately: Consider whether you want to fail fast on the first error or collect partial results
- Be mindful of memory usage: If tasks produce large results, consider processing them incrementally rather than collecting all results first
- Respect cancellation: Check for cancellation in long-running tasks
- Consider task priority: Set appropriate priorities for tasks based on their importance
- Limit concurrency when needed: Just because you can run many tasks concurrently doesn't mean you always should
Summary
Task Groups are a powerful tool in Swift's concurrency model that allow you to:
- Run multiple related asynchronous operations concurrently
- Collect and process their results in a type-safe way
- Maintain structured relationships between parent and child tasks
- Handle errors and cancellation gracefully
They're particularly useful when you need to perform multiple independent operations that can run in parallel and then combine their results, such as fetching different pieces of data for a single view or processing multiple items in a collection.
Exercises
- Create a task group that downloads multiple images concurrently and displays them in a collection view
- Implement a function that uses a task group to search multiple APIs for information and returns the first valid result
- Create a function that processes a large array of data items in chunks using task groups with limited concurrency
- Implement a function that uses a task group to perform a distributed calculation, splitting work across multiple tasks
Additional Resources
- Swift Documentation on Task Groups
- WWDC21 - Swift concurrency: Behind the scenes
- Swift Evolution Proposal: SE-0304 Structured concurrency
Happy coding with Task Groups in Swift!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)