← All shorts
60Swift 5.5+2 min read

Task Cancellation Is Cooperative

Cancelling a Task only flips a flag — the running code has to check it and stop itself, or it keeps running to completion.

Operation.cancel() had the same limitation, but it's easy to forget when Task makes cancellation look like a single, clean call. Calling task.cancel() doesn't stop anything by itself — it marks the task cancelled and leaves the running body to notice.

#The old way

final class ImageDownloadOperation: Operation {
    let url: URL
    init(url: URL) { self.url = url }

    override func main() {
        while !isCancelled {
            performDownloadChunk(url: url)
        }
    }

    func performDownloadChunk(url: URL) {}
}

#The new way

struct SearchResult { let query: String }

func search(_ query: String) async throws -> [SearchResult] {
    var results: [SearchResult] = []
    for page in 1...20 {
        try Task.checkCancellation()
        let batch = try await fetchPage(query: query, page: page)
        results.append(contentsOf: batch)
    }
    return results
}

func fetchPage(query: String, page: Int) async throws -> [SearchResult] {
    [SearchResult(query: query)]
}

final class SearchController {
    private var currentTask: Task<Void, Never>?

    func runSearch(for query: String) {
        currentTask?.cancel()
        currentTask = Task {
            do {
                let results = try await search(query)
                print("Found \(results.count) results")
            } catch is CancellationError {
                print("Search cancelled")
            } catch {
                print("Search failed: \(error)")
            }
        }
    }
}

#Why it matters

  • Replacing an in-flight search task when the user types again avoids racing stale results against fresh ones
  • Task.checkCancellation() throws CancellationError, which flows through existing try/catch instead of needing a separate cancelled-state check
  • Cancellation propagates automatically from a parent task to every child created inside a TaskGroup or async let
  • Task.isCancelled is available for code paths that want to react without throwing, such as returning a partial result

#Gotcha

Cancellation does not interrupt currently executing code — a long synchronous loop or an await on a call that itself never checks cancellation will run to completion regardless of cancel() having been called. Every loop iteration or long-running step in async code needs an explicit try Task.checkCancellation() or Task.isCancelled check; without one, cancelling the task is a no-op until the next suspension point that happens to check.

swiftconcurrency

Related shorts