Categories
iOS Swift

TaskGroup error handling in Swift

Task groups in Swift are used for running n-number of child tasks and letting it handle things like cancellation or different priorities. Task groups are created with either withThrowingTaskGroup(of:returning:body:) or withTaskGroup(of:returning:body:). The latter is for cases when errors are not thrown. In this blog post, we will observe two cases of generating Data objects using a task group. In the first case, we want to stop the group as soon as an error has occurred and discard all the remaining work. The other case looks at ignoring any errors in child tasks and collect just collecting Data objects for tasks which were successful.

The example we are going to use simulates creating image data for multiple identifiers and then returning an array of Data objects. The actual image creating and processing is simulated with Task’s sleep function. Since task groups coordinate cancellation to all the child tasks, then the processor implementation also calls Task.checkCancellation() to react to cancellation and stopping as soon as possible for avoiding unnecessary work.

struct ImageProcessor {
static func process(identifier: Int) async throws -> Data {
// Read image data
try await Task.sleep(nanoseconds: UInt64(identifier) * UInt64(1e8))
try Task.checkCancellation()
// Simulate processing the data and transforming it
try await Task.sleep(nanoseconds: UInt64(1e8))
try Task.checkCancellation()
if identifier != 2 {
print("Success: \(identifier)")
return Data()
}
else {
print("Failing: \(identifier)")
throw ProcessingError.invalidData
}
}
enum ProcessingError: Error {
case invalidData
}
}

Now we have the processor created. Let’s see an example of calling this function from a task group. As soon as we detect an error in one of the child tasks, we would like to stop processing and return an error from the task group.

let imageDatas = try await withThrowingTaskGroup(of: Data.self, returning: [Data].self) { group in
imageIdentifiers.forEach { imageIdentifier in
group.addTask {
return try await ImageProcessor.process(identifier: imageIdentifier)
}
}
var results = [Data]()
for try await imageData in group {
results.append(imageData)
}
return results
}
view raw Case1.swift hosted with ❤ by GitHub

We loop over the imageIdentifiers array and create a child task for each of these. When child tasks are created and running, we wait for child tasks to finish by looping over the group and waiting each of the child task. If the child task throws an error, then in the for loop we re-throw the error which makes the task group to cancel all the remaining child tasks and then return the error to the caller. Since we loop over each of the task and wait until it finishes, then the group will throw an error of the first added failing task. Also, just to remind that cancellation needs to be handled explicitly by the child task’s implementation by calling Task.checkCancellation().

Great, but what if we would like to ignore errors in child tasks and just collect Data objects of all the successful tasks. This could be implemented with withTaskGroup function by specifying the child task’s return type optional and handling the error within the child task’s closure. If error is thrown, return nil, and later when looping over child tasks, ignore nil values with AsyncSequence’s compactMap().

let imageDatas = await withTaskGroup(of: Data?.self, returning: [Data].self) { group in
imageIdentifiers.forEach { imageIdentifier in
group.addTask {
do {
return try await ImageProcessor.process(identifier: imageIdentifier)
} catch {
return nil
}
}
}
var results = [Data]()
for await imageData in group.compactMap({ $0 }) {
results.append(imageData)
}
return results
}
view raw Case2.swift hosted with ❤ by GitHub

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.