< How to create a task group and add tasks to it | How to handle different result types in a task group > |
Updated for Xcode 14.2
Swift’s task groups can be cancelled in one of three ways:
cancelAll()
on the group.The first of those happens outside of the task group, but the other two are worth investigating.
First, calling cancelAll()
will cancel all remaining tasks. As with standalone tasks, cancelling a task group is cooperative: your child tasks can check for cancellation using Task.isCancelled
or Task.checkCancellation()
, but they can ignore cancellation entirely if they want.
I’ll show you a real-world example of cancelAll()
in action in a moment, but before that I want to show you some toy examples so you can see how it works.
We could write a simple printMessage()
function like this one, creating three tasks inside a group in order to generate a string:
func printMessage() async {
let result = await withThrowingTaskGroup(of: String.self) { group -> String in
group.addTask {
return "Testing"
}
group.addTask {
return "Group"
}
group.addTask {
return "Cancellation"
}
group.cancelAll()
var collected = [String]()
do {
for try await value in group {
collected.append(value)
}
} catch {
print(error.localizedDescription)
}
return collected.joined(separator: " ")
}
print(result)
}
await printMessage()
Download this as an Xcode project
As you can see, that calls cancelAll()
immediately after creating all three tasks, and yet when the code is run you’ll still see all three strings printed out. I’ve said it before, but it bears repeating and this time in bold: cancelling a task group is cooperative, so unless the tasks you add implicitly or explicitly check for cancellation calling cancelAll()
by itself won’t do much.
To see cancelAll()
actually working, try replacing the first addTask()
call with this:
group.addTask {
try Task.checkCancellation()
return "Testing"
}
And now our behavior will be different: you might see “Cancellation” by itself, “Group” by itself, “Cancellation Group”, “Group Cancellation”, or nothing at all.
To understand why, keep the following in mind:
cancelAll()
, some of the tasks might have started running. When you put those together, it’s entirely possible the first task to complete is the one that calls Task.checkCancellation()
, which means our loop will exit, we’ll print an error message, and send back an empty string. Alternatively, one or both of the other tasks might run first, in which case we’ll get our other possible outputs.
Remember, calling cancelAll()
only cancels remaining tasks, meaning that it won’t undo work that has already completed. Even then the cancellation is cooperative, so you need to make sure the tasks you add to the group check for cancellation.
With that toy example out of the way, here’s a more complex demonstration of cancelAll()
that builds on an example from an earlier chapter. This code attempts to fetch, merge, and display using SwiftUI the contents of five news feeds. If any of the fetches throws an error the whole group will throw an error and end, but if a fetch somehow succeeds while ending up with an empty array it means our data quota has run out and we should stop trying any other feed fetches.
Here’s the code:
struct NewsStory: Identifiable, Decodable {
let id: Int
let title: String
let strap: String
let url: URL
}
struct ContentView: View {
@State private var stories = [NewsStory]()
var body: some View {
NavigationView {
List(stories) { story in
VStack(alignment: .leading) {
Text(story.title)
.font(.headline)
Text(story.strap)
}
}
.navigationTitle("Latest News")
}
.task {
await loadStories()
}
}
func loadStories() async {
do {
try await withThrowingTaskGroup(of: [NewsStory].self) { group -> Void in
for i in 1...5 {
group.addTask {
let url = URL(string: "https://hws.dev/news-\(i).json")!
let (data, _) = try await URLSession.shared.data(from: url)
try Task.checkCancellation()
return try JSONDecoder().decode([NewsStory].self, from: data)
}
}
for try await result in group {
if result.isEmpty {
group.cancelAll()
} else {
stories.append(contentsOf: result)
}
}
stories.sort { $0.id < $1.id }
}
} catch {
print("Failed to load stories: \(error.localizedDescription)")
}
}
}
Download this as an Xcode project
As you can see, that calls cancelAll()
as soon as any feed sends back an empty array, thus aborting all remaining fetches. Inside the child tasks there is an explicit call to Task.checkCancellation()
, but the data(from:)
also runs check for cancellation to avoid doing unnecessary work.
The other way task groups get cancelled is if one of the tasks throws an uncaught error. We can write a simple test for this by creating two tasks inside a group, both of which sleep for a little time. The first task will sleep for 1 second then throw an example error, whereas the second will sleep for 2 seconds then print the value of Task.isCancelled
.
Here’s how that looks:
enum ExampleError: Error {
case badURL
}
func testCancellation() async {
do {
try await withThrowingTaskGroup(of: Void.self) { group -> Void in
group.addTask {
try await Task.sleep(nanoseconds: 1_000_000_000)
throw ExampleError.badURL
}
group.addTask {
try await Task.sleep(nanoseconds: 2_000_000_000)
print("Task is cancelled: \(Task.isCancelled)")
}
try await group.next()
}
} catch {
print("Error thrown: \(error.localizedDescription)")
}
}
await testCancellation()
Download this as an Xcode project
Note: Just throwing an error inside addTask()
isn’t enough to cause other tasks in the group to be cancelled – this only happens when you access the value of the throwing task using next()
or when looping over the child tasks. This is why the code sample above specifically waits for the result of a task, because doing so will cause ExampleError.badURL
to be rethrown and cancel the other task.
Calling addTask()
on your group will unconditionally add a new task to the group, even if you have already cancelled the group. If you want to avoid adding tasks to a cancelled group, use the addTaskUnlessCancelled()
method instead – it works identically except will do nothing if called on a cancelled group. Calling addTaskUnlessCancelled()
returns a Boolean that will be true if the task was successfully added, or false if the task group was already cancelled.
SAVE 50% To celebrate WWDC23, all our books and bundles are half price, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.
Link copied to your pasteboard.