Apple introduced task groups in Swift 5.5 as one of the essential parts in the Swift concurrency framework. As the name implies, a task group is a collection of child tasks that run concurrently, and it only returns when all of its child tasks finish executing.
In this article, I would like to show you how to create a task group, add child tasks to a task group, and gather results from all the child tasks. That’s a lot of topics to cover, so let’s get started.
Getting Ready
As usual, I will use some sample code to explain and help you to understand how task groups work in Swift. Before diving into task groups, let’s first define an operation struct that we will use throughout this article.
struct SlowDivideOperation {
let name: String
let a: Double
let b: Double
let sleepDuration: UInt64
func execute() async -> Double {
// Sleep for x seconds
await Task.sleep(sleepDuration * 1_000_000_000)
let value = a / b
return value
}
}
The SlowDivideOperation
is a simple struct that contains an execute()
function that performs a divide operation based on the given numbers. Note that I purposely slow down the execution by making it sleep for a certain amount of time before dividing the numbers.
This gives us good control of how long the divide operation should execute. Therefore, allowing us to easily observe what really happens when multiple SlowDivideOperation
run concurrently in a task group.
With that out of the way, let’s dive right into task groups.
The Task Groups Behaviors
In order for us to be able to use task groups correctly, there are a few task group behaviors that we need to be aware of:
- A task group consists of a collection of asynchronous tasks (child tasks) that is independent of each other.
- All child tasks added to a task group start running automatically and concurrently.
- We cannot control when a child task finishes its execution. Therefore, we should not use a task group if we want the child tasks to finish in a specific order.
- A task group only returns when all of its child tasks finish their execution. In other words, all child tasks of a task group can only live within the scope of their task group.
- A task group can exit by either returning a value, returning void (non-value-returning), or throwing an error.
Now that you have learned how a task group behaves, it is time to write some Swift code.
Creating a Task Group
To create a task group, we can use the withTaskGroup(of:returning:body:)
or withThrowingTaskGroup(of:returning:body:)
function introduced in Swift 5.5. Since the task group that we want to create will not throw any errors, we will be using the withTaskGroup(of:returning:body:)
variant in our sample code. It takes the following form when being called:
In our example, we will create a task group that spawns multiple child tasks that execute a SlowDivideOperation
and return its name and result. When all the SlowDivideOperation
finished, the task group will collect all its child task results and return a dictionary consisting of all the SlowDivideOperation
names and results.
With that in mind, we can create a task group using the function like so:
let allResults = await withTaskGroup(of: (String, Double).self,
returning: [String: Double].self,
body: { taskGroup in
// We can use `taskGroup` to spawn child tasks here.
})
Notice that an instance of task group is given to us as a parameter of the body
closure. In the next section, we will take a look at how we can leverage this task group instance to spawn multiple child tasks that run concurrently.
Task Group in Action
Let’s say we have an array of SlowDivideOperation
as shown below:
let operations = [
SlowDivideOperation(name: "operation-0", a: 5, b: 1, sleepDuration: 5),
SlowDivideOperation(name: "operation-1", a: 14, b: 7, sleepDuration: 1),
SlowDivideOperation(name: "operation-2", a: 8, b: 2, sleepDuration: 3),
]
Then we can add child tasks to the task group by looping through the operations
array:
let allResults = await withTaskGroup(of: (String, Double).self,
returning: [String: Double].self,
body: { taskGroup in
// Loop through operations array
for operation in operations {
// Add child task to task group
taskGroup.addTask {
// Execute slow operation
let value = await operation.execute()
// Return child task result
return (operation.name, value)
}
}
// Collect child task results here...
})
One thing worth mentioning is that we are returning a tuple of String
and Double
in the child task operation closure, which matches with the child task result data type we set earlier on.
As mentioned earlier, all child tasks will run concurrently and we have no control over when they will finish running. In order to collect the result of each child task, we must loop through the task group like so:
// Collect results of all child task in a dictionary
var childTaskResults = [String: Double]()
for await result in taskGroup {
// Set operation name as key and operation result as value
childTaskResults[result.0] = result.1
}
// Task group finish running & return task group result
return childTaskResults
Notice we are using the await
keyword when looping to indicate that the for
loop might suspend while waiting for the child task to complete. Every time when a child task returns, the for
loop will iterate and update the childTaskResults
dictionary.
Once all the child tasks completed execution, the for
loop will exit and proceed to return the task group result. As you might have noticed, the data type of childTaskResults
must match with the task group result type that we previously set.
Pro Tip:
Use
Void.self
as the result type if you have a task group or child task that does not return a value.
Before taking our sample code for a spin, let’s add some print statements before and after we trigger the task group to inspect the final result and the time required to execute all child tasks. Here’s the full sample code:
let operations = [
SlowDivideOperation(name: "operation-0", a: 5, b: 1, sleepDuration: 5),
SlowDivideOperation(name: "operation-1", a: 14, b: 7, sleepDuration: 1),
SlowDivideOperation(name: "operation-2", a: 8, b: 2, sleepDuration: 3),
]
Task {
print("Task start : \(Date())")
let allResults = await withTaskGroup(of: (String, Double).self,
returning: [String: Double].self,
body: { taskGroup in
// Loop through operations array
for operation in operations {
// Add child task to task group
taskGroup.addTask {
// Execute slow operation
let value = await operation.execute()
// Return child task result
return (operation.name, value)
}
}
// Collect results of all child task in a dictionary
var childTaskResults = [String: Double]()
for await result in taskGroup {
// Set operation name as key and operation result as value
childTaskResults[result.0] = result.1
}
// All child tasks finish running, thus task group result
return childTaskResults
})
print("Task end : \(Date())")
print("allResults : \(allResults)")
}
If we try to execute the code above, the output we get will be something like this:
Task start : 2021-10-23 05:53:15 +0000
Task end : 2021-10-23 05:53:20 +0000
allResults : ["operation-1": 2.0, "operation-2": 4.0, "operation-0": 5.0]
As you can see, the entire task group took 5s to complete. The fact that 5s is also equal to the longest sleep duration among all the SlowDivideOperation
, indicating that all child tasks do in fact run concurrently.
If you take a look at allResults
, you will notice that it contains results of all the child tasks. This further proves that a task group only returns when all of its child tasks finish running. This also indicates that a child task can only live within the context of a task group.
If you would like to try out the sample code in this article, you can get it here.
Further Readings
- Making Network Requests with Async/await in Swift
- Preventing Data Races Using Actors in Swift
- The Actor Reentrancy Problem in Swift
- How Sendable Can Help in Preventing Data Races
Wrapping Up
In this article, I have shown you how to create a task group, how to add child tasks to a task group, and how to gather results from a task group’s child tasks. In my next article, I will talk about error handling in task groups. Stay tuned!
Feel free to follow me on Twitter and subscribe to my monthly newsletter so that you won’t miss out on any of my upcoming iOS development-related articles.
Thanks for reading. 👨🏻💻
👋🏻 Hey!
While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.
- ✨ Bartender: Superpower your menu bar and take full control over your menu bar items.
- ✨ CleanShot X: The best screen capture app I’ve ever used.
- ✨ PixelSnap: Measure on-screen elements with ease and precision.
- ✨ iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.