Swift
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Optionals
-
Section 4: Object-Oriented and Value Types
-
Section 5: Memory Management
-
Section 6: Generics and Error Handling
-
Section 7: Concurrency
-
Section 8: Working with Collections
-
Section 9: Codable and Data Handling
-
Section 10: Protocol-Oriented Programming
-
Section 11: Testing and Tooling
-
Section 12: Practical Projects
-
Section 13: Interview Practice
-
Section 14: More Practice Exercises
-
Section 15: More Standard Library
-
Section 16: Advanced Concurrency
-
Section 17: Foundation Framework Deep Dive
-
Section 18: URLSession and Networking Deep Dive
-
Section 19: Combine Framework
-
Section 20: SwiftUI Fundamentals for Swift Developers
-
Section 21: Server-Side Swift with Vapor
-
Section 22: Swift Package Manager Deep Dive
-
Section 23: Swift Concurrency Deep Dive
-
Section 24: More Language Features
-
Section 25: Error Handling Deep Dive
-
Section 26: Testing Deep Dive
-
Section 27: Data Structures and Algorithms in Swift
-
Section 28: More Practice Exercises
-
Section 29: More Interview Practice
-
Section 30: Swift Macros (Swift 5.9+)
-
Section 31: Property Wrappers Ecosystem
-
Section 32: Swift Interop Deep Dive
-
Section 33: iOS App Architecture Patterns
-
Section 34: Performance and Debugging
-
Section 35: App Distribution and CI/CD
-
Section 36: More Practical Projects
-
Section 37: SwiftData and Persistence
-
Section 38: More Design Patterns
-
Section 39: More Review and Practice
-
Section 40: More Foundation Deep Dive
-
Section 41: Advanced Collections in Swift
-
Section 42: Advanced Generics Practice
-
Section 43: UIKit for Legacy and Hybrid Apps
-
Section 44: watchOS and visionOS Development Basics
-
Section 45: More Networking Patterns
-
Section 46: More Testing Practice
-
Section 47: Accessibility in Swift Apps
-
Section 48: Localization
-
Section 49: More Practical Projects Round 2
-
Section 50: Swift Charts Framework
-
Section 51: More Interview and Algorithm Practice
-
Section 52: Final Practice and Mastery
-
Section 53: Swift Compiler and Build System
-
Section 54: More Concurrency Practice
-
Section 55: App Store Guidelines and Review
-
Section 56: More Design and Architecture
106: Task Groups Revisited with Cancellation Handling
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]usingwithThrowingTaskGroup. - 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, callgroup.cancelAll()and rethrow the error.
There are no comments for now.