Skip to Content
Course content

106: Task Groups Revisited with Cancellation Handling

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By now, you're comfortable spinning up a TaskGroup to kick off a handful of concurrent operations. It feels powerful—you throw a bunch of work into the group, await the results, and everything happens in parallel. But there is a dangerous assumption I see a lot of developers make: the idea that calling cancelAll() or having a parent task cancelled acts like a "kill switch" that instantly terminates every child task.

It doesn't. In Swift, cancellation is cooperative. If your child tasks aren't actively looking for the cancellation signal, they will keep churning away, eating CPU and battery, even though the result they're working toward is no longer wanted. I've spent way too many hours debugging "ghost" network requests and zombie background threads because of this.

The Illusion of Immediate Termination

Let's look at a scenario where we're downloading a set of high-resolution images and then applying a heavy blur filter to each one. In a naive implementation, you might write something like this:

async func processGalleryImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask {
                // Step 1: Download
                let (data, _) = try await URLSession.shared.data(from: url)
                let image = UIImage(data: data)!
                
                // Step 2: Heavy processing (Simulated)
                return await self.applyHeavyBlur(to: image)
            }
        }
        
        var images = [UIImage]()
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

At first glance, this looks fine. If the user navigates away from the gallery and the parent task is cancelled, the group is cancelled. And since URLSession.shared.data(from:) is built by Apple to be cancellation-aware, the download will likely stop. But look at that applyHeavyBlur call. If that function contains a for loop processing pixels or a heavy computation, it has no idea the group was cancelled. It will finish the entire blur operation for every image already in flight before finally exiting. You're essentially leaking resources.

Making Child Tasks Cooperative

To fix this, you have to teach your child tasks how to stop. You have two primary tools here: Task.isCancelled for a boolean check, and try Task.checkCancellation() for when you want to bail out immediately by throwing a CancellationError.

I generally prefer checkCancellation() because it integrates perfectly with withThrowingTaskGroup. When it throws, the task ends, and the group handles the error. Here is how I would rewrite that processing logic to be actually respectful of the system's resources:

func applyHeavyBlur(to image: UIImage) async -> UIImage {
    // Imagine this is a complex loop processing image chunks
    for chunk in 0...100 {
        // We check at the start of every significant iteration
        if Task.isCancelled {
            // We could return a partial image, but usually, 
            // we just want to stop entirely.
            return image 
        }
        
        // Simulate heavy work
        await performBlurOnChunk(chunk, image: image)
    }
    return image
}

// OR, if the function can throw:
func applyHeavyBlurThrowing(to image: UIImage) async throws -> UIImage {
    for chunk in 0...100 {
        try Task.checkCancellation() // Throws CancellationError if cancelled
        await performBlurOnChunk(chunk, image: image)
    }
    return image
}

The trade-off here is a tiny bit of overhead for checking the cancellation flag, but the payoff is massive. If you're processing 50 images and the user hits "Back," you stop the CPU usage almost instantly rather than waiting for 50 heavy blur operations to complete. I always tell my team: if you have a while or for loop inside an async function, it is practically a requirement to have a cancellation check inside that loop.

Handling Partial Successes

One last thing to consider is how you want the group to behave when one task fails. By default, if a child task in a withThrowingTaskGroup throws an error, the group doesn't automatically cancel the other siblings—it just propagates that error to the caller. However, if you want a "one fails, all fail" policy, you should explicitly call group.cancelAll() as soon as you catch the first error.

This creates a tight loop of efficiency: one task fails $\rightarrow$ you cancel the group $\rightarrow$ the other siblings see the cancellation flag $\rightarrow$ they stop their heavy work $\rightarrow$ the whole operation cleans up quickly. That's the professional way to handle concurrent pipelines.




📋 Practical Task

Exercise: Building a Cancellable Batch Log Parser

You need to build a log parsing system that processes several large text files concurrently. Each file is processed line-by-line. If any single file contains a "CRITICAL" error, the entire operation should be considered a failure, and all other ongoing parsing tasks should stop immediately to save resources.

Requirements:

  • Create a function parseLogs(files: [String]) async throws -> [String] using withThrowingTaskGroup.
  • Inside each child task, simulate parsing a file by looping through a range (e.g., 0 to 1000).
  • Inside the loop, use try Task.checkCancellation() to ensure the task stops if the group is cancelled.
  • If a task encounters a "CRITICAL" error (you can simulate this by checking if a random number hits a certain value or if the file name contains "error"), it should throw a custom LogError.criticalFound.
  • In the main group loop, if you catch a LogError.criticalFound, call group.cancelAll() and rethrow the error.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.