You are currently viewing How to Handle Errors in Swift Task Groups

How to Handle Errors in Swift Task Groups

If you have read my previous article, you should know by now how to create a task group, add child tasks to a task group, and gather results from all the child tasks. However, there is one important topic related to task groups that I have not covered, which is “error handling“.

As we all know, a task group consists of multiple child tasks that run concurrently. When one of the child tasks encounters an error, how should the task group handle the error? What happens to those child tasks that are still running?

In this article, we will look into 2 most common ways we can use to handle errors in a task group:

  1. Throw an error using a throwing task group
  2. Returns results of all completed child tasks

As usual, I will be using some easy-to-understand sample code to help you learn how both of these methods work, so let’s get started!

Note:

This article requires you to have a basic understanding of Swift task groups. If you are unfamiliar with the basics of task groups, I highly encourage you to first read my previous article “Understanding Swift Task Groups With Example“.


Define a Throwing Child Task

In order to demonstrate error handling in a task group, we must first have a child task that can throw an error. Let’s modify the SlowDivideOperation that we created previously so that it will throw an error when the divisor is zero:

enum DivideOperationError: Error {
    case divideByZero
}

struct SlowDivideOperation {
    
    let name: String
    let a: Double
    let b: Double
    let sleepDuration: UInt64
    
    func execute() async throws -> Double {
        
        // Sleep for x seconds
        await Task.sleep(sleepDuration * 1_000_000_000)

        // Throw error when divisor is zero
        guard b != 0 else {
            print("⛔️ \(name) throw error")
            throw DivideOperationError.divideByZero
        }
        
        let value = a / b
        
        print("✅ \(name) completed: \(value)")
        return value
    }
}

As you can see, the execute() function is now marked with the throws keyword indicating that it is now a throwing function. On top of that, I also added 2 print statements to help us visualize what really happens when we execute the operation.

With that in place, we can now start diving into the first method.


Method 1: Throw an Error Using a Throwing Task Group

For demonstration purposes, 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.

If any of the child tasks encounters an error, it will throw and propagate the error to the task group, and the task group will throw the error. Here’s the sample code:

// 1
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: 4, b: 0, sleepDuration: 2),
    SlowDivideOperation(name: "operation-3", a: 8, b: 2, sleepDuration: 3),
]

Task {

    do {
        
        // 2
        let allResults = try await withThrowingTaskGroup(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 {
                    
                    // 3
                    // Execute slow operation
                    let value = try await operation.execute()
                    
                    // Return child task result
                    return (operation.name, value)
                }
                
            }
            
            // Collect results of all child task in a dictionary
            var childTaskResults = [String: Double]()
            // 4
            for try 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 return task group result
            return childTaskResults
            
        })
        
        print("👍🏻 Task group completed with result: \(allResults)")
        
    } catch {
        print("👎🏻 Task group throws error: \(error)")
    }
}

Let’s go through a few important details of the above code:

  1. The operations array defines the child tasks to be spawned by the task group. Note that “operation-2” will throw an error when we execute the sample code.
  2. We will use the withThrowingTaskGroup(of:returning:body:) function to create a throwing task group. It works similarly to the withTaskGroup(of:returning:body:) function, but we need to call it using the try keyword because it might throw an error.
  3. We must call the SlowDivideOperation‘s execute() function with a try keyword. This allows errors thrown by the execute() function to propagate to the task group.
  4. Since we are now using a throwing task group, we must use the try keyword when gathering results from each child task.

Now, if we try to execute the sample code, we will get the following output:

✅ operation-1 completed: 2.0
⛔️ operation-2 throw error
✅ operation-3 completed: 4.0
✅ operation-0 completed: 5.0
👎🏻 Task group throws error: divideByZero

The above output shows exactly what we are expecting — “operation-2” throws an error and the error is being propagated to the task group, thus causing the task group to throw the divideByZero error.

Even though our sample code is doing what we want, it is not optimized. As you can see from the output, “operation-3” and “operation-0” will still continue to execute until finished even though “operation-2” has thrown an error. Is there anything we can do to avoid this kind of situation?

Understanding the Behavior of a Throwing Task Group

In order to optimize our sample code, we must first understand how a throwing task group will behave when its child tasks throw an error. Here are a few important behaviors that you should be aware of:

  1. A task group will only throw the first error thrown by its child tasks. All subsequence errors from other child tasks will be ignored.
  2. When a child task throws an error, all of the remaining child tasks (child tasks that are still running) will be marked as canceled. 
  3. A child task marked as canceled will continue to execute until we explicitly stop it.
  4. A child task marked as canceled will not trigger the for-loop that gathers results from a task group even if the child task completed its execution.

The third behavior on the above list is what causes “operation-3” and “operation-0” to continue to execute even though “operation-2” has thrown an error. To explicitly stop a canceled task, we can use the Task.checkCancellation() method. This method will check the task which is currently executing the code, if the task is canceled, it will throw a CancellationError.

With that in mind, let’s switch our focus back to the SlowDivideOperation.execute() method. For our case, the best place to check for cancellation will be after the Task.sleep() method.

func execute() async throws -> Double {
    
    // Sleep for x seconds
    await Task.sleep(sleepDuration * 1_000_000_000)
    
    // Check for cancellation. If task is canceled, throw `CancellationError`.
    try Task.checkCancellation()
    
    // Throw error when divisor is zero
    // ...
    // ...
}

That’s all we need to do. Now, if we execute our sample code again, we will get the following output:

✅ operation-1 completed: 2.0
⛔️ operation-2 throw error
👎🏻 Task group throws error: divideByZero

With that, we have successfully improved the efficiency of our sample code. All remaining child tasks within our task group will now stop executing once a child task throws an error.


Method 2: Returning Results of All the Completed Child Tasks

Now, what if we want an outcome totally opposite of method 1? We want our task group to ignore all child tasks with errors and return the results of all the completed child tasks.

The concept to use is very similar to method 1, but this time we will create a normal (non-throwing) task group and ignore all the errors using try?:

Task {
    
    // 1
    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
                // 2
                guard let value = try? await operation.execute() else {
                    return nil
                }
                
                // Return child task result
                return (operation.name, value)
            }
            
        }
        
        // Collect results of all child task in a dictionary
        var childTaskResults = [String: Double]()
        
        // 3
        for await result in taskGroup.compactMap({ $0 }) {
            // Set operation name as key and operation result as value
            childTaskResults[result.0] = result.1
        }
        
        // All child tasks finish running, thus return task group result
        return childTaskResults
        
    })
    
    print("👍🏻 Task group completed with result: \(allResults)")
}

The sample code above is almost identical to the sample code of method 1, but there are a few significant differences that you should be aware of. Let’s go through them in detail:

  1. We are using the withTaskGroup(of:returning:body:) function to create a task group as our task group will no longer throw an error. On top of that, we must change the child task result type to optional so that our child tasks can return nil when an error occurs.
  2. Use optional try (try?) when calling execute() and return nil when the execute() function throws an error.
  3. Since our child tasks are no longer throwing errors, we can remove the try keyword from the for-loop. Furthermore, we must apply compactMap to taskGroup in order to filter out all the nil results returned by the child tasks.

Here’s the output we get from the code above:

✅ operation-1 completed: 2.0
⛔️ operation-2 throw error
✅ operation-3 completed: 4.0
✅ operation-0 completed: 5.0
👍🏻 Task group completed with result: ["operation-0": 5.0, "operation-1": 2.0, "operation-3": 4.0]

Pretty simple, isn’t it?


Wrapping Up

The 2 methods that I have shown you in this article are just 2 of the most basic ways to handle errors in a task group. You can definitely extend the concepts used in these methods to handle a more complex situation that suits your needs.

As usual, you can get the sample code in this article on Github.

I hope this article gives you a good idea of how to handle errors when using a task group. If you have any questions or comments, feel free to reach out to me on Twitter.

Thanks for reading. 👨🏻‍💻


Further 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.