Structured Concurrency & Cancellation

Cancellation

The user closed the tab. Should the database query still run? The HTTP call to the payment provider? The 40-second report generation? Cancellation is a first-class design question, and the uncomfortable answer is that in most runtimes a task that never checks is not cancelled — it is merely marked.

▶ Run the lab

The question this answers

The question

When nobody wants this result any more, what should stop — and what actually will?

The work

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.

What is shared

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.

The invariant — what must stay true under every interleaving

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.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

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).

Four runtimes, four mechanisms, one shared property: nothing is forced. — Signal that in-progress work should stop, and have the work notice.
JavaScriptNODE.JS
1const ac = new AbortController()
2req.on('close', () => ac.abort()) // client disconnected
3
4async function search(q, signal) {
5 const rows = await db.query(q, { signal }) // driver must honour it
6 signal.throwIfAborted() // explicit check point
7 const ranked = await fetch(RANK_URL, { signal }) // fetch honours it natively
8 return ranked
9}
10// A CPU loop between the awaits is uninterruptible: the abort event
11// 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.

TypeScriptRUNTIME-SPECIFIC
1async function search(q: string, signal: AbortSignal): Promise<Result> {
2 using _ = registerCleanup(() => releaseConnection()) // runs on abort path
3 const rows = await db.query(q, { signal })
4 if (signal.aborted) throw signal.reason // reason, not a bare Error
5 return rank(rows, { signal })
6}
7
8// Composing deadlines with cancellation:
9const signal = AbortSignal.any([
10 req.signal, // client went away
11 AbortSignal.timeout(remainingMs), // deadline for this hop
12])

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.

PythonCPYTHON
1task = asyncio.create_task(search(q))
2task.cancel() # raises CancelledError at the next await
3
4async def search(q):
5 try:
6 rows = await db.fetch(q) # CancelledError raised here
7 return rank(rows)
8 except asyncio.CancelledError:
9 await release_connection() # cleanup
10 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 from
15# 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.

C++LANGUAGE-SPECIFIC
1std::jthread worker([](std::stop_token st) {
2 while (!st.stop_requested()) { // an explicit poll, every iteration
3 auto chunk = read_next(); // a blocking read here is NOT
4 if (st.stop_requested()) return; // interruptible by the token
5 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 too

std::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.

What actually differs
  • 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.

Cancel is called at t=400ms. What each task actually does about it.ILLUSTRATIVE
Invariant · Once the client is gone, the work it triggered releases its resources within a bounded time.
#Client / handlerDB query task (async driver)Ranking task (CPU loop)Cache write taskState
1client 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.
5scope 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 writingcache=written elapsed=430ms
Three tasks, three different correct answers. The query should and did stop. The cache write should not and did not. The ranking task could not, and the only fixes are structural: put a check inside the loop, chunk the work so suspension points exist, or move it to a process you can actually terminate.

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).

OperationCancel?WhyWhat must be true first
Read query with no side effectsYesPure waste once nobody reads the resultThe driver supports it and sends a cancel to the server, not just abandoning the socket
Outbound call to another internal serviceYesFrees its capacity too, and the deadline should shrink downstreamThe deadline is propagated so the callee also stops (Deadlines vs Timeouts)
CPU-bound computationYes, but it needs check pointsOtherwise it is marked, not cancelled, and holds a coreCheck inside the loop, or chunk it, or run it where it can be killed
Cache or index writeUsually noCheap, idempotent, benefits the next requestIt is genuinely idempotent and a partial write is impossible
Payment authorization already sentNo"Cancelled" and "succeeded, unheard" are indistinguishable from hereAn idempotency key exists so the state can be reconciled later
Multi-step write with no transactionDangerousStopping between steps leaves the system inconsistentEither a transaction, or a compensating action, or do not cancel
Audit or billing recordNoMust complete regardless of who is listeningIt has an owner that outlives the request (Orphaned Tasks)
Should this be cancelled? Answer per operation, not per system.

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.

How it works
  • 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.
Interleavings that matter
  • 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.
What it guarantees — and does not
  • 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).
Where contention appears
  • 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).
How it fails
  • 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.
When it helps
  • 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 it hurts
  • 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.
How you would know
  • 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.
Complexity it introduces
  • 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.
Simpler alternatives
  • 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

Cancelling a parent task
Cancellation sets a flag and hopes. Whether anything stops depends entirely on whether the child ever looks at it.
parent scope
supervising 3 children
cancel() → awaiting children
child A · HTTP fetch
HTTP fetch
child B · row loop
row loop
child C · image resize
image resize
ignoring the flag
↑ cancel()
runningreadywaitingblockedidlems
Step 1/5. All three children running under one parent scope.
cancel requested at
60 ms
last child actually stopped
220 ms
work done after cancel
184 ms
orphaned tasks
1
# 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 kill
Child C was marked cancelled at 60 ms and kept computing until 220 ms. This is the whole lesson: `cancel()` sets a flag. It does not stop a thread, it does not interrupt a tight loop, and in a runtime with cooperative scheduling it will not even get a chance to run until the loop yields. For the 160 ms after cancellation, C is an orphan — burning CPU, holding a database connection and possibly writing results for a request that no longer exists. The parent, if it is honest, is blocked waiting for it, which is why "cancel the request" can hang. Every long-running body needs a check on its loop, and every blocking call needs a cancellable variant or a timeout.
1/5SIMULATEDRUNTIME-SPECIFIC

What people believe, and what is true

Claim

I called cancel(), so the task stopped.

Reality

You recorded a request. The task stops when it next checks. If it never checks, it never stops, and nothing will tell you.

Claim

Cancellation rolls back what the task did.

Reality

It stops future work. Everything already applied stays applied — that is what transactions and compensating actions are for.

Claim

Cancelled requests are errors.

Reality

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.

Apply it