How to cancel a task group
How to cancel a task group êŽë š
Updated for Xcode 15
Swiftâs task groups can be cancelled in one of three ways:
- If the parent task of the task group is cancelled.
- If you explicitly call
cancelAll()
on the group. - If one of your child tasks throws an uncaught error, all remaining tasks will be implicitly cancelled.
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()
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:
- Swift will start all three tasks immediately. They might all run in parallel; it depends on what the system thinks will work best at runtime.
- Although we immediately call
cancelAll()
, some of the tasks might have started running. - All the tasks finish in completion order, so when we first loop over the group we might receive the result from any of the three tasks.
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)")
}
}
}
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()
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.