Task Groups in Swift allow you to combine multiple parallel tasks and wait for the result to return when all tasks are finished. They are commonly used for tasks like combining multiple API request responses into a single response object.
Read my article about tasks first if you’re new to them, and make sure you’ve read my article covering async/await since it’s the foundation of task groups today. With that in mind, we’re ready to jump into the details.
What is a Task Group?
You can see a Task Group as a container of several child tasks that are dynamically added. Child tasks can run in parallel or in serial, but the Task Group will only be marked as finished once its child tasks are done.
A common example could be downloading several images from a photo gallery:
await withTaskGroup(of: UIImage.self) { taskGroup in
let photoURLs = await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { await downloadPhoto(url: photoURL) }
}
}
We first download the gallery’s photo URL list in the above example. Note that this task doesn’t link to our group and, with that, doesn’t influence the state of the task group. Secondly, we iterate over each photo URL and start downloading them in parallel. The withTaskGroup
method will return once all photos are downloaded.
How to use a Task Group
You can group tasks in several ways, including handling errors or returning the final collection of results. They’re a more advanced alternative to async let and allow dynamically adding tasks.
Returning the final collection of results
The first example shared in this article covered combining tasks without returning the final collection of images. We could rewrite that example and return the collection of images instead:
let images = await withTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
let photoURLs = await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { await downloadPhoto(url: photoURL) }
}
var images = [UIImage]()
for await result in taskGroup {
images.append(result)
}
return images
}
We defined the return type as a collection of images using [UIImage].self
. After starting all child tasks, we use an Async Sequence to await the following result and append the outcome image to our results collection.
Tasks groups conform to AsyncSequence
, allowing us to rewrite the above code using a reduce operator:
let images = await withTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
let photoURLs = await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { await downloadPhoto(url: photoURL) }
}
return await taskGroup.reduce(into: [UIImage]()) { partialResult, name in
partialResult.append(name)
}
}
Other operations like map and flatMap can also be used, allowing for flexible solutions to create the outcome. Finally, you can use the collection of images and continue your workflow.
Handling errors by using a throwing variant
It’s common for image downloading methods to throw an error on failure. We can rewrite our example to handle these cases by renaming withTaskGroup
to withThrowingTaskGroup
:
let images = try await withThrowingTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
let photoURLs = try await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { try await downloadPhoto(url: photoURL) }
}
return try await taskGroup.reduce(into: [UIImage]()) { partialResult, name in
partialResult.append(name)
}
}
Note that we added the try
keyword before each async method since we have to handle potentially thrown errors. The outcome will be a ThrowingTaskGroup
that will return the result of images as long as there is no thrown error.
Failing a group when a child task throws
In the above example, our group wouldn’t fail if a child download task throws an error. To make that happen, we need to change how we iterate over the outcome results by using the next()
method:
let images = try await withThrowingTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
let photoURLs = try await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { try await downloadPhoto(url: photoURL) }
}
var images = [UIImage]()
/// Note the use of `next()`:
while let downloadImage = try await taskGroup.next() {
images.append(downloadImage)
}
return images
}
The next()
method receives errors from individual tasks, allowing you to handle them accordingly. In this case, we forward the error to the group closure, making the entire task group fail. Any other running child tasks will be canceled at this point.
Avoid concurrent mutation
You need to realize you shouldn’t mutate a task group from outside the task where you created it. For example, please don’t pass it around and add child tasks to it from another task. In most cases, you should be warned by the Swift type system when you do since mutating operations like this can’t be performed from a concurrent execution context like a child task.
Cancellations in groups
You can cancel a group of tasks by canceling the task it’s running in or by calling the cancelAll()
method on the group itself.
When tasks are added to a canceled group using the addTask()
, they’ll be canceled directly after creation. It will stop its work directly depending on whether that task respects cancelation correctly. Optionally, you can use addTaskUnlessCancelled()
to prevent the task from starting.
Creating a Tasks Group Result Builder
Remembering how to bundle tasks together in a group with the earlier shared code examples can be challenging. You have to call the addTask()
method, await the results, and append it to a collection before you get the outcome result.
Instead, we can create a custom Result Builder, allowing us to rewrite the above code as follows:
let photoURLs = try await listPhotoURLs(inGallery: "Amsterdam Holiday")
let images = try await withThrowingTaskGroup {
for photoURL in photoURLs {
Task { try await downloadPhoto(url: photoURL) }
}
}
The outcome is compact, more readable code while we’re still running tasks in parallel. You can explore code for the above result builder in the GitHub repository.
Continuing your journey into Swift Concurrency
The concurrency changes are more than just task groups and include many new features you can benefit from in your code. Now that you’ve learned about TaskGroup, it’s time to dive into other concurrency features:
- Async await in Swift explained with code examples
- Swift 6: Incrementally migrate your Xcode projects and packages
- Concurrency-safe global variables to prevent data races
- Unit testing async/await Swift code
- Thread dispatching and Actors: understanding execution
- @preconcurrency: Incremental migration to concurrency checking
- MainActor usage in Swift explained to dispatch to the main thread
- Detached Tasks in Swift explained with code examples
- Task Groups in Swift explained with code examples
- Sendable and @Sendable closures explained with code examples
- AsyncSequence explained with Code Examples
- AsyncThrowingStream and AsyncStream explained with code examples
- Tasks in Swift explained with code examples
- Nonisolated and isolated keywords: Understanding Actor isolation
- Async let explained: call async functions in parallel
- Actors in Swift: how to use and prevent data races
Conclusion
A Task Group allows you to dynamically bundle a set of tasks and wait for their outcome while they execute in parallel. You can handle errors and cancelation, but you must be careful to use the next()
method to forward failures. The outcome can be returned once all tasks are complete, allowing you to bundle request responses.
If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.
Thanks!