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:
- Throw an error using a throwing task group
- 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:
- 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. - We will use the
withThrowingTaskGroup(of:returning:body:)
function to create a throwing task group. It works similarly to thewithTaskGroup(of:returning:body:)
function, but we need to call it using thetry
keyword because it might throw an error. - We must call the
SlowDivideOperation
‘sexecute()
function with atry
keyword. This allows errors thrown by theexecute()
function to propagate to the task group. - 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:
- A task group will only throw the first error thrown by its child tasks. All subsequence errors from other child tasks will be ignored.
- When a child task throws an error, all of the remaining child tasks (child tasks that are still running) will be marked as canceled.
- A child task marked as canceled will continue to execute until we explicitly stop it.
- 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:
- 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 returnnil
when an error occurs. - Use optional try (
try?
) when callingexecute()
and returnnil
when theexecute()
function throws an error. - Since our child tasks are no longer throwing errors, we can remove the
try
keyword from the for-loop. Furthermore, we must applycompactMap
totaskGroup
in order to filter out all thenil
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
- 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
👋🏻 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.