In our previous article about async/await in the senior section we saw a basic implementation of an asyncronous task using the Task
structure. Task
allows us to execute a piece of code in an asynchronous way and wait for it to finish. But, what if I want to execute multiple tasks in paralel and wait for them to finish its execution?. Here is when it comes Task Groups to the table.
What are Task Groups?
As you can guess by the name: nothing more than a way to execute a group of tasks. You have Task
for single tasks and TaskGroup
for multiple tasks. However, the latter have some variants: you have the mentioned TaskGroup
and also DiscardingTaskGroup
One of the core elements of structured concurrency in Swift is the concept of task groups. A task group allows you to create multiple child tasks that run concurrently. These child tasks are all tied to the parent task that creates them, and their execution is structured to be completed within a well-defined scope.
TaskGroup
Let's see how this first variant work in a real example. We will write a simple function that downloads an image and return the Data
object received:
func downloadImage(url urlString: String) async -> Data? {
guard let url = URL(string: urlString) else { return nil }
do {
let (data, _) = try await URLSession.shared.data(from: url)
return data
} catch {
return nil
}
}
This function will either return a Data
object or in case of any error, it is handled to return nil
. We are going to use this function to download multiple images inside a TaskGroup
:
func getImages() async -> [UIImage] {
await withTaskGroup(of: Data?.self) { group -> [UIImage] in // 1
group.addTask { // 2
await downloadImage(url: "https://educaswift.com/public/samples/products/1/thumbnail.jpg")
}
group.addTask {
await downloadImage(url: "https://educaswift.com/public/samples/products/2/thumbnail.jpg")
}
group.addTask {
await downloadImage(url: "https://educaswift.com/public/samples/products/3/thumbnail.jpg")
}
var images = [UIImage]()
for await imageData in group { // 3
guard let imageData, let image = UIImage(data: imageData) else { continue }
images.append(image)
}
return images
}
}
let imagesCount = await getImages().count // 4
print("count: \(imagesCount)") // count: 3
Let's summarize what we've done here:
- Use the
withTaskGroup
function to provide a starting point for managing multiple tasks concurrently. It accepts a closure where you will do the tasks and we specify the type of value we want to return from the execution of this function. In our case, an array ofUIImage
objects. - Task groups have the handly function
addTask
that will allows us specify the task we want to run. Add as many as you need. - We just loop over the results of the group execution. As you can see, we set the return type for our tasks to be
Data?
, so we get each value separately. - Just printing the count of the resulting array.
As you observe, withTaskGroup
function does not throw, so in case of any error, you need to handle them inside the task group execution flow. Fortunately, Swift provides an equivalent throwing version of this function called withThrowingTaskGroup
. This will allow us to keep the error handling cleaner and postpone it to a later stage.
Now let's rewrite our functions to use the throwing version:
enum DownloadError: Error { // 1
case wrongUrl
}
func downloadImage(url urlString: String) async throws -> Data { // 2
guard let url = URL(string: urlString) else { throw DownloadError.wrongUrl }
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
func getImages() async throws -> [UIImage] {
try await withThrowingTaskGroup(of: Data.self) { group -> [UIImage] in // 3
group.addTask {
try await downloadImage(url: "https://educaswift.com/public/samples/products/1/thumbnail.jpg")
}
group.addTask {
try await downloadImage(url: "https://educaswift.com/public/samples/products/2/thumbnail.jpg")
}
group.addTask {
try await downloadImage(url: "https://educaswift.com/public/samples/products/3/thumbnail.jpg")
}
var images = [UIImage]()
for try await imageData in group { // 4
guard let image = UIImage(data: imageData) else { continue }
images.append(image)
}
return images
}
}
do {
let imagesCount = try await getImages().count // 5
print("count: \(imagesCount)")
} catch {
print("error downloading images: \(error)")
}
- We need to get used to this: define a custom error for readability, clarity and better organization.
- Now our
downloadImage(url:)
function throws compared to the previous version. You don't need to handle the error inside it. - Here it is the main work. Now we can return
Data
from every task and expect that they could throw at any time. Likewise, we should expectwithThrowingTaskGroup
to also throw and need to handle the errors from outside. - This
group
now returns a bunch ofThrowingTaskGroup
objects that we need to access to also using thetry await
syntax. - Finally, we handle the errors from outside (My preferred option always).
We need to take into consideration that there is no any guarantee of the order execution. No matter the order you add the tasks to the group, they can be executed at any point in time. You can get first the results of the last task you added.
DiscardingTaskGroup And ThrowingDiscardingTaskGroup
This structure, and its throwing counterpart, came to solve a problem withThrowingTaskGroup
left behind.
The behavior of a Task Group is that it will retain any task you add to the group until they get manually released at some point. You either call the next()
TaskGroup function to get the value, or loop over the returned data as we did in out previous example. The case with a DiscardingTaskGroup is different, because they release the tasks as soon as they complete, and this allows for the efficient releasing of memory used by those tasks, which are not retained for future consumption, as would be the case with a TaskGroup.
Let's see a simple example:
func executeSideEffects() async throws {
try await withThrowingDiscardingTaskGroup { group in
group.addTask {
try await sendPushNotification()
}
group.addTask {
try await downloadFileAndSaveToFileSystem()
}
group.addTask {
try await reorderProductList()
}
}
}
The whole point of it is to execute tasks that don't expect a return value or can be used as a side effect, as the resulting data is inmediatelly discarded after its execution. So we cannot use it in replacement of a normal Task Group.
In this example you can benefit of executing tasks in group while discarding the results. Although the result of the individual tasks are discarded you can still return a value from the group or even return another task that will run along with all the child tasks.
Be the first to comment