How does cancellation actually reach your code: what do checkCancellation, isCancelled, withTaskCancellationHandler and sleeping do, and which calls ignore it?
Predict first
When does onCancel run, and what does a checked loop do?
import Foundation
final class Flag: @unchecked Sendable {
private let lock = NSLock(); private var set = false
func raise() { lock.lock(); set = true; lock.unlock() }
var isSet: Bool { lock.lock(); defer { lock.unlock() }; return set }
}
let flag = Flag()
let t = Task {
await withTaskCancellationHandler {
try? await Task.sleep(nanoseconds: 300_000_000)
print("body resumed, isCancelled:", Task.isCancelled)
} onCancel: {
flag.raise()
print("onCancel ran")
}
}
try await Task.sleep(nanoseconds: 50_000_000)
t.cancel()
print("cancel() returned, flag:", flag.isSet)
await t.value
func work() async throws -> Int {
for i in 1...3 { try Task.checkCancellation(); print("step", i); try await Task.sleep(nanoseconds: 20_000_000) }
return 3
}
let t2 = Task { try await work() }
try await Task.sleep(nanoseconds: 30_000_000)
t2.cancel()
do { _ = try await t2.value } catch { print("t2 threw", type(of: error)) }
- onCancel ran / cancel() returned, flag: true / body resumed, isCancelled: true / step 1 / step 2 / t2 threw CancellationError
- cancel() returned, flag: false / onCancel ran / body resumed, isCancelled: true
- step 1 / step 2 / step 3 / t2 threw CancellationError
Predict first
What do Task.sleep and a blocking sleep do when their task is cancelled?
import Foundation
func blockFor(_ s: Double) { Thread.sleep(forTimeInterval: s) }
let t = Task {
do { try await Task.sleep(nanoseconds: 5_000_000_000); print("done") }
catch { print("Task.sleep threw", type(of: error)) }
}
try await Task.sleep(nanoseconds: 30_000_000)
let s = ContinuousClock.now
t.cancel(); await t.value
print("cancel noticed fast:", ContinuousClock.now - s < .milliseconds(500))
let t2 = Task.detached { blockFor(0.3); print("Thread.sleep finished, isCancelled:", Task.isCancelled) }
try await Task.sleep(nanoseconds: 30_000_000)
let s2 = ContinuousClock.now
t2.cancel(); await t2.value
print("Thread.sleep ignored the cancel:", ContinuousClock.now - s2 > .milliseconds(150))
- Task.sleep threw CancellationError / cancel noticed fast: true / Thread.sleep finished, isCancelled: true / Thread.sleep ignored the cancel: true
- done / cancel noticed fast: false / Thread.sleep finished, isCancelled: true
- Task.sleep threw CancellationError / cancel noticed fast: true / Thread.sleep ignored the cancel: false
Mental model. Cancellation is a sign saying “please stop”. Polite code reads the sign at every checkpoint; the handler is a bell that rings straight away so someone can switch off the machine you cannot interrupt.
Cancelling a task only sets a flag and runs handlers; nothing is interrupted. Your code notices it by polling (Task.isCancelled, or try Task.checkCancellation(), which throws CancellationError) or by calling something that checks for you. In the first part of the check, work() checks at the top of each loop: after the cancel at 30 ms it stops at step 3 and the awaiting caller sees CancellationError. Check at natural boundaries (each loop iteration, before an expensive step), and make cleanup run through defer so a throw leaves nothing half done.
withTaskCancellationHandler { … } onCancel: { … } is for work that cannot poll, such as a callback-based API or a stored URLSessionTask: onCancel runs immediately, on whichever thread called cancel(), even while the body is suspended, so it must be quick and thread-safe (flip a lock-protected flag, cancel the underlying request). In the check the handler runs before cancel() returns, while the body only resumes later and then sees isCancelled == true. Finally, know what honours cancellation: Task.sleep throws at once, URLSession’s async APIs cancel the request, and an AsyncStream loop ends when its task is cancelled; Thread.sleep and a busy loop ignore it (and calling Thread.sleep directly from async code is rejected in Swift 6 language mode, which is why the check wraps it in a plain function), so the sleep below runs its full 0.3 s after cancel().
The handler runs on the cancelling thread straight away; the polling loop stops at its next check.
Task.sleep is a cancellation point; a blocking sleep simply runs to the end.
Common traps
- Doing long work with no cancellation checks. Cancelling a user’s search then changes nothing until the loop ends.
- Doing heavy or non-thread-safe work in
onCancel. It runs synchronously on the cancelling thread.
They might follow up with
- Does cancelling a parent cancel its children? Yes, for child tasks and groups; unstructured and detached tasks are not cancelled with their creator (Q293).
- Should I catch
CancellationError? Usually let it propagate; treat it as “not a failure”, not as something to show the user.
Related questions
- 10.8.5 Which tasks inherit a @TaskLocal value, and when is it a better tool than a parameter?
- 10.8.2 What happens to the other children when one child in a throwing task group fails, and what does a discarding…
- 10.8.3 What happens to an async let that you never await when the scope ends?
- 10.3.3 How does cancellation work in Swift concurrency, and what are task priorities and task locals for?
- 10.6.1 How do continuations bridge callback APIs, and what is the difference between checked and unsafe?
← What happens to an async let that you never await when the scope ends? Which tasks inherit a @TaskLocal value, and when is it a better tool… →