How does cancellation work in Swift concurrency, and what are task priorities and task locals for?
Predict first
A task is cancelled before it checks. What does checkCancellation do, and what does a child of a cancelled group see?
let t = Task { () -> String in
do { try Task.checkCancellation(); return "ran" } catch { return "\(type(of: error))" }
}
t.cancel()
print(await t.value)
let seen = await withTaskGroup(of: Bool.self) { g in
g.addTask { try? await Task.sleep(nanoseconds: 2_000_000_000); return Task.isCancelled }
g.cancelAll()
return await g.next()!
}
print(seen)
- ran, then false
- CancellationError, then true
- CancellationError, then false
Predict first
Does the onCancel handler run when the task is cancelled during a sleep?
import Foundation
final class Flag: @unchecked Sendable { var v = false }
let flag = Flag()
let t = Task {
await withTaskCancellationHandler { try? await Task.sleep(nanoseconds: 2_000_000_000) } onCancel: { flag.v = true }
}
try? await Task.sleep(nanoseconds: 50_000_000)
t.cancel()
await t.value
print(flag.v)
- true
- false
Mental model. Cancelling a task is hanging a “please wrap up” sign on its door. Polite workers look at the sign regularly, and the sign also gets hung on every sub-worker’s door.
Cancellation is cooperative and advisory. Calling task.cancel() (or cancelling a parent, or leaving a group by throwing) only sets a flag on the task and its children; nothing is interrupted. Your code must look: Task.isCancelled is a plain check, try Task.checkCancellation() throws CancellationError, and most framework async APIs (Task.sleep, URLSession’s async methods, async sequences) check for you and throw. So a long loop should call try Task.checkCancellation() every so often, and any cleanup should be in defer blocks that run regardless. To react immediately, for example to cancel an underlying request, wrap the work in withTaskCancellationHandler { … } onCancel: { … }, whose onCancel runs at once on whatever thread cancelled (so it must be thread-safe). Treat CancellationError as a normal outcome, not an error to show: do not log it or show an alert.
Cancellation propagates down the tree: cancelling a task group’s parent, or calling group.cancelAll(), makes Task.isCancelled true in every child. A new Task created after cancellation is unaffected unless it is a child. Priorities (.high, .medium, .low, .background and their QoS equivalents) are inherited by children and by Task { }, can be raised automatically when a higher-priority task awaits, and affect scheduling, not correctness. Task-local values (@TaskLocal) carry context such as a request id or a logger down the task tree without passing parameters: bind with $value.withValue(x) { … }, and children (including async let and group tasks) see it, while detached tasks do not. They are for ambient context like tracing, not for passing data the callee needs.
The check throws CancellationError once the flag is set, and cancelAll makes children see isCancelled true (and the sleep ends early).
The handler runs as soon as cancellation happens, while the operation is suspended.
Common traps
- Cancelling and assuming work stopped. Nothing stops unless the code checks the flag or awaits a cancellable API.
- Doing non-thread-safe work in
onCancel. It runs on the cancelling thread, concurrently with the task.
They might follow up with
- How do you stop a long loop on cancel? Call
try Task.checkCancellation()each iteration, or testTask.isCancelled. - When are task locals the right tool? For ambient context like a trace id, not for required inputs.
Related questions
- 10.3.2 How do tasks form a tree: Task, task groups, async let and detached tasks, and what does a child inherit?
- 10.2.1 When do you use OperationQueue instead of GCD, and how do dependencies, state and cancellation work?
- 8.4.1 When do onAppear, task and onChange run, and which one do you use for async work?
← How do tasks form a tree: Task, task groups, async let and detached…