The question this answers
When nobody wants this result any more, what should stop — and what actually will?
A search endpoint that runs a 3-second database query, an 800 ms call to a ranking service, and a cache write. A user types, gets impatient, and navigates away 400 ms in; the browser aborts the connection.
The cancellation signal itself — written once by the canceller, read by every task that checks it. Plus every resource the cancelled work holds: a database connection, an open transaction, a partially written buffer.
Work whose result nobody will read consumes no further resources beyond a bounded settling period, and cancelling never leaves a resource unreleased or a partial write visible.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Ask the question explicitly
Most systems never ask it. A handler starts a query, the client disconnects, and the query runs to completion because nothing connected the two events. At low traffic this is invisible. At scale it is a specific and expensive failure: during a slowdown, users retry, each retry starts fresh work, the abandoned work keeps its database connection, and the system spends its capacity computing answers for people who left. That is how a latency blip becomes an outage.
So ask it, per operation, out loud. Should the database query be cancelled? Usually yes, and most drivers can send a cancel to the server. Should the ranking call be cancelled? Yes, and its own deadline should shrink to whatever is left (Deadlines vs Timeouts). Should the cache write be cancelled? Probably not — it is cheap, it benefits the *next* user, and aborting it halfway may leave a partial entry. Should a payment authorization be cancelled? Almost certainly not once it has been sent, because "cancelled" and "succeeded but we stopped listening" are indistinguishable from your side, and the difference is money.
That last case is the general shape of the hard ones. Cancellation is safe when the work has no external effect or a fully idempotent one. It is dangerous exactly where the work has committed something outside your process, because stopping the local task does not stop the remote effect (Idempotency Keys: The Mechanism in API Design is how you make it recoverable).
1const ac = new AbortController()2req.on('close', () => ac.abort()) // client disconnected3 4async function search(q, signal) {5 const rows = await db.query(q, { signal }) // driver must honour it6 signal.throwIfAborted() // explicit check point7 const ranked = await fetch(RANK_URL, { signal }) // fetch honours it natively8 return ranked9}10// A CPU loop between the awaits is uninterruptible: the abort event11// cannot even be delivered until the loop yields to the event loop.AbortSignal is a value you pass down. Nothing propagates it for you — a function that does not take a signal parameter cannot be cancelled, and that is most of the ecosystem.
1async function search(q: string, signal: AbortSignal): Promise<Result> {2 using _ = registerCleanup(() => releaseConnection()) // runs on abort path3 const rows = await db.query(q, { signal })4 if (signal.aborted) throw signal.reason // reason, not a bare Error5 return rank(rows, { signal })6}7 8// Composing deadlines with cancellation:9const signal = AbortSignal.any([10 req.signal, // client went away11 AbortSignal.timeout(remainingMs), // deadline for this hop12])The type system can require a signal parameter, which is the only reliable way to make cancellability a checked property rather than a hope. AbortSignal.any composes several reasons into one signal.
1task = asyncio.create_task(search(q))2task.cancel() # raises CancelledError at the next await3 4async def search(q):5 try:6 rows = await db.fetch(q) # CancelledError raised here7 return rank(rows)8 except asyncio.CancelledError:9 await release_connection() # cleanup10 raise # RE-RAISE. Swallowing it breaks the contract.11 finally:12 span.end()13 14# except Exception does NOT catch CancelledError in 3.8+ (it derives from15# BaseException) - which is deliberate, so a broad handler cannot eat it.Cancellation is delivered as an exception at the next suspension point. The rule that catches people: catching it and not re-raising leaves the task alive while its parent believes it was cancelled.
1std::jthread worker([](std::stop_token st) {2 while (!st.stop_requested()) { // an explicit poll, every iteration3 auto chunk = read_next(); // a blocking read here is NOT4 if (st.stop_requested()) return; // interruptible by the token5 process(chunk);6 }7});8// worker.request_stop() is called automatically by ~jthread.9 10// Blocking waits must be given the token explicitly:11std::condition_variable_any cv;12cv.wait(lock, st, []{ return ready; }); // wakes on stop_requested toostd::stop_token is a flag you poll. There is no exception, no unwinding and no interruption of a blocking syscall — a thread in read() stays there until the read returns, whatever the token says.
- Delivery differs fundamentally: an exception raised at a suspension point (Python asyncio), an event plus a value you must check (AbortSignal), or a flag you poll (stop_token). Only the exception form interrupts a waiting operation without the callee cooperating.
- None of them interrupts a CPU-bound loop. In every one of these runtimes, a task that does not reach a check point is not cancelled — it is marked.
- None of them interrupts a blocking syscall. Cancelling a thread parked in a synchronous read requires closing the descriptor or sending a signal, which is an OS-level action, not a language one (Blocking, Non-blocking, Multiplexed, Asynchronous).
- Propagation is manual everywhere: the signal, token or task reference must be threaded through every function that could be cancelled. A single library call that does not accept one is a hole in the chain.
- Cleanup semantics differ: Python unwinds through finally blocks because cancellation is an exception; the flag-based models run no cleanup at all unless the code polls, notices and does it.
Cooperative means it can be ignored
This is the sentence to keep: cancellation in these runtimes is cooperative, so a task that never checks is not cancelled — it is merely marked. Cancelling sets a flag or queues an exception. The task stops when it next reaches a place where the runtime can deliver it. If it never reaches one, it never stops, and the caller has a perfectly reasonable-looking cancel() call that did nothing.
Three shapes cause this in practice. A CPU-bound loop with no suspension point — a 40-second regex, a big JSON parse, a tight numerical loop. A blocking syscall — a synchronous socket read or file read, where the thread is inside the kernel and the language runtime cannot reach it (Blocking, Non-blocking, Multiplexed, Asynchronous in Operating Systems). And a swallowed cancellation — except Exception in older code, or a catch that logs and continues, converting a cancel into an ignored error.
The consequence is that "cancelled" is not a state you can assert; it is a request you made. Anything that must be bounded needs a second mechanism behind it: a deadline the caller enforces regardless (Timeouts), a resource limit that kills the process, or a design where the work is small enough that "at the next check point" is soon. Preemptive alternatives exist — killing a thread, terminating a process — and they are avoided for a reason: they leave locks held, buffers half-written and invariants broken, which is a worse failure than the one you were preventing.
| # | Client / handler | DB query task (async driver) | Ranking task (CPU loop) | Cache write task | State |
|---|---|---|---|---|---|
| 1 | client disconnects; controller.abort() | · | · | · | signal=aborted db_conn=held elapsed=400ms |
| 2 | · | at next await, receives cancellation | · | · | signal=aborted db_conn=held elapsed=402ms |
| 3 | · | sends cancel to the server, releases connection in finally | · | · | db_conn=released elapsed=415ms |
| 4 | · | · | in a tight scoring loop; no suspension point | · | signal=aborted ranking=still running elapsed=900ms ✕ The task is marked cancelled and is still consuming a core. Nothing in the runtime can stop it. |
| 5 | scope waits for all children before returning | · | · | · | handler=blocked on ranking elapsed=900ms |
| 6 | · | · | loop completes normally at 2.6s; discards its result | · | ranking=done, unused elapsed=2600ms ✕ 2.2 seconds of CPU spent producing a value nobody will read, while queued requests waited for that core. |
| 7 | · | · | · | catches the cancellation, logs it, continues writing | cache=written elapsed=430ms |
Doing it properly
The practical rules are short. Thread the signal through everything. A function that performs cancellable work takes a signal, token or context parameter; one that does not is a hole, and the hole is usually a third-party library. Check at every boundary — before starting an expensive step, and after every await — because checking only at the start means a cancel that arrives one microsecond later is ignored for the whole operation.
Always clean up on the cancellation path, and test that path specifically. Cancellation is an error path that runs in production far more often than most error paths and is tested far less. A cancelled task that leaks a connection is worse than one that runs to completion, because now you have both the wasted work and a pool leak.
Never swallow it. If cancellation arrives as an exception, catch it only to clean up and then re-raise. Converting it into a return value, or logging and continuing, leaves the task alive while its parent has been told it stopped — and that divergence is nearly impossible to debug because both sides look correct in isolation. Finally, distinguish "cancelled" from "failed" in your metrics: a cancelled request is not an error and should not consume the error budget, but it should be counted, because a rising cancellation rate is a strong early signal of user-visible slowness (Error Budgets: Unreliability You Are Allowed to Spend in Performance).
| Operation | Cancel? | Why | What must be true first |
|---|---|---|---|
| Read query with no side effects | Yes | Pure waste once nobody reads the result | The driver supports it and sends a cancel to the server, not just abandoning the socket |
| Outbound call to another internal service | Yes | Frees its capacity too, and the deadline should shrink downstream | The deadline is propagated so the callee also stops (Deadlines vs Timeouts) |
| CPU-bound computation | Yes, but it needs check points | Otherwise it is marked, not cancelled, and holds a core | Check inside the loop, or chunk it, or run it where it can be killed |
| Cache or index write | Usually no | Cheap, idempotent, benefits the next request | It is genuinely idempotent and a partial write is impossible |
| Payment authorization already sent | No | "Cancelled" and "succeeded, unheard" are indistinguishable from here | An idempotency key exists so the state can be reconciled later |
| Multi-step write with no transaction | Dangerous | Stopping between steps leaves the system inconsistent | Either a transaction, or a compensating action, or do not cancel |
| Audit or billing record | No | Must complete regardless of who is listening | It has an owner that outlives the request (Orphaned Tasks) |
Key points
- Ask the question per operation: the query should stop, the cache write probably should not, the payment authorization definitely should not.
- Cancellation is cooperative in mainstream runtimes — a task that never reaches a check point is marked, not cancelled.
- Neither a CPU loop nor a blocking syscall can be interrupted by a language-level cancel; those need chunking, a check inside the loop, or an OS-level action.
- The signal has to be threaded through every function by hand; a library that does not accept one is a hole in the chain.
- Catch cancellation only to clean up, then re-raise. Swallowing it makes the parent believe a still-running task has stopped.
- Count cancellations separately from errors: not an error budget item, but a leading indicator of user-visible slowness.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • A cancellation source is created with a lifetime — usually the request — and a signal or token derived from it is passed down the call chain.
- • Cancelling sets the source's state and notifies registered listeners; it does not touch any running code directly.
- • A task observes cancellation either by polling the token, by an exception raised at its next suspension point, or by an awaited operation that itself honours the signal.
- • On observing it, the task runs its cleanup — release connections, close files, end spans — and then propagates the cancellation to its caller.
- • Awaited I/O operations that accept a signal abort at the transport level, which is what actually frees the remote resource rather than merely abandoning the local wait.
- • The canceller does not know when the task stopped unless it joins it; without a join, "cancelled" means "asked", and the resource may still be held.
- • Cancel arrives while the task is suspended at an await: the exception is raised there, finally blocks run, the connection is released. The intended path.
- • Cancel arrives one instruction after the task checked the flag and entered a 3-second computation: the check passed, the work runs to completion, the result is discarded. Checking at the start only is checking at the wrong granularity.
- • Cancel arrives while a thread is inside a blocking read: the flag is set and the thread stays in the kernel until the read returns or the descriptor is closed. Nothing in the language runtime can help.
- • Cancel is delivered as an exception; the task catches a broad exception type, logs "search failed", and returns a default result. The parent sees a normal return and believes the task cancelled cleanly; it did not, and it kept its connection.
- • Double cancel: the deadline fires and the client also disconnects. Two cancellations arrive; cleanup runs twice; the connection is returned to the pool twice and is then handed to two different requests simultaneously. Cleanup must be idempotent.
- • The safe schedule: signal checked before each expensive step and after each await, cleanup in a finally, cancellation re-raised — the task stops within one step's duration and holds nothing.
- • Cancellation guarantees that a request to stop has been recorded and will be visible to anything that looks.
- • It guarantees that operations explicitly honouring the signal will abort at their next opportunity and unwind through their cleanup.
- • It does NOT guarantee the task stops. Cooperative means exactly that: no check point, no stop.
- • It does NOT guarantee promptness. The bound is "at the next check point", which for an unchunked computation may be minutes.
- • It does NOT undo anything. Effects already applied stay applied; cancellation is not a rollback (Transactions and ACID in Database Engineering is where rollback lives).
- • It does NOT propagate on its own. A child task started without the signal is unreachable by it (Cancellation Propagation).
- • The signal is read by every task on every check, making it a shared read-mostly variable — cheap, but on a hot path it should be an atomic load rather than something behind a lock (Atomics: What Is Actually Indivisible).
- • Cancelling a scope with many children wakes all of them at once, producing a scheduler burst during an already-degraded moment.
- • Cleanup contends for the resources being released: a hundred cancelled requests returning connections simultaneously hammers the pool's lock.
- • Uncancellable CPU work is the worst contention of all — it holds a core that queued, still-wanted requests need (Oversubscription).
- • Marked but not cancelled: the task keeps running and holding resources while every caller believes it stopped.
- • Swallowed cancellation, converting a stop request into an unlogged error and a still-live task.
- • Resource leak on the cancellation path, because that path has no cleanup or the cleanup itself was skipped.
- • Partial writes when a multi-step operation is cancelled between steps with no transaction and no compensation.
- • Double cleanup when two cancellation sources fire and the cleanup is not idempotent.
- • Cancellation storms during a slowdown: every client times out at once, every handler cancels, and the cleanup work itself becomes the load.
- • Whenever the caller can go away — every user-facing request path, which is most of them.
- • During overload, because abandoning work nobody wants is the cheapest capacity you will ever recover.
- • For fan-out where one branch failing makes the others pointless — cancelling siblings turns wasted parallel work into an immediate error (Structured Concurrency).
- • For anything with a deadline, because a timeout without cancellation stops waiting and does not stop the work (Timeouts).
- • When the work has an external effect that is not idempotent. Cancelling after the effect and before the record is the ambiguity you cannot resolve later.
- • When the work benefits someone other than the caller — cache warms, index updates, audit records. Cancelling those is a small self-inflicted regression.
- • When cleanup is more expensive than finishing. Aborting a nearly-complete operation and unwinding can cost more than the 20 ms it had left.
- • When it creates a false sense of boundedness: a system that "supports cancellation" but whose hot loop never checks is exactly as unbounded as one that does not, with more code.
- • Cancellation rate, counted separately from errors and broken down by cause: client disconnect, deadline, sibling failure. Each has a different fix.
- • Time from cancel to task exit, per task type. A p99 in seconds identifies exactly which code paths lack check points.
- • Work performed after cancellation — CPU seconds or query time attributable to already-cancelled requests. This is the number that justifies the engineering work.
- • Resource-release rate on the cancellation path versus the success path; a gap is a leak that only appears under the conditions that cause cancellation.
- • Client-disconnect rate at the server, which most stacks can report and almost nobody graphs; it is a direct measure of users leaving before you answered.
- • Every signature on a cancellable path grows a parameter, and every layer must pass it — mechanical, invasive, and easy to get 95% right, which is 0% effective on the missing path.
- • Every cancellable operation grows an error path that must clean up idempotently and be tested, roughly doubling the paths through the function.
- • Third-party libraries that do not accept a signal force a wrapper, a thread, or an accepted gap in coverage — and the gap must be documented or it will be assumed closed.
- • Cancellation interacts with retries, timeouts and deadlines, and the composition of all four is where the genuinely subtle bugs live.
- • A deadline enforced by the caller: stop waiting and return, accepting that the work continues. Weaker, much simpler, and honest about what it does (Timeouts).
- • Make the work short enough that cancellation is unnecessary — chunking a 40-second job into 200 ms pieces solves the problem structurally.
- • Run cancellable work in a separate process you can actually kill, when the language cannot interrupt it. Preemption at the OS level, with all the state-loss caveats (Process versus Thread in Operating Systems).
- • Admission control: refuse work you cannot complete rather than starting it and cancelling later. Cheaper than any cancellation machinery (Backpressure).
Cancelling a parent task
# cooperative cancellation — the only kind that exists in practice
async def child(token):
while work_remains():
if token.cancelled: raise CancelledError # ← the check IS the mechanism
do_a_batch() # ← must be short enough to notice
await parent.cancel() # sets the flag on every child, then WAITS for them
# it cannot pre-empt a running thread; there is no safe killWhat people believe, and what is true
I called cancel(), so the task stopped.
You recorded a request. The task stops when it next checks. If it never checks, it never stops, and nothing will tell you.
Cancellation rolls back what the task did.
It stops future work. Everything already applied stays applied — that is what transactions and compensating actions are for.
Cancelled requests are errors.
They are a distinct outcome. Counting them as errors corrupts the error budget; not counting them at all loses one of the best early signals of user-visible slowness.