Swift Task Cancellation
In Swift's concurrency model, gracefully handling cancellation is a critical skill. Task cancellation allows your app to stop work that's no longer needed, saving resources and improving user experience. This guide will walk you through how cancellation works in Swift's concurrency system and how to implement it properly in your code.
Introduction to Task Cancellation
When performing asynchronous operations, there are many scenarios where you might need to cancel work in progress:
- A user navigates away from a screen where data was being loaded
- A search query changes before the previous results finish loading
- A timeout occurs while waiting for a network response
- Your app enters the background and needs to conserve resources
Swift's structured concurrency model provides a cooperative cancellation system that allows tasks to be cancelled cleanly without leaving resources in inconsistent states.
Swift's cancellation is cooperative, meaning that tasks must explicitly check for and respond to cancellation. They aren't automatically terminated when cancelled.
Checking for Cancellation
The first step in handling cancellation is knowing when a task has been cancelled. Swift provides several ways to check for cancellation.
Using Task.checkCancellation()
The simplest way to check for cancellation is by calling Task.checkCancellation()
:
func processLargeDataSet() async throws -> [ProcessedItem] {
var results = [ProcessedItem]()
for item in largeDataSet {
// Check if this task has been cancelled
try Task.checkCancellation()
// Process the item (this might take time)
let processedItem = await processItem(item)
results.append(processedItem)
}
return results
}
If the task has been cancelled when checkCancellation()
is called, it throws a CancellationError
, which you can catch and handle appropriately.
Using Task.isCancelled
For non-throwing contexts or when you want to handle cancellation without throwing:
func processFeed() async -> [FeedItem] {
var results = [FeedItem]()
for item in feed {
// Check if cancelled without throwing
if Task.isCancelled {
// Perform cleanup
return results
}
let processedItem = await processItem(item)
results.append(processedItem)
}
return results
}
Cancelling Tasks
Now that we know how to check for cancellation, let's look at how to actually cancel tasks.
Cancelling Child Tasks
When you create a child task using Task
, you can cancel it by calling cancel()
on the task instance:
// Create a task that can be cancelled
let downloadTask = Task {
try await downloadLargeFile(from: fileURL)
}
// Later, when you need to cancel:
downloadTask.cancel()
Cancellation with TaskGroup
When working with task groups, you can cancel the entire group:
func downloadImages(for urls: [URL]) async throws -> [UIImage] {
return try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
for (index, url) in urls.enumerated() {
group.addTask {
let image = try await downloadImage(from: url)
return (index, image)
}
}
// If a condition occurs that requires cancellation:
if someErrorCondition {
group.cancelAll() // Cancels all tasks in the group
}
// Collect and return results...
var images = [UIImage](repeating: UIImage(), count: urls.count)
for try await (index, image) in group {
images[index] = image
}
return images
}
}
Implementing Cancellation Handlers
Simply checking for cancellation isn't enough - you also need to implement proper cleanup of resources when cancellation occurs.
Clean Resource Handling
func processWithResources() async throws -> Result {
// Acquire resources
let resource = await acquireExpensiveResource()
// Use defer to ensure cleanup happens whether we complete or get cancelled
defer {
resource.release()
}
// Perform work with periodic cancellation checks
for item in items {
try Task.checkCancellation()
await process(item, using: resource)
}
return finalResult
}
Propagating Cancellation
When your function calls other async functions, cancellation is automatically propagated. However, for non-async operations, you may need to forward cancellation:
func processImages() async throws -> [ProcessedImage] {
let images = try await fetchImages()
return try await withThrowingTaskGroup(of: ProcessedImage.self) { group in
for image in images {
group.addTask {
// If the parent task is cancelled, child tasks inherit that cancellation
try Task.checkCancellation()
return try await processImage(image)
}
}
var results = [ProcessedImage]()
for try await processedImage in group {
results.append(processedImage)
}
return results
}
}
Real-World Example: Searchable Content
Let's implement a practical example of cancellation with a search feature that cancels previous requests when new searches are initiated:
class SearchViewModel: ObservableObject {
@Published var searchResults: [SearchResult] = []
@Published var isLoading = false
private var currentSearchTask: Task<Void, Never>?
func search(query: String) {
// Cancel any previous search
currentSearchTask?.cancel()
// If the query is empty, clear results and return
if query.isEmpty {
searchResults = []
isLoading = false
return
}
isLoading = true
// Create and store new search task
currentSearchTask = Task {
do {
// Add a small delay to avoid excessive searches while typing
try await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
try Task.checkCancellation()
// Perform the search
let results = try await searchAPI.fetchResults(for: query)
// Check again if cancelled before updating UI
try Task.checkCancellation()
// Update UI on the main thread
await MainActor.run {
self.searchResults = results
self.isLoading = false
}
} catch is CancellationError {
// Search was cancelled, do nothing
print("Search cancelled: \(query)")
} catch {
// Handle other errors
await MainActor.run {
self.isLoading = false
// Handle error (show alert, etc.)
}
}
}
}
func cancelSearch() {
currentSearchTask?.cancel()
currentSearchTask = nil
isLoading = false
}
// Clean up when the view model is deallocated
deinit {
cancelSearch()
}
}
In a SwiftUI view, you might use this view model like this:
struct SearchView: View {
@StateObject private var viewModel = SearchViewModel()
@State private var searchQuery = ""
var body: some View {
VStack {
TextField("Search", text: $searchQuery)
.onChange(of: searchQuery) { query in
viewModel.search(query: query)
}
if viewModel.isLoading {
ProgressView()
} else {
List(viewModel.searchResults) { result in
Text(result.title)
}
}
}
.onDisappear {
viewModel.cancelSearch()
}
}
}
Advanced Cancellation Techniques
Custom Cancellation Handling
Sometimes you might want to implement custom cancellation behavior:
func downloadWithProgress(url: URL, progressHandler: @escaping (Double) -> Void) async throws -> Data {
let (bytes, response) = try await URLSession.shared.bytes(from: url)
let totalBytes = Double(response.expectedContentLength)
var downloadedBytes = 0
var data = Data()
for try await byte in bytes {
try Task.checkCancellation()
data.append(byte)
downloadedBytes += 1
// Update progress every 50KB or so
if downloadedBytes % 50_000 == 0 {
let progress = Double(downloadedBytes) / totalBytes
await MainActor.run {
progressHandler(progress)
}
}
}
return data
}
Working with Cancellation Handlers
You can use cancellation handlers to perform cleanup when a task is cancelled:
let task = Task {
try await performLongRunningOperation()
}
// Install a handler that runs when the task is cancelled
task.setCancellationHandler {
print("The task was cancelled, cleaning up...")
// Perform any necessary cleanup
}
// Later when needed:
task.cancel()
Implementing Timeouts
Cancellation is useful for implementing timeouts:
func fetchWithTimeout<T>(timeout: UInt64, operation: @escaping () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
// Add the main operation
group.addTask {
return try await operation()
}
// Add a timeout task
group.addTask {
try await Task.sleep(nanoseconds: timeout)
// This will only throw if the operation is still running
throw TimeoutError()
}
// When the first task completes (either operation or timeout)
// cancel all other tasks and return the result
do {
guard let result = try await group.next() else {
throw CancellationError()
}
// Cancel any remaining tasks (the timeout in this case)
group.cancelAll()
// Consume any remaining tasks
for try await _ in group {
// Ignore remaining results
}
return result
} catch {
// Cancel all tasks on any error
group.cancelAll()
throw error
}
}
}
// Usage example
func loadData() async throws -> Data {
// Will throw TimeoutError if it takes longer than 5 seconds
return try await fetchWithTimeout(timeout: 5_000_000_000) {
try await networkClient.fetchData()
}
}
Best Practices for Cancellation
-
Check Cancellation Regularly: Insert cancellation checks at appropriate intervals, especially during long-running operations.
-
Clean Up Resources: Always release any acquired resources when cancellation occurs.
-
Use
defer
Statements: They ensure cleanup code runs regardless of how a function exits. -
Be Cooperative: Remember that cancellation is cooperative - your code must actively check for and respond to cancellation.
-
Propagate Cancellation: Make sure to forward cancellation to any child tasks or operations.
-
Test Cancellation Paths: Verify that resources are properly cleaned up when tasks are cancelled.
-
Handle Cancellation Explicitly: Distinguish between cancellation and other errors in your error handling.
Summary
Swift's task cancellation system provides a powerful way to manage the lifecycle of asynchronous operations. By implementing proper cancellation handling, you can create more responsive applications that efficiently manage resources and provide a better user experience.
Key takeaways:
- Cancellation in Swift is cooperative, not preemptive
- Use
Task.checkCancellation()
orTask.isCancelled
to detect cancellation - Always clean up resources when cancellation occurs
- Cancellation automatically propagates to child tasks
- Implement cancellation in real-world scenarios like search operations or network requests
Exercises
-
Modify the search example to add a debounce mechanism that prevents searches from triggering too frequently when the user is typing quickly.
-
Implement a file download manager that can download multiple files concurrently but allows individual downloads to be cancelled.
-
Create a caching image loader that cancels unnecessary downloads when the user rapidly scrolls through a list of images.
-
Implement a task that automatically cancels if it runs longer than a specified timeout period.
-
Build a weather app that cancels outdated weather requests when the user changes location.
Additional Resources
- Apple's Documentation on Task Cancellation
- WWDC21: Swift Concurrency: Behind the Scenes
- Swift.org: Structured Concurrency
- Hacking with Swift: How to Cancel Tasks
- Swift by Sundell: Task Management in Swift
By understanding and properly implementing task cancellation, you'll write more efficient and responsive asynchronous code that provides a better experience for your users.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)