You are currently viewing Understanding Swift Task Groups With Example

Understanding Swift Task Groups With Example

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:

  1. A task group consists of a collection of asynchronous tasks (child tasks) that is independent of each other.
  2. All child tasks added to a task group start running automatically and concurrently.
  3. 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.
  4. 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.
  5. 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:

how to create task group in swift
Calling withTaskGroup(of:returning:body:)

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.

matching child task return type when create task group in Swift
Matching child task return type

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.

matching task group return type when create task group in Swift
Matching task group result type

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


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.